diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 5fcf05cfa..74f66bfe4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -81,6 +81,21 @@ public class DispatchService { @Resource private ReturnRelabelDispatchStock returnRelabelStock; @Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements; + private List measuredRelabelCandidates(List rows) { + var cache = new java.util.HashMap>(); + for (var row : rows) { + var values = cache.computeIfAbsent(row.customerId() + ":" + row.orderRefs(), ignored -> { + var app = relabelApplications.findAnyByRelabelNo(row.orderRefs()); + return app != null && Objects.equals(app.getCustomerId(), row.customerId()) ? relabelMeasurements.snapshots(app) : List.of(); + }); + if (!values.isEmpty()) { + var p = values.stream().filter(value -> Objects.equals(value.palletId(), row.warehousePalletId())).findFirst().orElse(null); + row.grossWeightKg(p == null ? null : p.grossWeightKg()); row.volumeCbm(p == null ? null : p.volumeCbm()); + } + } + return rows; + } + public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), "Selected inbound plans"); @@ -125,7 +140,7 @@ public class DispatchService { trimToNull(sourceStage), trimToNull(draftToken)); if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) { List relabel = java.util.stream.Stream.concat( - relabelMeasurements.enrich(candidates.relabelAvailable(null, trimToNull(orderNo), null)).stream(), + measuredRelabelCandidates(candidates.relabelAvailable(null, trimToNull(orderNo), null)).stream(), returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList(); if (relabel != null && !relabel.isEmpty()) { result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item -> @@ -580,7 +595,7 @@ public class DispatchService { if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态"); List selected = java.util.stream.Stream.concat( - relabelMeasurements.enrich(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(), + measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(), returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList(); Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 584cd936c..6c2f9a61d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -268,7 +268,7 @@ public class PdaPalletOperationService { throw conflict("实际来源托盘库存已变化,请刷新后确认"); if (actual != null && application.getOutboundOrderNo() != null && !application.getOutboundOrderNo().isBlank()) throw conflict("已生成出库单,请先处理出库单,不能重新分配冻结来源"); - if (actual != null && marks.stream().anyMatch(boundRelabelMarks(application)::contains)) + if (actual != null && source.palletId() != target.palletId() && marks.stream().anyMatch(boundRelabelMarks(application)::contains)) throw conflict("本批包含本轮已组托的换标箱,请刷新后选择未组托的箱"); if ("PROCESSING".equals(application.getStatus())) { var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId()); @@ -309,8 +309,14 @@ public class PdaPalletOperationService { if (marks.stream().anyMatch(bound::contains)) throw conflict("本批包含已绑定箱唛,请刷新后重试"); recordRelabelBinding(identity, new RelabelMutation(application, byPosition, Map.of()), retained); } - return new PalletOperationResponses.Operation(IdWorker.getId(), "RELABEL_PALLET_RETAIN", request.idempotencyKey(), + var result = new PalletOperationResponses.Operation(IdWorker.getId(), "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), "COMPLETED", false, LocalDateTime.now(), List.of(content(identity.overseasWarehouseId(), source))); + var operation = new PdaPalletOperation(); operation.setId(result.operationId()); + operation.setOverseasWarehouseId(identity.overseasWarehouseId()); operation.setOperatorKey(identity.operatorKey()); + operation.setOperationType(result.operationType()); operation.setIdempotencyKey(request.idempotencyKey()); + operation.setRequestHash(hash(request)); operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); + operation.setCreatedAt(result.completedAt()); operation.setUpdatedAt(result.completedAt()); mapper.insert(operation); + return result; }); } for (RelabelItem item : selected) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java index 8dcc70add..ccd4f5e71 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java @@ -10,15 +10,17 @@ public interface RelabelPalletMeasurementMapper { record Original(Long palletId, String palletNo, int packages, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal volumeCbm) {} @Select("SELECT id FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo}") - Long transfer(String customerId, String relabelNo); + Long transfer(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); @Select("SELECT id,pallet_id palletId,pallet_no palletNo," + "COALESCE(available_packages,0)+COALESCE(hold_packages,0)+COALESCE(allocated_packages,0)+COALESCE(picked_packages,0)+COALESCE(receiving_packages,0) packages,version " + "FROM ll_inventory_position WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} ORDER BY id") - List stock(String customerId, String warehouseId); + List stock(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); + @Select("SELECT id FROM ll_inventory_position WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND pallet_no=#{palletCode} ORDER BY id FOR UPDATE") + List lockStock(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); @Select("SELECT warehouse_pallet_id palletId,pallet_no palletNo,carton_count packages,length_cm lengthCm,width_cm widthCm,height_cm heightCm,gross_weight_kg grossWeightKg,volume_cbm volumeCbm " + "FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferId} AND status='COMPLETED'") - List originals(String customerId, Long transferId); + List originals(@Param("customerId") String customerId, @Param("transferId") Long transferId); @Select("SELECT COUNT(*) FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + "AND business_type IN ('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND')") - int movements(String customerId, Long palletId); + int movements(@Param("customerId") String customerId, @Param("palletId") Long palletId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java index 1d39d13fe..1708beff3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java @@ -7,7 +7,6 @@ import com.baiyue.lastleg.relabel.mapper.*; import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; import com.baiyue.lastleg.relabel.pda.mapper.*; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; -import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import jakarta.validation.constraints.*; @@ -28,8 +27,8 @@ public class RelabelPalletMeasurementService { @Resource private PdaRelabelHistoryMapper histories; @Resource private PdaRelabelTaskMapper tasks; @Resource private ObjectMapper json; - public record Measure(@NotBlank String relabelNo, @NotBlank String palletCode, - @NotBlank String idempotencyKey, @NotBlank String stockVersion, + public record Measure(@NotBlank @Size(max=64) String relabelNo, @NotBlank @Size(max=128) String palletCode, + @NotBlank @Size(max=128) String idempotencyKey, @NotBlank @Size(max=32768) String stockVersion, @NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal lengthCm, @NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal widthCm, @NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal heightCm, @@ -47,7 +46,7 @@ public class RelabelPalletMeasurementService { throw conflict("换标单不属于当前海外仓"); return app; } - @Transactional + @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public List save(PdaWarehouseIdentity identity, Measure request) { var app = scoped(identity, request.relabelNo(), true); if (!Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(app.getStatus()) @@ -55,7 +54,7 @@ public class RelabelPalletMeasurementService { || app.getFulfillmentStatus() != null && !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(app.getFulfillmentStatus())) throw conflict("已排车或出库的换标单不能修改测量"); var task = tasks.find(app.getCustomerId(), app.getId()); - if (task == null || "PROCESSING".equals(app.getStatus()) && !identity.operatorKey().equals(task.getOperatorKey())) + if (task == null || "PROCESSING".equals(app.getStatus()) && (!"PROCESSING".equals(task.getStatus()) || !identity.operatorKey().equals(task.getOperatorKey()))) throw conflict("请先认领当前换标任务"); var records = histories(app); String payload = write(request); @@ -67,6 +66,7 @@ public class RelabelPalletMeasurementService { return snapshots(app); } } + mapper.lockStock(app.getCustomerId(), app.getOverseasWarehouseId(), request.palletCode()); var current = snapshots(app).stream().filter(p -> p.palletCode().equals(request.palletCode())).findFirst() .orElseThrow(() -> conflict("托盘不属于本换标单或已经清空")); if (!current.stockVersion().equals(request.stockVersion())) throw conflict("托盘库存已变化,请刷新后重新测量"); @@ -118,23 +118,10 @@ public class RelabelPalletMeasurementService { } public void requireMeasured(RelabelApplication app) { for (var p : snapshots(app)) if (p.grossWeightKg() == null || p.volumeCbm() == null + || p.lengthCm() == null || p.widthCm() == null || p.heightCm() == null || p.grossWeightKg().signum() <= 0 || p.volumeCbm().signum() <= 0) throw conflict("托盘 " + p.palletCode() + " 拆合托后尚未确认实测尺寸和重量,请在 PDA 补测(含原托盘剩余货物)"); } - public List enrich(List candidates) { - var cache = new HashMap>(); - for (var c : candidates) { - var values = cache.computeIfAbsent(c.customerId()+":"+c.orderRefs(), ignored -> { - var app = applications.findAnyByRelabelNo(c.orderRefs()); - return app != null && Objects.equals(app.getCustomerId(),c.customerId()) ? snapshots(app) : List.of(); - }); - if (!values.isEmpty()) { - var p = values.stream().filter(v -> Objects.equals(v.palletId(), c.warehousePalletId())).findFirst().orElse(null); - c.grossWeightKg(p == null ? null : p.grossWeightKg()); c.volumeCbm(p == null ? null : p.volumeCbm()); - } - } - return candidates; - } private List histories(RelabelApplication app) { return histories.selectList(new QueryWrapper().eq("customer_id", app.getCustomerId()) .eq("application_id", app.getId()).orderByAsc("created_at","id")); diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index c08d1aba7..0d2a8cce3 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -220,9 +220,44 @@ paths: /api/pda/v1/last-leg/pallet-operations/relabel-disassemble: post: summary: 按换标箱唛拆托并绑定目标托盘 - description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。 + description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。普通库存整托全部换标允许目标与来源为同一托盘,校验整托数量和本单冻结归属后仅记录绑定、不搬移库存;部分换标必须使用新托盘。货物和包装不变时可继承原托盘测量,拆合托后新托盘和原托余货均须重新实测。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } } responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/relabel-pallet-measurements: + post: + summary: 查询仓储转运换标涉及托盘的最终测量及库存版本 + description: 权限 lastleg:pda:operate 及海外PDA角色;仅Token绑定仓。整托原托盘且没有拆合托流水、箱数未变时沿用原始测量;新托盘和拆合托后的原托盘余货需要补测。测量保存为PALLET_MEASURE作业历史,库存版本变化后失效。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [relabelNo] + properties: + relabelNo: { type: string, maxLength: 64 } + responses: { '200': { description: 托盘业务码、箱数、stockVersion、长宽高cm、毛重kg、体积m³及inherited;其他来源换标返回空列表 }, '409': { description: 不属于当前仓 } } + /api/pda/v1/last-leg/pallet-operations/relabel-pallet-measure: + post: + summary: 确认仓储转运换标托盘最终实测 + description: 已认领的操作员可在处理中保存;待出库但未排车亦可补测。保存当前stockVersion及实测长宽高、毛重,服务端计算体积;相同幂等键异载荷拒绝,库存变化要求刷新。原托盘包装变化同样使用此接口重新测量。排车前核验所有相关非空托盘,包括原托盘余货。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [relabelNo, palletCode, stockVersion, idempotencyKey, lengthCm, widthCm, heightCm, grossWeightKg] + properties: + relabelNo: { type: string, maxLength: 64 } + palletCode: { type: string, maxLength: 128 } + stockVersion: { type: string, description: 查询返回的当前库存版本指纹 } + idempotencyKey: { type: string, maxLength: 128 } + lengthCm: { type: number, minimum: 0.01, multipleOf: 0.01 } + widthCm: { type: number, minimum: 0.01, multipleOf: 0.01 } + heightCm: { type: number, minimum: 0.01, multipleOf: 0.01 } + grossWeightKg: { type: number, minimum: 0.01, multipleOf: 0.001 } + responses: { '200': { description: 已保存并返回全部相关托盘测量 }, '409': { description: 版本、归属、状态、认领人或幂等载荷冲突 } } /api/pda/v1/last-leg/pallet-operations/swap: post: summary: 将整托库存更换到空目标托盘 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java index a25e8bfcd..eb21f2262 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java @@ -25,6 +25,11 @@ class DispatchPhysicalWeightTest { int to = sql.indexOf(" AS grossWeightKg,"); if (from < 0 || to < 0) continue; String projection = sql.substring(from + "AS volumeWeightKg,".length(), to); + if (projection.contains("wtp.gross_weight_kg")) { + assertThat(jdbc.queryForObject("SELECT " + projection + " FROM (SELECT CAST(NULL AS DECIMAL(12,3)) gross_weight_kg) wtp", BigDecimal.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT " + projection + " FROM (SELECT 80 gross_weight_kg) wtp", BigDecimal.class)).isEqualByComparingTo("80"); + continue; + } String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id) ip " + "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm) l " + "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm) a " diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index cbcb3dcd9..c561e8731 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -72,7 +72,6 @@ class DispatchServiceTest { void setUp() { service = new DispatchService(); ReflectionTestUtils.setField(service, "relabelMeasurements", relabelMeasurements); - lenient().when(relabelMeasurements.enrich(any())).thenAnswer(invocation -> invocation.getArgument(0)); ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "returnRelabelStock", returnRelabelStock); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index c9fb4a745..ed36eedf3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -138,14 +138,14 @@ class PdaPalletOperationServiceTest { new PalletOperationRequests.PalletExpectation("SOURCE", 1), new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("BOX-1"), "rebind"); assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, - "bindReturnRelabelPallet", identity(), app, List.of(item), source, target, request)); + "bindReturnRelabelPallet", identity(), app, List.of(item), source, target, request, false)); verifyNoInteractions(physical); var binding = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); binding.setDetailJson("{\"round\":\"INITIAL\",\"cartonMarks\":[\"BOX-1\"]}"); when(workHistory.selectList(any())).thenReturn(List.of(binding)); task.setReviewedAt(LocalDateTime.of(2026, 10, 12, 12, 0)); assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, - "bindReturnRelabelPallet", identity(), app, List.of(item), source, target, request)); + "bindReturnRelabelPallet", identity(), app, List.of(item), source, target, request, false)); task.setReviewedAt(null); when(physical.resolveLocation(88L, 1020L)).thenReturn( new PalletPhysicalGateway.LocationReference(1020L, "SOURCE-LOC", "STORAGE", "ACTIVE")); @@ -155,7 +155,7 @@ class PdaPalletOperationServiceTest { when(physical.resolve("TARGET")).thenReturn(target); var result = (com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation) ReflectionTestUtils.invokeMethod(service, "bindReturnRelabelPallet", identity(), app, - List.of(item), source, target, request); + List.of(item), source, target, request, false); assertEquals("TARGET", box.getPalletCode()); assertEquals(0, result.pallets().get(0).totalPackages()); assertEquals(2, result.pallets().get(1).totalPackages()); @@ -584,6 +584,35 @@ class PdaPalletOperationServiceTest { assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different"))); } + @Test void wholeRelabelCanRetainOriginalPalletWithoutMovingStockAndPartialCannot() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service,"relabelWorkTasks",tasks); ReflectionTestUtils.setField(service,"relabelWorkHistory",histories); + var app = new RelabelApplication();app.setId(600L);app.setCustomerId("100");app.setRelabelNo("RL-1"); + app.setOverseasWarehouseId("88");app.setStatus("READY_FOR_OUTBOUND");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();task.setId(900L);task.setStatus("COMPLETED"); + when(tasks.find("100",600L)).thenReturn(task); + var item = relabelItem(701L,"CARTON-A",100L); + when(relabelItems.selectList(any())).thenReturn(List.of(item)); + when(relabelApplications.selectById(600L)).thenReturn(app); + when(physical.resolve("SOURCE")).thenReturn(pallet(10L,"SOURCE",88L,0)); + when(physical.resolveLocation(88L,1010L)).thenReturn(new PalletPhysicalGateway.LocationReference(1010L,"LOC-1010","STORAGE","ACTIVE")); + var position = position(100L,10L,"SOURCE",1,0);when(positions.selectById(100L)).thenReturn(position); + var row = row(100L,10L,0,0);row.setHoldPackages(1L); + when(mapper.lockPosition("88",100L)).thenReturn(row);when(mapper.content("88",10L)).thenReturn(List.of(row)); + when(mapper.palletPackages("88",10L)).thenReturn(1L); + var request = new PalletOperationRequests.RelabelDisassemble("retain-1","RL-1", + new PalletOperationRequests.PalletExpectation("SOURCE",0),new PalletOperationRequests.PalletExpectation("SOURCE",0),List.of("CARTON-A"),"整托贴标"); + var result = service.relabelDisassemble(identity(),request); + assertEquals("COMPLETED",result.status()); assertEquals(1,result.pallets().size()); + verify(positions,never()).updateById(any(InventoryPosition.class)); verifyNoInteractions(relabelPalletMutations,ledgers); + verify(histories).insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class)); + when(mapper.palletPackages("88",10L)).thenReturn(2L); + assertTrue(assertThrows(ResponseStatusException.class,() -> service.relabelDisassemble(identity(),request)).getReason().contains("部分贴标")); + } + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementServiceTest.java new file mode 100644 index 000000000..9d308c27e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementServiceTest.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.relabel.mapper.*; +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.pda.entity.*; +import com.baiyue.lastleg.relabel.pda.mapper.*; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class RelabelPalletMeasurementServiceTest { + final RelabelPalletMeasurementService service = new RelabelPalletMeasurementService(); + final RelabelPalletMeasurementMapper mapper = mock(RelabelPalletMeasurementMapper.class); + final RelabelApplicationMapper apps = mock(RelabelApplicationMapper.class); + final RelabelItemMapper items = mock(RelabelItemMapper.class); + final PdaRelabelHistoryMapper histories = mock(PdaRelabelHistoryMapper.class); + final PdaRelabelTaskMapper tasks = mock(PdaRelabelTaskMapper.class); + final RelabelApplication app = new RelabelApplication(); + final List records = new ArrayList<>(); + final ObjectMapper json = new ObjectMapper(); + @BeforeEach void setup() { + ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"applications",apps); + ReflectionTestUtils.setField(service,"items",items); ReflectionTestUtils.setField(service,"histories",histories); + ReflectionTestUtils.setField(service,"tasks",tasks); ReflectionTestUtils.setField(service,"json",json); + app.setId(1L);app.setCustomerId("7");app.setRelabelNo("RL-1");app.setOverseasWarehouseId("92"); + app.setStatus("READY_FOR_OUTBOUND");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + when(apps.findAnyByRelabelNo("RL-1")).thenReturn(app);when(apps.lockAnyByRelabelNo("RL-1")).thenReturn(app); + when(mapper.transfer("7","RL-1")).thenReturn(10L); + when(mapper.originals("7",10L)).thenReturn(List.of(new RelabelPalletMeasurementMapper.Original(100L,"OLD",10, + new BigDecimal("100"),new BigDecimal("100"),new BigDecimal("100"),new BigDecimal("80"),BigDecimal.ONE))); + when(mapper.stock("7","92")).thenReturn(List.of(stock(1,100,"OLD",10,1))); + when(items.selectList(any())).thenReturn(List.of()); + when(histories.selectList(any())).thenAnswer(i -> List.copyOf(records)); + when(histories.insert(any(PdaRelabelHistory.class))).thenAnswer(i -> { records.add(i.getArgument(0));return 1; }); + var task = new PdaRelabelTask(); task.setId(2L); task.setStatus("COMPLETED"); + when(tasks.find("7",1L)).thenReturn(task); + } + RelabelPalletMeasurementMapper.Stock stock(long id,long pallet,String code,long count,long version) { + return new RelabelPalletMeasurementMapper.Stock(id,pallet,code,count,version); + } + @Test void wholeOriginalPalletInheritsWithoutInventingOrRescalingMeasurements() { + var p = service.snapshots(app).get(0);assertTrue(p.inherited()); assertEquals(new BigDecimal("80"),p.grossWeightKg()); + assertEquals(BigDecimal.ONE,p.volumeCbm());assertDoesNotThrow(() -> service.requireMeasured(app)); + } + @Test void partialSplitRequiresBothNewPalletAndOriginalRemainderMeasurements() throws Exception { + split(); var values = service.snapshots(app);assertEquals(2,values.size()); + assertTrue(values.stream().allMatch(p -> p.volumeCbm()==null && p.grossWeightKg()==null)); + assertThrows(ResponseStatusException.class,() -> service.requireMeasured(app)); + } + @Test void explicitFinalMeasurementsAreAuditedAndReplayDoesNotDuplicateFacts() throws Exception { + split(); + for (var p : service.snapshots(app)) { + var request = new RelabelPalletMeasurementService.Measure("RL-1",p.palletCode(),"measure-"+p.palletId(),p.stockVersion(), + new BigDecimal("100"),new BigDecimal("50"),new BigDecimal("50"),new BigDecimal("40")); + service.save(identity(),request);service.save(identity(),request); + } + assertEquals(3,records.size()); // One binding, two measurements. + assertDoesNotThrow(() -> service.requireMeasured(app)); + assertTrue(service.snapshots(app).stream().allMatch(p -> p.volumeCbm().compareTo(new BigDecimal("0.25"))==0)); + when(mapper.stock("7","92")).thenReturn(List.of(stock(1,100,"OLD",5,3),stock(2,200,"NEW",5,1))); + assertThrows(ResponseStatusException.class,() -> service.requireMeasured(app)); + } + @Test void staleAndForeignMeasurementsAndAllocatedOrdersAreRejected() { + var request = new RelabelPalletMeasurementService.Measure("RL-1","OLD","key","stale", + BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE); + assertThrows(ResponseStatusException.class,() -> service.save(identity(),request)); + app.setOverseasWarehouseId("93"); assertThrows(ResponseStatusException.class,() -> service.list(identity(),"RL-1")); + app.setOverseasWarehouseId("92");app.setFulfillmentStatus("PRE_DISPATCH_CREATED"); + assertThrows(ResponseStatusException.class,() -> service.save(identity(),request)); + verify(histories,never()).insert(any(PdaRelabelHistory.class)); + } + private void split() throws Exception { + when(mapper.stock("7","92")).thenReturn(List.of(stock(1,100,"OLD",5,2),stock(2,200,"NEW",5,1))); + when(mapper.movements("7",100L)).thenReturn(1); + var binding = new PdaRelabelHistory();binding.setAction("PALLET_BIND"); + binding.setDetailJson(json.writeValueAsString(Map.of("sourcePallets",List.of("OLD"),"targetPallets",List.of("NEW")))); records.add(binding); + } + private PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("operator","现场人员",92L); } +}