fix: close phase4 overseas acceptance gaps
This commit is contained in:
+18
-17
@@ -34,11 +34,11 @@ public class ReceivingContainerController {
|
||||
@Resource private ReceivingContainerService service;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@Operation(summary = "List pending receiving containers for the authenticated customer")
|
||||
@Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker")
|
||||
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey()));
|
||||
return ApiResponse.success(service.candidates(identity.operatorKey()));
|
||||
}
|
||||
|
||||
@GetMapping("/{containerNo}")
|
||||
@@ -46,14 +46,14 @@ public class ReceivingContainerController {
|
||||
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));
|
||||
return ApiResponse.success(service.detail(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(),
|
||||
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(),
|
||||
containerNo, request));
|
||||
}
|
||||
|
||||
@@ -62,20 +62,20 @@ public class ReceivingContainerController {
|
||||
@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));
|
||||
return ApiResponse.success(service.uploadPhoto(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));
|
||||
return ApiResponse.success(service.sign(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()));
|
||||
return ApiResponse.success(service.current(identity.operatorKey()));
|
||||
}
|
||||
|
||||
@PostMapping("/current/cartons/resolve")
|
||||
@@ -83,7 +83,7 @@ public class ReceivingContainerController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request));
|
||||
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/current/skus/resolve")
|
||||
@@ -91,7 +91,7 @@ public class ReceivingContainerController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.resolveSku(identity.customerId(), identity.operatorKey(), request));
|
||||
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/current/pallets/resolve")
|
||||
@@ -99,41 +99,42 @@ public class ReceivingContainerController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.resolvePallet(identity.customerId(), identity.operatorKey(), request));
|
||||
return ApiResponse.success(service.resolvePallet(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));
|
||||
return ApiResponse.success(service.receiveBulk(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));
|
||||
return ApiResponse.success(service.receiveCarton(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));
|
||||
return ApiResponse.success(service.complete(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 == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
|
||||
|| !"PLATFORM".equals(user.appCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA 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,
|
||||
return new Identity(operator,
|
||||
user.username() == null || user.username().isBlank() ? operator : user.username());
|
||||
}
|
||||
|
||||
private record Identity(String customerId, String operatorKey, String operatorName) { }
|
||||
private record Identity(String operatorKey, String operatorName) { }
|
||||
}
|
||||
|
||||
+3
-4
@@ -8,9 +8,8 @@ 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,
|
||||
@Select("SELECT * FROM ll_receiving_container_operation " +
|
||||
"WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
||||
ReceivingContainerOperation find(@Param("operatorKey") String operatorKey,
|
||||
@Param("key") String key);
|
||||
}
|
||||
|
||||
+9
-12
@@ -8,18 +8,15 @@ 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 active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE")
|
||||
ReceivingContainerSession lockActiveByOperator(@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 active_operator_key=#{activeOperatorKey} LIMIT 1")
|
||||
ReceivingContainerSession findActiveByOperator(@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);
|
||||
@Select("SELECT * FROM ll_receiving_container_session " +
|
||||
"WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE")
|
||||
ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey);
|
||||
}
|
||||
|
||||
+78
-48
@@ -80,16 +80,17 @@ public class ReceivingContainerService {
|
||||
.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));
|
||||
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey) {
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
|
||||
plans.selectList(new LambdaQueryWrapper<InboundPlan>().eq(InboundPlan::getCustomerId, customerId)
|
||||
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||
.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 -> {
|
||||
requireSingleCustomer(entry.getValue(), entry.getKey());
|
||||
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",
|
||||
@@ -98,9 +99,10 @@ public class ReceivingContainerService {
|
||||
}).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));
|
||||
public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) {
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||
if (current != null && !containerNo.equals(current.getContainerNo())) {
|
||||
throw conflict("Current account is locked to another selected container");
|
||||
}
|
||||
@@ -109,27 +111,28 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName,
|
||||
String containerNo, ReceivingContainerRequests.Action request) {
|
||||
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo,
|
||||
ReceivingContainerRequests.Action request) {
|
||||
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), command);
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
|
||||
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);
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||
String activeOperatorKey = activeOperatorKey(operatorKey);
|
||||
ReceivingContainerSession active = sessions.lockActiveByOperator(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(), command, result); return result;
|
||||
}
|
||||
if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) {
|
||||
if (sessions.lockActiveByContainer(activeContainerKey(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.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
|
||||
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
|
||||
session.setVersion(0);
|
||||
try { sessions.insert(session); }
|
||||
@@ -138,20 +141,21 @@ public class ReceivingContainerService {
|
||||
save(session, "PREPARE", request.idempotencyKey(), command, result); return result;
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId,
|
||||
MultipartFile file) {
|
||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
||||
public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) {
|
||||
ReceivingContainerSession session = requireCurrent(operatorKey, true);
|
||||
String customerId = session.getCustomerId();
|
||||
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,
|
||||
public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName,
|
||||
ReceivingContainerRequests.Sign request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "SIGN", request.idempotencyKey(), request);
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
||||
ReceivingContainerSession session = requireCurrent(operatorKey, true);
|
||||
String customerId = session.getCustomerId();
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
|
||||
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
|
||||
List<Long> photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList();
|
||||
@@ -166,14 +170,15 @@ public class ReceivingContainerService {
|
||||
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.Session current(String operatorKey) {
|
||||
ReceivingContainerSession session = requireCurrent(operatorKey, false);
|
||||
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.CartonResolved resolveCarton(String customerId, String operatorKey,
|
||||
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
|
||||
ReceivingContainerRequests.CartonResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
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");
|
||||
@@ -190,9 +195,10 @@ public class ReceivingContainerService {
|
||||
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.SkuResolved resolveSku(String customerId, String operatorKey,
|
||||
public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey,
|
||||
ReceivingContainerRequests.SkuResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
List<SkuRef> matches = refs(customerId, session.getContainerNo()).stream()
|
||||
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
|
||||
.map(ref -> skuRef(ref, request.skuCode()))
|
||||
@@ -203,9 +209,10 @@ public class ReceivingContainerService {
|
||||
return skuResponse(matches.get(0));
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey,
|
||||
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
|
||||
ReceivingContainerRequests.PalletResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode());
|
||||
validatePallet(ref, pallet);
|
||||
@@ -214,11 +221,12 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName,
|
||||
public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName,
|
||||
ReceivingContainerRequests.BulkReceive request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
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");
|
||||
@@ -248,11 +256,12 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName,
|
||||
public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName,
|
||||
ReceivingContainerRequests.CartonReceive request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
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");
|
||||
@@ -284,11 +293,12 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Session complete(String customerId, String operatorKey,
|
||||
public ReceivingContainerResponses.Session complete(String operatorKey,
|
||||
ReceivingContainerRequests.Action request) {
|
||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
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");
|
||||
@@ -301,22 +311,33 @@ public class ReceivingContainerService {
|
||||
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);
|
||||
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
|
||||
String key = activeOperatorKey(operatorKey);
|
||||
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
|
||||
: sessions.findActiveByOperator(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);
|
||||
private ReceivingContainerSession requireSigned(String operatorKey) {
|
||||
ReceivingContainerSession session = requireCurrent(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 containerNo) {
|
||||
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
|
||||
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||
requireSingleCustomer(values, containerNo);
|
||||
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
|
||||
throw conflict("Receiving container is already completed");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<InboundPlan> requirePlans(String customerId, String containerNo) {
|
||||
List<InboundPlan> values = plansForContainer(customerId, containerNo);
|
||||
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||
@@ -334,6 +355,15 @@ public class ReceivingContainerService {
|
||||
return values;
|
||||
}
|
||||
|
||||
private String requireSingleCustomer(List<InboundPlan> values, String containerNo) {
|
||||
Set<String> customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim)
|
||||
.filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||
if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||
throw conflict("Receiving container must resolve to exactly one customer: " + containerNo);
|
||||
}
|
||||
return customers.iterator().next();
|
||||
}
|
||||
|
||||
private List<InboundRef> refs(String customerId, String containerNo) {
|
||||
List<InboundRef> result = new ArrayList<>();
|
||||
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
|
||||
@@ -576,9 +606,9 @@ public class ReceivingContainerService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) {
|
||||
private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) {
|
||||
key = idempotencyKey(key);
|
||||
ReceivingContainerOperation value = operations.find(customerId, operatorKey, key);
|
||||
ReceivingContainerOperation value = operations.find(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");
|
||||
@@ -618,8 +648,8 @@ public class ReceivingContainerService {
|
||||
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 String activeOperatorKey(String operatorKey) { return "OPERATOR:" + operatorKey; }
|
||||
private static String activeContainerKey(String containerNo) { return "CONTAINER:" + containerNo.trim().toUpperCase(java.util.Locale.ROOT); }
|
||||
private static String idempotencyKey(String value) {
|
||||
if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required");
|
||||
value = value.trim();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.inventory.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
@@ -40,8 +41,11 @@ public class InventoryLedger {
|
||||
private Long deltaPackages;
|
||||
private Long afterPackages;
|
||||
/** Quantity snapshot is retained even though current PDA operations are carton-count based. */
|
||||
@TableField(exist = false)
|
||||
private Long beforeQuantity;
|
||||
@TableField(exist = false)
|
||||
private Long deltaQuantity;
|
||||
@TableField(exist = false)
|
||||
private Long afterQuantity;
|
||||
private String operatorId;
|
||||
private String remark;
|
||||
|
||||
+12
-10
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.lastleg.procurement.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
@@ -17,24 +19,24 @@ public class OverseasProcurementOrder {
|
||||
private String orderNo;
|
||||
private String purchaseCategory;
|
||||
private String productName;
|
||||
private String productSpecification;
|
||||
private String productUrl;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productUrl;
|
||||
private Long purchaseQuantity;
|
||||
private BigDecimal budgetUnitPrice;
|
||||
private String currency;
|
||||
private String receivingAddress;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String purchaseWarehouseId;
|
||||
private String purchaseWarehouseName;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName;
|
||||
private Long inboundQuantity;
|
||||
private String domesticCarrier;
|
||||
private String domesticTrackingNo;
|
||||
private String overseasCarrier;
|
||||
private String overseasTrackingNo;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
|
||||
private String status;
|
||||
private String remark;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
|
||||
@@ -678,10 +678,10 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
|
||||
OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
||||
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
|
||||
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
@@ -709,7 +709,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||
|
||||
@@ -6,20 +6,20 @@ paths:
|
||||
/api/pda/v1/last-leg/receiving-containers/candidates:
|
||||
get:
|
||||
summary: 查询当前客户待签收柜号
|
||||
description: 权限 lastleg:pda:operate;客户与操作账号均从 PDA Token 获取,不接收 customerId。
|
||||
description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用,操作账号从 Token 获取,客户由所选柜业务事实唯一解析,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}:
|
||||
get:
|
||||
summary: 查询待签收柜号详情
|
||||
description: 权限 lastleg:pda:operate;返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。
|
||||
description: 权限 lastleg:pda:operate;按柜号唯一解析客户并返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare:
|
||||
post:
|
||||
summary: 选择当前待签收柜号
|
||||
description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以账号、柜号双唯一键防止切柜或多人同时处理。
|
||||
description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
||||
|
||||
+11
-1
@@ -47,7 +47,7 @@ class ReceivingContainerContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaContainsCustomerScopedPlanChildrenAndContainerSessionLocks() throws IOException {
|
||||
void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException {
|
||||
String schema = resource("db/lastleg.sql").toLowerCase();
|
||||
String unified = java.nio.file.Files.readString(
|
||||
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase();
|
||||
@@ -62,7 +62,10 @@ class ReceivingContainerContractTest {
|
||||
assertTrue(schema.contains("create table if not exists ll_receiving_container_session"));
|
||||
assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)"));
|
||||
assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)"));
|
||||
assertTrue(schema.contains("平台操作账号全局唯一锁键"));
|
||||
assertTrue(schema.contains("柜号全局唯一锁键"));
|
||||
assertTrue(schema.contains("create table if not exists ll_receiving_container_operation"));
|
||||
assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)"));
|
||||
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
|
||||
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
|
||||
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
|
||||
@@ -75,6 +78,11 @@ class ReceivingContainerContractTest {
|
||||
assertTrue(unified.contains("customer_id is null or v.customer_id=''"));
|
||||
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
|
||||
assertTrue(unified.contains("signal sqlstate '45000'"));
|
||||
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
|
||||
assertTrue(unified.contains("concat('operator:',operator_key)"));
|
||||
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
|
||||
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
|
||||
assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -94,6 +102,8 @@ class ReceivingContainerContractTest {
|
||||
assertTrue(successResponses.stream().allMatch(line ->
|
||||
line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving")));
|
||||
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
|
||||
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
||||
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
||||
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
|
||||
assertFalse(receivingPaths.contains("photoFileIds"));
|
||||
}
|
||||
|
||||
+18
-11
@@ -30,25 +30,32 @@ class ReceivingContainerControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() {
|
||||
when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
|
||||
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
|
||||
when(service.candidates("42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
|
||||
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
|
||||
|
||||
controller.candidates(pda());
|
||||
controller.candidates(picker());
|
||||
|
||||
verify(service).candidates("100", "42");
|
||||
verify(service).candidates("42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformIdentityCannotEnterPdaReceivingApi() {
|
||||
AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
||||
Set.of("lastleg:pda:operate"));
|
||||
assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class)
|
||||
void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() {
|
||||
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB",
|
||||
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
|
||||
assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("401");
|
||||
}
|
||||
|
||||
private static AuthenticatedUser pda() {
|
||||
return new AuthenticatedUser(42L, 100L, 100L, 8L, "receiver", "PDA", "PDA",
|
||||
Set.of("lastleg:pda:operate"));
|
||||
@Test
|
||||
void platformIdentityWithoutPickerAuthorityIsForbidden() {
|
||||
AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("403");
|
||||
}
|
||||
|
||||
private static AuthenticatedUser picker() {
|
||||
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
|
||||
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
|
||||
}
|
||||
}
|
||||
|
||||
+73
-25
@@ -35,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
@@ -87,35 +88,56 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(lines.selectList(any())).thenReturn(List.of());
|
||||
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
||||
service.prepare("100", "7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
|
||||
service.prepare("7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
|
||||
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
|
||||
verify(sessions).insert(selected.capture());
|
||||
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
|
||||
assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7");
|
||||
assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A");
|
||||
ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class);
|
||||
verify(operations).insert(saved.capture());
|
||||
when(operations.find("100", "7", "same-key")).thenReturn(saved.getValue());
|
||||
when(operations.find("7", "same-key")).thenReturn(saved.getValue());
|
||||
|
||||
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
|
||||
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
|
||||
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() {
|
||||
InboundPlan first = plan("CONT-A");
|
||||
InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200");
|
||||
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
|
||||
when(lines.selectList(any())).thenReturn(List.of());
|
||||
|
||||
assertThat(service.candidates("7")).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
|
||||
|
||||
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
|
||||
org.mockito.Mockito.reset(plans, sessions);
|
||||
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
|
||||
assertThatThrownBy(() -> service.candidates("7")).isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("exactly one customer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
|
||||
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
||||
when(sessions.findActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
assertThatThrownBy(() -> service.detail("100", "7", "CONT-B"))
|
||||
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
assertThatThrownBy(() -> service.detail("7", "CONT-B"))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
|
||||
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
|
||||
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
|
||||
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
||||
|
||||
org.mockito.Mockito.reset(sessions, operations);
|
||||
when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
|
||||
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
|
||||
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
|
||||
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
|
||||
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
|
||||
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
||||
}
|
||||
@@ -123,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
@Test
|
||||
void destinationModesAreHardRejectedBeforeWarehouseCalls() {
|
||||
stubCurrent("OVERSEAS", "CONT-A");
|
||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
||||
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verifyNoInteractions(warehouse);
|
||||
@@ -132,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
InboundPlanCarton carton = carton(1L, "BOX-1");
|
||||
when(cartons.selectOne(any())).thenReturn(carton);
|
||||
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
|
||||
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verifyNoInteractions(warehouse);
|
||||
@@ -142,7 +164,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
|
||||
stubCurrent("OVERSEAS", "CONT-A");
|
||||
when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X"));
|
||||
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
|
||||
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||
new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verifyNoInteractions(warehouse);
|
||||
@@ -151,7 +173,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
stubSku();
|
||||
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
|
||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
||||
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||
@@ -165,7 +187,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
||||
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
|
||||
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
|
||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
||||
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||
@@ -177,7 +199,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
||||
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
|
||||
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
|
||||
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
|
||||
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||
@@ -189,11 +211,24 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
|
||||
partial.setLineId(10L); partial.setPickedPackageCount(1);
|
||||
when(allocations.selectList(any())).thenReturn(List.of(partial));
|
||||
assertThatThrownBy(() -> service.complete("100", "7", new ReceivingContainerRequests.Action("complete-key")))
|
||||
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verify(workflow, never()).completeContainer(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedRequestReplaysWithoutAnActiveCustomerSession() {
|
||||
ReceivingContainerOperation operation = new ReceivingContainerOperation();
|
||||
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
|
||||
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
|
||||
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
|
||||
when(operations.find("7", "complete-replay")).thenReturn(operation);
|
||||
|
||||
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
|
||||
.isEqualTo("COMPLETED");
|
||||
verifyNoInteractions(sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
@@ -204,7 +239,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
|
||||
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
|
||||
|
||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
||||
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key")))
|
||||
.isSameAs(failure);
|
||||
verify(workflow).compensateReservation("100", 55L, failure);
|
||||
@@ -212,7 +247,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
|
||||
@Test
|
||||
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
|
||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
|
||||
when(lines.selectList(any())).thenReturn(List.of());
|
||||
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||
@@ -222,7 +257,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key");
|
||||
|
||||
var response = service.sign("100", "7", "receiver", request);
|
||||
var response = service.sign("7", "receiver", request);
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role")
|
||||
.containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL,
|
||||
@@ -234,26 +269,26 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
|
||||
@Test
|
||||
void signRejectsDuplicateRolesBeforeFileValidation() {
|
||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
|
||||
|
||||
assertThatThrownBy(() -> service.sign("100", "7", "receiver", request))
|
||||
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verifyNoInteractions(unloadPhotos);
|
||||
}
|
||||
|
||||
@Test
|
||||
void signRejectsDuplicateFileIdsBeforeFileValidation() {
|
||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files");
|
||||
|
||||
assertThatThrownBy(() -> service.sign("100", "7", "receiver", request))
|
||||
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verifyNoInteractions(unloadPhotos);
|
||||
}
|
||||
@@ -267,7 +302,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
received.setReceivedPackages(2);
|
||||
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received));
|
||||
|
||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
||||
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verifyNoInteractions(warehouse);
|
||||
@@ -277,7 +312,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
InboundPlan plan = plan(containerNo);
|
||||
InboundPlanLine line = line(destinationType);
|
||||
LastlegOrder order = order(destinationType);
|
||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session(containerNo, "SIGNED"));
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
|
||||
when(plans.selectList(any())).thenReturn(List.of(plan));
|
||||
when(lines.selectList(any())).thenReturn(List.of(line));
|
||||
when(orders.selectById(1L)).thenReturn(order);
|
||||
@@ -342,4 +377,17 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
|
||||
"AVAILABLE", fbaCode, 0);
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
String json = new ObjectMapper().findAndRegisterModules().copy()
|
||||
.configure(com.fasterxml.jackson.databind.MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
|
||||
.configure(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
|
||||
.writeValueAsString(value);
|
||||
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(json.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -12,6 +12,7 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -108,9 +109,12 @@ class InventorySchemaContractTest {
|
||||
assertThrowsNoField(InventoryPosition.class, "productQuantity");
|
||||
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
|
||||
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
|
||||
assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity"));
|
||||
assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity"));
|
||||
assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity"));
|
||||
for (String field : List.of("beforeQuantity", "deltaQuantity", "afterQuantity")) {
|
||||
var annotation = InventoryLedger.class.getDeclaredField(field)
|
||||
.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class);
|
||||
assertNotNull(annotation, field);
|
||||
assertFalse(annotation.exist(), field);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertThrowsNoField(Class<?> type, String fieldName) {
|
||||
|
||||
+14
@@ -1,5 +1,7 @@
|
||||
package com.baiyue.lastleg.procurement.service;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
|
||||
@@ -21,6 +23,7 @@ import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -155,4 +158,15 @@ class OverseasProcurementServiceTest {
|
||||
order.setUpdatedAt(order.getCreatedAt());
|
||||
return order;
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception {
|
||||
for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId",
|
||||
"purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier",
|
||||
"overseasTrackingNo", "estimatedArrivalAt", "remark")) {
|
||||
TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class);
|
||||
assertNotNull(annotation, field);
|
||||
assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user