优化
This commit is contained in:
+2
-1
@@ -41,7 +41,8 @@ public class LastlegOrderController {
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping("/internal/lastleg/events")
|
||||
@Operation(summary = "Receive a first-leg loading-completed event")
|
||||
@Operation(summary = "Receive a first-leg loading-completed event",
|
||||
description = "V2 orders[].destinationType is FBA or OVERSEAS. FBA may use package counts; OVERSEAS requires one carton mark per loaded package. Legacy events are accepted only when the destination type can be inferred safely.")
|
||||
public ApiResponse<Integer> receive(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope event) {
|
||||
|
||||
@@ -11,6 +11,8 @@ public record LastlegOrderSummary(
|
||||
String orderNo,
|
||||
String orderType,
|
||||
String orderTypeName,
|
||||
@Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"})
|
||||
String destinationType,
|
||||
String sourceInboundId,
|
||||
String inboundCode,
|
||||
String sourceOrderNo,
|
||||
|
||||
@@ -25,6 +25,7 @@ public class LastlegInboundOrder {
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String destinationType;
|
||||
private Integer packageCount;
|
||||
private Integer productQuantity;
|
||||
private Integer palletCount;
|
||||
|
||||
@@ -15,6 +15,7 @@ public class LastlegOrder {
|
||||
private String orderNo;
|
||||
private String customerId;
|
||||
private String orderType;
|
||||
private String destinationType;
|
||||
private String inboundCode;
|
||||
private String sourceRegion;
|
||||
private String sourceInboundId;
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ public class InboundPlanLine {
|
||||
private Long planId;
|
||||
private Long orderId;
|
||||
private String inboundCode;
|
||||
private String destinationType;
|
||||
private String fbaWarehouseCode;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pda/v1/last-leg/receiving-containers")
|
||||
@Tag(name = "Overseas PDA container receiving")
|
||||
public class ReceivingContainerController {
|
||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||
@Resource private ReceivingContainerService service;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@Operation(summary = "List pending receiving containers for the authenticated customer")
|
||||
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey()));
|
||||
}
|
||||
|
||||
@GetMapping("/{containerNo}")
|
||||
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String containerNo) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.detail(identity.customerId(), identity.operatorKey(), containerNo));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerNo}/prepare")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.prepare(identity.customerId(), identity.operatorKey(), identity.operatorName(),
|
||||
containerNo, request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
Identity identity = identity(user);
|
||||
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
|
||||
return ApiResponse.success(service.uploadPhoto(identity.customerId(), identity.operatorKey(), user.userId(), file));
|
||||
}
|
||||
|
||||
@PostMapping("/current/sign")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.sign(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/current")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.current(identity.customerId(), identity.operatorKey()));
|
||||
}
|
||||
|
||||
@PostMapping("/current/cartons/resolve")
|
||||
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/current/pallets/resolve")
|
||||
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.resolvePallet(identity.customerId(), identity.operatorKey(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/current/receipts/bulk")
|
||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.receiveBulk(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/current/receipts/carton")
|
||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.receiveCarton(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/current/complete")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.complete(identity.customerId(), identity.operatorKey(), request));
|
||||
}
|
||||
|
||||
private static Identity identity(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated PDA customer identity is required");
|
||||
}
|
||||
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
||||
}
|
||||
String operator = String.valueOf(user.userId());
|
||||
return new Identity(String.valueOf(user.tenantId()), operator,
|
||||
user.username() == null || user.username().isBlank() ? operator : user.username());
|
||||
}
|
||||
|
||||
private record Identity(String customerId, String operatorKey, String operatorName) { }
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class ReceivingContainerRequests {
|
||||
private ReceivingContainerRequests() { }
|
||||
|
||||
public record Action(@NotBlank String idempotencyKey) { }
|
||||
public record Sign(@NotEmpty List<@Positive Long> photoFileIds, @NotBlank String idempotencyKey) { }
|
||||
public record CartonResolve(@NotBlank String cartonMark) { }
|
||||
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
|
||||
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
|
||||
@Positive int packageCount, @NotBlank String idempotencyKey) { }
|
||||
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
|
||||
@NotBlank String idempotencyKey) { }
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class ReceivingContainerResponses {
|
||||
private ReceivingContainerResponses() { }
|
||||
|
||||
public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount,
|
||||
int totalPackages, int receivedPackages, boolean currentAccount) { }
|
||||
|
||||
public record Session(String containerNo, String status, String operatorName, int version,
|
||||
List<Long> photoFileIds, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
LocalDateTime receivingAt, LocalDateTime completedAt,
|
||||
int totalPackages, int receivedPackages, int pendingPackages,
|
||||
List<Inbound> inbounds) { }
|
||||
|
||||
public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||
int receivedPackages, int pendingPackages) { }
|
||||
|
||||
public record CartonResolved(String cartonMark, String skuCode, String inboundCode,
|
||||
String destinationType, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||
int receivedPackages, int pendingPackages) { }
|
||||
|
||||
public record PalletResolved(String palletCode, long warehouseId, String status,
|
||||
String fbaWarehouseCode, String inboundCode,
|
||||
String destinationType, boolean compatible) { }
|
||||
|
||||
public record Receipt(String result, String containerNo, String inboundCode,
|
||||
String destinationType, String palletCode, int acceptedPackages,
|
||||
int receivedPackages, int pendingPackages, String sessionStatus) { }
|
||||
|
||||
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_receiving_container_operation")
|
||||
public class ReceivingContainerOperation {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long sessionId;
|
||||
private String customerId;
|
||||
private String operatorKey;
|
||||
private String action;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String requestJson;
|
||||
private String resultJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.annotation.Version;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_receiving_container_session")
|
||||
public class ReceivingContainerSession {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String containerNo;
|
||||
private String operatorKey;
|
||||
private String operatorName;
|
||||
private String activeOperatorKey;
|
||||
private String activeContainerKey;
|
||||
private String status;
|
||||
private String photoFileIds;
|
||||
private LocalDateTime preparedAt;
|
||||
private LocalDateTime signedAt;
|
||||
private LocalDateTime receivingAt;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
@Version
|
||||
private Integer version;
|
||||
}
|
||||
+19
-2
@@ -35,7 +35,9 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode());
|
||||
}).body(WarehousePalletResponse.class);
|
||||
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
|
||||
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId());
|
||||
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
|
||||
response.data().warehouseId(), response.data().locationId(), response.data().status(),
|
||||
response.data().fbaWarehouseCode(), response.data().version());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,8 +84,23 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public WarehousePalletReference resolvePallet(String palletCode) {
|
||||
WarehousePalletResponse response = client.get()
|
||||
.uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode)
|
||||
.header("X-Internal-Service-Token", internalToken)
|
||||
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode());
|
||||
}).body(WarehousePalletResponse.class);
|
||||
if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response");
|
||||
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
|
||||
response.data().warehouseId(), response.data().locationId(), response.data().status(),
|
||||
response.data().fbaWarehouseCode(), response.data().version());
|
||||
}
|
||||
|
||||
private record WarehousePalletResponse(WarehousePalletData data) {}
|
||||
private record WarehousePalletData(long id, String palletCode, long locationId) {}
|
||||
private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode, int version) {}
|
||||
private record ReserveEnvelope(ReserveData data) {}
|
||||
private record ReserveData(long lockId) {}
|
||||
}
|
||||
|
||||
+7
-1
@@ -6,6 +6,12 @@ public interface WarehouseResourceGateway {
|
||||
String businessType, String businessId, String reservationKey);
|
||||
void scrap(String customerId, long resourceLockId);
|
||||
void scrapPallet(String customerId, long palletId);
|
||||
WarehousePalletReference resolvePallet(String palletCode);
|
||||
|
||||
record WarehousePalletReference(long palletId, String palletNo, long locationId) {}
|
||||
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode, int version) {
|
||||
public WarehousePalletReference(long palletId, String palletNo, long locationId) {
|
||||
this(palletId, palletNo, 0L, locationId, null, null, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ReceivingContainerOperationMapper extends BaseMapper<ReceivingContainerOperation> {
|
||||
@Select("SELECT * FROM ll_receiving_container_operation WHERE customer_id=#{customerId} " +
|
||||
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
||||
ReceivingContainerOperation find(@Param("customerId") String customerId,
|
||||
@Param("operatorKey") String operatorKey,
|
||||
@Param("key") String key);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingContainerSession> {
|
||||
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
||||
"AND active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE")
|
||||
ReceivingContainerSession lockActiveByOperator(@Param("customerId") String customerId,
|
||||
@Param("activeOperatorKey") String activeOperatorKey);
|
||||
|
||||
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
||||
"AND active_operator_key=#{activeOperatorKey} LIMIT 1")
|
||||
ReceivingContainerSession findActiveByOperator(@Param("customerId") String customerId,
|
||||
@Param("activeOperatorKey") String activeOperatorKey);
|
||||
|
||||
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
||||
"AND active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE")
|
||||
ReceivingContainerSession lockActiveByContainer(@Param("customerId") String customerId,
|
||||
@Param("activeContainerKey") String activeContainerKey);
|
||||
}
|
||||
+81
-2
@@ -135,6 +135,74 @@ public class InboundPdaWorkflowService {
|
||||
return task(task, planNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void ensureSignedTask(String customerId, InboundPlan plan, String operatorId, String operatorName,
|
||||
List<Long> photoFileIds, LocalDateTime signedAt) {
|
||||
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
||||
if (task == null) {
|
||||
task = new InboundPdaTask();
|
||||
task.setId(IdWorker.getId()); task.setPlanId(plan.getId()); task.setCustomerId(customerId);
|
||||
task.setTaskNo("PDA" + task.getId()); task.setCreatedAt(signedAt); task.setVersion(0);
|
||||
repository.insert(task);
|
||||
}
|
||||
if ("COMPLETED".equals(task.getStatus())) return;
|
||||
task.setStatus("SIGNED"); task.setOperatorId(operatorId); task.setOperatorName(operatorName);
|
||||
task.setUnloadPhotoFileIds(write(photoFileIds)); task.setUnloadPhotoUploadedAt(signedAt);
|
||||
task.setEstimatedEmptyReturnAt(signedAt.plusDays(2)); task.setUpdatedAt(signedAt);
|
||||
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
|
||||
plan.setStatus("SIGNED"); plan.setUpdatedAt(signedAt); repository.update(plan);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet,
|
||||
WarehouseResourceGateway.WarehousePalletReference resource) {
|
||||
if (pallet.getWarehouseResourceLockId() != null) {
|
||||
if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return;
|
||||
throw conflict("Destination pallet is already bound to another physical pallet");
|
||||
}
|
||||
String destinationKey = destinationKey(pallet);
|
||||
String businessId = plan.getContainerNo() + ":" + destinationKey;
|
||||
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId();
|
||||
long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(),
|
||||
"INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey);
|
||||
pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId());
|
||||
pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED");
|
||||
pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int receiveBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line,
|
||||
InboundPlanPallet pallet, int packageCount,
|
||||
String operatorId, String operatorName) {
|
||||
InboundPdaTask task = requireActiveTask(customerId, plan);
|
||||
int received = repository.allocations(plan.getId()).stream()
|
||||
.filter(value -> Objects.equals(value.getLineId(), line.getId()))
|
||||
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
|
||||
int pending = value(line.getRequiredPackageCount()) - received;
|
||||
if (packageCount > pending) throw conflict("Received package count exceeds the inbound pending total");
|
||||
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
|
||||
if (allocation == null) {
|
||||
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
||||
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
|
||||
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
|
||||
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0);
|
||||
allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME);
|
||||
allocation.setStatus("RECEIVING"); allocation.setVersion(0); repository.insert(allocation);
|
||||
}
|
||||
BigDecimal perPackage = perPackageVolume(line);
|
||||
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + packageCount);
|
||||
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + packageCount);
|
||||
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm())
|
||||
.add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP));
|
||||
allocation.setStatus(allocation.getPickedPackageCount() == value(line.getRequiredPackageCount())
|
||||
? "RECEIVED" : "RECEIVING"); bump(allocation); repository.update(allocation);
|
||||
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + packageCount);
|
||||
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm())
|
||||
.add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP));
|
||||
repository.update(pallet); startPicking(task, plan, operatorId, operatorName);
|
||||
return packageCount;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
|
||||
PdaRequests.PackageOperation request) {
|
||||
@@ -395,14 +463,18 @@ public class InboundPdaWorkflowService {
|
||||
throw conflict("All line picked package totals must exactly equal required package totals");
|
||||
}
|
||||
}
|
||||
Map<Long, String> destinationTypes = lines.stream().collect(java.util.stream.Collectors.toMap(
|
||||
InboundPlanLine::getId, line -> destinationType(line)));
|
||||
List<InboundPlanCarton> cartons = repository.cartons(plan.getId());
|
||||
if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) {
|
||||
if (cartons.stream().anyMatch(carton -> "OVERSEAS".equals(destinationTypes.get(carton.getLineId()))
|
||||
&& !"ON_PALLET".equals(carton.getStatus()))) {
|
||||
throw conflict("All expected carton marks must be scanned onto a pallet before stock-in");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
|
||||
for (InboundPlanCarton carton : cartons) {
|
||||
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
|
||||
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
|
||||
carton.setReceivedAt(now); carton.setUpdatedAt(now);
|
||||
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
|
||||
}
|
||||
task.setStatus("COMPLETED");
|
||||
@@ -705,6 +777,13 @@ public class InboundPdaWorkflowService {
|
||||
return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static String destinationType(InboundPlanLine line) {
|
||||
if (line.getDestinationType() != null && !line.getDestinationType().isBlank()) {
|
||||
return line.getDestinationType().trim().toUpperCase();
|
||||
}
|
||||
return line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() ? "OVERSEAS" : "FBA";
|
||||
}
|
||||
|
||||
private InboundPdaOperation replay(String customerId, String action, String key, Object request) {
|
||||
InboundPdaOperation operation = repository.operation(customerId, key);
|
||||
if (operation == null) return null;
|
||||
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class ReceivingContainerService {
|
||||
@Resource private InboundPlanMapper plans;
|
||||
@Resource private InboundPlanLineMapper lines;
|
||||
@Resource private InboundPlanPalletMapper pallets;
|
||||
@Resource private InboundPlanPalletAllocationMapper allocations;
|
||||
@Resource private InboundPlanCartonMapper cartons;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private ReceivingContainerSessionMapper sessions;
|
||||
@Resource private ReceivingContainerOperationMapper operations;
|
||||
@Resource private WarehouseResourceGateway warehouse;
|
||||
@Resource private UnloadPhotoFileGateway unloadPhotos;
|
||||
@Resource private InboundPdaWorkflowService workflow;
|
||||
@Resource private ObjectMapper sourceObjectMapper;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@PostConstruct
|
||||
void initializeObjectMapper() {
|
||||
objectMapper = sourceObjectMapper.copy()
|
||||
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
|
||||
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||
}
|
||||
|
||||
public List<ReceivingContainerResponses.Candidate> candidates(String customerId, String operatorKey) {
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey));
|
||||
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
|
||||
plans.selectList(new LambdaQueryWrapper<InboundPlan>().eq(InboundPlan::getCustomerId, customerId)
|
||||
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
|
||||
if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) {
|
||||
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
|
||||
}
|
||||
});
|
||||
return grouped.entrySet().stream().map(entry -> {
|
||||
Totals totals = totals(entry.getValue());
|
||||
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
|
||||
return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN",
|
||||
entry.getValue().size(), totals.inboundCount(), totals.total(), totals.received(), active);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) {
|
||||
List<InboundPlan> containerPlans = requirePlans(customerId, containerNo);
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey));
|
||||
if (current != null && !containerNo.equals(current.getContainerNo()) && !"WAIT_SIGN".equals(current.getStatus())) {
|
||||
throw conflict("Current account is locked to another signed container");
|
||||
}
|
||||
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
|
||||
customerId, operatorKey, containerNo), containerPlans);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName,
|
||||
String containerNo, ReceivingContainerRequests.Action request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
List<InboundPlan> containerPlans = requirePlans(customerId, containerNo);
|
||||
String activeOperatorKey = activeOperatorKey(customerId, operatorKey);
|
||||
ReceivingContainerSession active = sessions.lockActiveByOperator(customerId, activeOperatorKey);
|
||||
if (active != null) {
|
||||
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
|
||||
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
|
||||
save(active, "PREPARE", request.idempotencyKey(), request, result); return result;
|
||||
}
|
||||
if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) {
|
||||
throw conflict("Container is already signed or prepared by another account");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ReceivingContainerSession session = new ReceivingContainerSession();
|
||||
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
|
||||
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
|
||||
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(customerId, containerNo));
|
||||
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
|
||||
session.setVersion(0);
|
||||
try { sessions.insert(session); }
|
||||
catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); }
|
||||
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
|
||||
save(session, "PREPARE", request.idempotencyKey(), request, result); return result;
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId,
|
||||
MultipartFile file) {
|
||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
|
||||
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
|
||||
return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Session sign(String customerId, String operatorKey, String operatorName,
|
||||
ReceivingContainerRequests.Sign request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "SIGN", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
|
||||
List<Long> photos = request.photoFileIds().stream().distinct().sorted().toList();
|
||||
if (photos.size() != request.photoFileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated");
|
||||
photos.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
session.setPhotoFileIds(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
|
||||
session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session);
|
||||
for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) {
|
||||
workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photos, now);
|
||||
}
|
||||
ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo()));
|
||||
save(session, "SIGN", request.idempotencyKey(), request, result); return result;
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.Session current(String customerId, String operatorKey) {
|
||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, false);
|
||||
return snapshot(session, requirePlans(customerId, session.getContainerNo()));
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.CartonResolved resolveCarton(String customerId, String operatorKey,
|
||||
ReceivingContainerRequests.CartonResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
|
||||
if (carton == null) throw notFound("Carton mark was not found");
|
||||
InboundRef ref = refs(customerId, session.getContainerNo()).stream()
|
||||
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
|
||||
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
|
||||
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
|
||||
throw conflict("FBA inbound must use bulk count instead of carton scan");
|
||||
}
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||
return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(),
|
||||
inbound.inboundCode(), inbound.destinationType(), inbound.destinationWarehouseId(),
|
||||
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
|
||||
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey,
|
||||
ReceivingContainerRequests.PalletResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode());
|
||||
validatePallet(ref, pallet);
|
||||
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
|
||||
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName,
|
||||
ReceivingContainerRequests.BulkReceive request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
|
||||
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
|
||||
}
|
||||
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
|
||||
validatePallet(ref, resource);
|
||||
InboundPlanPallet pallet = choosePallet(ref, resource);
|
||||
workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
|
||||
int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet,
|
||||
request.packageCount(), operatorKey, operatorName);
|
||||
markReceiving(session);
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted,
|
||||
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
|
||||
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName,
|
||||
ReceivingContainerRequests.CartonReceive request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
|
||||
if (carton == null) throw notFound("Carton mark was not found");
|
||||
InboundRef ref = refs(customerId, session.getContainerNo()).stream()
|
||||
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
|
||||
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
|
||||
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
|
||||
throw conflict("FBA destination must use bulk count");
|
||||
}
|
||||
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
|
||||
validatePallet(ref, resource);
|
||||
InboundPlanPallet pallet = choosePallet(ref, resource);
|
||||
workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
|
||||
workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton(
|
||||
request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName));
|
||||
markReceiving(session);
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
|
||||
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
|
||||
save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Session complete(String customerId, String operatorKey,
|
||||
ReceivingContainerRequests.Action request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
List<InboundPlan> containerPlans = requirePlans(customerId, session.getContainerNo());
|
||||
Totals totals = totals(containerPlans);
|
||||
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
|
||||
for (InboundPlan plan : containerPlans) workflow.complete(customerId, plan.getPlanNo());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
|
||||
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
|
||||
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
|
||||
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
|
||||
}
|
||||
|
||||
private ReceivingContainerSession requireCurrent(String customerId, String operatorKey, boolean lock) {
|
||||
String key = activeOperatorKey(customerId, operatorKey);
|
||||
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(customerId, key)
|
||||
: sessions.findActiveByOperator(customerId, key);
|
||||
if (session == null) throw notFound("Current receiving container session was not found");
|
||||
return session;
|
||||
}
|
||||
|
||||
private ReceivingContainerSession requireSigned(String customerId, String operatorKey) {
|
||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
||||
if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) {
|
||||
throw conflict("Sign the receiving container before receiving cargo");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private List<InboundPlan> requirePlans(String customerId, String containerNo) {
|
||||
List<InboundPlan> values = plansForContainer(customerId, containerNo);
|
||||
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
|
||||
throw conflict("Receiving container is already completed");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<InboundPlan> plansForContainer(String customerId, String containerNo) {
|
||||
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo)
|
||||
.orderByAsc(InboundPlan::getId));
|
||||
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<InboundRef> refs(String customerId, String containerNo) {
|
||||
List<InboundRef> result = new ArrayList<>();
|
||||
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
|
||||
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
|
||||
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
|
||||
LastlegOrder order = orders.selectById(line.getOrderId());
|
||||
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing");
|
||||
result.add(new InboundRef(plan, line, order));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) {
|
||||
List<InboundRef> matches = refs(customerId, containerNo).stream()
|
||||
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
|
||||
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
|
||||
if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container");
|
||||
return matches.get(0);
|
||||
}
|
||||
|
||||
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) {
|
||||
List<InboundPlanPallet> candidates = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()
|
||||
.filter(value -> sameDestination(ref.line(), value))
|
||||
.filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId()))
|
||||
.sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId()))
|
||||
.thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount()))
|
||||
.toList();
|
||||
if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code");
|
||||
return candidates.get(0);
|
||||
}
|
||||
|
||||
private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) {
|
||||
if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped");
|
||||
long actualWarehouse;
|
||||
try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); }
|
||||
catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); }
|
||||
if (pallet.warehouseId() != actualWarehouse) throw conflict("Pallet belongs to another overseas warehouse");
|
||||
String type = destinationType(ref.line(), ref.order());
|
||||
if ("FBA".equals(type)) {
|
||||
if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank()
|
||||
|| !ref.line().getFbaWarehouseCode().equalsIgnoreCase(String.valueOf(pallet.fbaWarehouseCode()))) {
|
||||
throw conflict("Pallet FBA destination does not match the inbound code");
|
||||
}
|
||||
} else {
|
||||
if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) {
|
||||
throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton");
|
||||
}
|
||||
if (ref.line().getDestinationWarehouseId() != null && !ref.line().getDestinationWarehouseId().isBlank()) {
|
||||
try {
|
||||
if (Long.parseLong(ref.line().getDestinationWarehouseId()) != pallet.warehouseId()) {
|
||||
throw conflict("Pallet destination warehouse does not match the inbound code");
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Destination codes that are not resource IDs are still protected by the actual overseas warehouse check.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean sameDestination(InboundPlanLine line, InboundPlanPallet pallet) {
|
||||
if (line.getDestinationWarehouseId() != null && !line.getDestinationWarehouseId().isBlank()) {
|
||||
return line.getDestinationWarehouseId().equals(pallet.getDestinationWarehouseId());
|
||||
}
|
||||
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
|
||||
}
|
||||
|
||||
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
|
||||
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream()
|
||||
.map(this::inbound).toList();
|
||||
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
||||
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
|
||||
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(),
|
||||
value(session.getVersion()), photoIds(session), session.getPreparedAt(), session.getSignedAt(),
|
||||
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
|
||||
}
|
||||
|
||||
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
|
||||
int received = allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
|
||||
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
|
||||
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream()
|
||||
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
|
||||
int total = value(ref.line().getRequiredPackageCount());
|
||||
return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()),
|
||||
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
|
||||
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
|
||||
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
|
||||
}
|
||||
|
||||
private Totals totals(List<InboundPlan> containerPlans) {
|
||||
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getCustomerId(),
|
||||
containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList();
|
||||
return new Totals(values.size(), values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
|
||||
values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
|
||||
}
|
||||
|
||||
private String destinationType(InboundPlanLine line, LastlegOrder order) {
|
||||
String value = line.getDestinationType();
|
||||
if (value == null || value.isBlank()) value = order.getDestinationType();
|
||||
if (value == null || value.isBlank()) value = line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank()
|
||||
? "OVERSEAS" : "FBA";
|
||||
value = value.trim().toUpperCase();
|
||||
if (!List.of("FBA", "OVERSEAS").contains(value)) throw conflict("Unsupported destination type");
|
||||
return value;
|
||||
}
|
||||
|
||||
private void markReceiving(ReceivingContainerSession session) {
|
||||
if (!"RECEIVING".equals(session.getStatus())) {
|
||||
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
|
||||
}
|
||||
session.setUpdatedAt(LocalDateTime.now()); update(session);
|
||||
}
|
||||
|
||||
private void update(ReceivingContainerSession session) {
|
||||
if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently");
|
||||
}
|
||||
|
||||
private ReceivingContainerSession waitingView(String customerId, String operatorKey, String containerNo) {
|
||||
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
|
||||
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
|
||||
return value;
|
||||
}
|
||||
|
||||
private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) {
|
||||
ReceivingContainerOperation value = operations.find(customerId, operatorKey, key);
|
||||
if (value == null) return null;
|
||||
if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) {
|
||||
throw conflict("Idempotency key was already used with a different request");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) {
|
||||
ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId());
|
||||
value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId());
|
||||
value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key);
|
||||
value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result));
|
||||
value.setCreatedAt(LocalDateTime.now()); operations.insert(value);
|
||||
}
|
||||
|
||||
private List<Long> photoIds(ReceivingContainerSession session) {
|
||||
if (session.getPhotoFileIds() == null || session.getPhotoFileIds().isBlank()) return List.of();
|
||||
try { return objectMapper.readValue(session.getPhotoFileIds(), new TypeReference<List<Long>>() { }); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read receiving photo IDs", exception); }
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
|
||||
private String write(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
|
||||
private <T> T read(ReceivingContainerOperation operation, Class<T> type) {
|
||||
try { return objectMapper.readValue(operation.getResultJson(), type); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
|
||||
private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; }
|
||||
private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; }
|
||||
private static int value(Integer value) { return value == null ? 0 : value; }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
|
||||
private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { }
|
||||
private record Totals(int inboundCount, int total, int received) { }
|
||||
}
|
||||
+2
@@ -162,6 +162,8 @@ public class InboundPlanService {
|
||||
line.setPlanId(plan.getId());
|
||||
line.setOrderId(order.orderId());
|
||||
line.setInboundCode(order.inboundCode());
|
||||
line.setDestinationType(order.fbaWarehouseCode() == null || order.fbaWarehouseCode().isBlank()
|
||||
? "OVERSEAS" : "FBA");
|
||||
line.setFbaWarehouseCode(order.fbaWarehouseCode());
|
||||
line.setDestinationWarehouseId(order.destinationWarehouseId());
|
||||
line.setDestinationWarehouseCode(order.destinationWarehouseCode());
|
||||
|
||||
@@ -179,7 +179,7 @@ public class LastlegOrderService {
|
||||
|
||||
private LastlegOrderSummary summary(LastlegOrder o) {
|
||||
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
|
||||
LastlegOrderType.labelOf(o.getOrderType()), o.getSourceInboundId(),
|
||||
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
|
||||
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
|
||||
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
|
||||
|
||||
+58
-4
@@ -107,12 +107,27 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String destinationType = destinationType(payload, order);
|
||||
for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) {
|
||||
requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
||||
if ("OVERSEAS".equals(destinationType)) {
|
||||
requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
||||
} else {
|
||||
validateOptionalFbaCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOptionalFbaCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
|
||||
Set<String> orderCartonMarks) {
|
||||
List<String> marks = cartonMarks == null ? List.of() : cartonMarks;
|
||||
if (skuCode == null || skuCode.isBlank()) {
|
||||
throw new IllegalArgumentException("V2 尾程交接事件缺少 SKU");
|
||||
}
|
||||
if (marks.isEmpty()) return;
|
||||
requireSkuCartonMapping(skuCode, loadedPackages, marks, orderCartonMarks);
|
||||
}
|
||||
|
||||
private void requireSkuCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
|
||||
Set<String> orderCartonMarks) {
|
||||
if (skuCode == null || skuCode.isBlank()) {
|
||||
@@ -139,6 +154,7 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
LastlegOrder order=new LastlegOrder();
|
||||
String orderType = source.orderType();
|
||||
String destinationType = destinationType(payload, source);
|
||||
String customerId = source.customerId() == null || source.customerId().isBlank()
|
||||
? event.tenantId() : source.customerId();
|
||||
if (!event.tenantId().equals(customerId)) {
|
||||
@@ -150,6 +166,7 @@ public class LoadingCompletedConsumer {
|
||||
throw new IllegalArgumentException("One-click dropship event has no pallet snapshot");
|
||||
}
|
||||
order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType);
|
||||
order.setDestinationType(destinationType);
|
||||
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
||||
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
||||
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
||||
@@ -186,7 +203,7 @@ public class LoadingCompletedConsumer {
|
||||
detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode());
|
||||
detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo());
|
||||
detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail);
|
||||
createCartonLabels(customerId, order.getId(), item);
|
||||
createCartonLabels(customerId, order.getId(), destinationType, item);
|
||||
}
|
||||
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId);
|
||||
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
|
||||
@@ -216,6 +233,7 @@ public class LoadingCompletedConsumer {
|
||||
snapshot.setDestinationWarehouseId(source.destinationWarehouseId());
|
||||
snapshot.setDestinationWarehouseCode(source.destinationWarehouseCode());
|
||||
snapshot.setDestinationWarehouseName(source.destinationWarehouseName());
|
||||
snapshot.setDestinationType(order.getDestinationType());
|
||||
snapshot.setPackageCount(source.packageCount()); snapshot.setProductQuantity(source.productQuantity());
|
||||
snapshot.setPalletCount(source.palletCount()); snapshot.setGrossWeightKg(source.grossWeightKg());
|
||||
snapshot.setNetWeightKg(source.netWeightKg()); snapshot.setVolumeCbm(source.volumeCbm());
|
||||
@@ -243,9 +261,11 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
private void createCartonLabels(String customerId, Long orderId, FirstLegLoadingPlanCompletedV1.Item item) {
|
||||
private void createCartonLabels(String customerId, Long orderId, String destinationType,
|
||||
FirstLegLoadingPlanCompletedV1.Item item) {
|
||||
if (!"OVERSEAS".equals(destinationType)) return;
|
||||
List<String> marks = item.cartonMarks() == null ? List.of() : item.cartonMarks();
|
||||
if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。
|
||||
if (marks.isEmpty()) return;
|
||||
if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) {
|
||||
throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛");
|
||||
}
|
||||
@@ -261,6 +281,40 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
private String destinationType(FirstLegLoadingPlanCompletedV1 payload,
|
||||
FirstLegLoadingPlanCompletedV1.Order order) {
|
||||
String explicit = normalizeDestinationType(order.destinationType());
|
||||
if (explicit != null) return explicit;
|
||||
|
||||
boolean fbaEvidence = order.items().stream().anyMatch(item -> hasText(item.fbaTrackingNo())
|
||||
|| hasText(item.shipmentNo()) || startsWithFba(item.destinationWarehouseName()));
|
||||
boolean overseasWarehouseIdentity = hasText(payload.warehouseId())
|
||||
&& Objects.equals(payload.warehouseId(), order.destinationWarehouseId());
|
||||
if (fbaEvidence && overseasWarehouseIdentity) {
|
||||
throw new IllegalArgumentException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId());
|
||||
}
|
||||
if (fbaEvidence) return "FBA";
|
||||
if (overseasWarehouseIdentity) return "OVERSEAS";
|
||||
throw new IllegalArgumentException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId());
|
||||
}
|
||||
|
||||
private String normalizeDestinationType(String value) {
|
||||
if (!hasText(value)) return null;
|
||||
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!"FBA".equals(normalized) && !"OVERSEAS".equals(normalized)) {
|
||||
throw new IllegalArgumentException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private boolean startsWithFba(String value) {
|
||||
return hasText(value) && value.trim().toUpperCase(java.util.Locale.ROOT).startsWith("FBA");
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
||||
if (source.pallets() != null && !source.pallets().isEmpty()) {
|
||||
return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(),
|
||||
|
||||
Reference in New Issue
Block a user