From 44c76d89536fae17f4d094f48ef1dba1a16ed811 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 15:54:19 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transfer/service/WarehouseTransferService.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index d53b25d82..46d16fc16 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -190,7 +190,9 @@ public class WarehouseTransferService { private List unreservedSourceCartons(String customerId, String warehouseId, String source, List cartons) { if (inventorySources == null) return cartons; - Set occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).stream() + var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source); + if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons; + Set occupied = occupiedMarks.stream() .filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT)) .collect(Collectors.toSet()); return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim() @@ -483,7 +485,6 @@ public class WarehouseTransferService { // 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。 if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null) throw conflict("库存来源已失效,请重新选择来源单"); - if (inventorySources != null) reserveSourceCartons(customerId, transfer); } if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { @@ -499,6 +500,11 @@ public class WarehouseTransferService { } } + // 来源箱归属校验通过后再写入占用快照,保证错误箱号返回明确提示。 + if (!transfer.getTransferNo().equals(transfer.getInboundCode()) && inventorySources != null) { + reserveSourceCartons(customerId, transfer); + } + LastlegOrder order = requireTailOrder(customerId, transfer); LocalDateTime now = LocalDateTime.now(); transfer.setStatus(PENDING_REVIEW); From e0677e7ae7e131629f1e34adf4b119424c875b9c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 16:41:20 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...atformDropshipPalletizationController.java | 22 +++ .../dto/DropshipPalletizationDtos.java | 9 ++ .../mapper/DropshipPalletizationMapper.java | 39 +++++ .../DropshipWorkspaceRepository.java | 39 +++++ .../service/DropshipPalletizationService.java | 32 ++++- .../openapi/dropship-palletization-v1.yaml | 20 +++ .../DropshipPalletizationServiceTest.java | 51 ++++++- .../OutboundQuantityRepository.java | 30 ++++ .../service/DeliveryTransferService.java | 4 +- .../discard/model/CargoDiscardQuantities.java | 26 ++++ .../discard/pda/dto/PdaCargoDiscardDtos.java | 3 +- .../pda/service/PdaCargoDiscardService.java | 12 +- .../CargoDiscardQuantityRepository.java | 19 +++ .../service/CargoDiscardOutboundService.java | 20 ++- .../discard/service/CargoDiscardService.java | 12 +- .../service/DeliveryTransferServiceTest.java | 17 +++ .../service/PdaCargoDiscardServiceTest.java | 22 +++ .../CargoDiscardOutboundServiceTest.java | 19 +++ .../service/CargoDiscardServiceTest.java | 29 ++++ scripts/admin-test-env-acceptance.mjs | 3 +- scripts/admin-test-env-modules/README.md | 2 + .../dropship-mixed-palletization.mjs | 134 ++++++++++++++++++ 22 files changed, 546 insertions(+), 18 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipWorkspaceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java create mode 100644 scripts/admin-test-env-modules/dropship-mixed-palletization.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java index cec0a9079..d19e174bb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -35,6 +35,28 @@ public class PlatformDropshipPalletizationController { @Resource private DropshipPalletizationService service; @Resource private InboundCartonLabelPrintService printService; + /** 跨票识别箱码只读预览,清点仍按原任务领取并提交。 */ + @GetMapping("/cartons/resolve") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse resolveWorkspaceCarton( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Positive long warehouseId, + @RequestParam @jakarta.validation.constraints.NotBlank @Size(max = 80) String boxCode) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.resolveWorkspaceCarton(warehouseId, boxCode, user.userId())); + } + + /** 获取当前实物托盘及客户组成,不要求托盘已与当前票绑定。 */ + @GetMapping("/pallets/resolve") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse workspacePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Positive long warehouseId, + @RequestParam @jakarta.validation.constraints.Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.workspacePallet(warehouseId, palletNo)); + } + @GetMapping("/tasks/pending") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse> pending( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java index f3aedcf35..c4a359958 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java @@ -18,6 +18,15 @@ public final class DropshipPalletizationDtos { public record ClaimRequest(@Positive long warehouseId) {} + /** 跨票只读识别结果;确认清点前仍须领取对应任务。 */ + public record ScanContext(Task task, Carton carton) {} + + /** 当前实物托盘及逐客户、逐票组成。 */ + public record PalletWorkspace(Pallet pallet, List members) {} + + public record PalletMember(Long taskId, Long inboundId, String inboundCode, + Long customerId, String customerName, Integer cartonCount) {} + public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE } public record CountCartonRequest(@Positive long warehouseId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index bceb2bfca..1de5ea97e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -15,6 +15,45 @@ import java.util.List; /** 一件代发逐箱清点、待绑托与封托事实。 */ @Mapper public interface DropshipPalletizationMapper { + /** 在指定仓库按稳定箱码识别任务,多个匹配由业务层拒绝。 */ + @Select(""" + SELECT DISTINCT t.id FROM fl_dropship_pallet_task t + JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id AND o.deleted=0 + JOIN fl_inbound_carton_label l ON l.inbound_id=t.inbound_id AND l.tenant_id=t.tenant_id + WHERE t.warehouse_id=#{warehouseId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE' + AND o.fulfillment_type='ONE_CLICK_DROPSHIP' + """) + List workspaceTaskIds(@Param("warehouseId") long warehouseId,@Param("boxCode") String boxCode); + + /** 扫码预览为只读查询,不能把未知或已封托托盘默认成开放。 */ + @Select("SELECT * FROM fl_dropship_pallet WHERE warehouse_id=#{warehouseId} AND pallet_no=#{palletNo}") + PalletRow workspacePallet(@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo); + + /** 按实际成员汇总客户与票,空的预生成托盘不虚构成员。 */ + @Select(""" + SELECT c.task_id,c.inbound_id,o.inbound_code,c.tenant_id customer_id,o.customer_name,COUNT(*) carton_count + FROM fl_dropship_pallet_carton c + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE c.pallet_id=#{palletId} + GROUP BY c.task_id,c.inbound_id,o.inbound_code,c.tenant_id,o.customer_name + ORDER BY o.inbound_code,c.task_id + """) + List workspaceMembers(@Param("palletId") long palletId); + + /** 混托成员必须与当前票同渠道、同目的仓,客户可以不同。 */ + @Select(""" + SELECT COUNT(*) FROM fl_dropship_pallet_carton c + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + JOIN fl_dropship_pallet_task t ON t.id=#{taskId} + JOIN fl_inbound_order target ON target.id=t.inbound_id AND target.tenant_id=t.tenant_id + WHERE c.pallet_id=#{palletId} AND ( + NOT (o.channel_code <=> target.channel_code) + OR NOT (o.destination_type <=> target.destination_type) + OR NOT (o.destination_warehouse_id <=> target.destination_warehouse_id) + OR NOT (o.destination_code <=> target.destination_code)) + """) + int workspaceIncompatibleMembers(@Param("taskId") long taskId,@Param("palletId") long palletId); + @Insert(""" INSERT IGNORE INTO fl_dropship_pallet_task (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipWorkspaceRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipWorkspaceRepository.java new file mode 100644 index 000000000..4556ea8a0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipWorkspaceRepository.java @@ -0,0 +1,39 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 仓内跨票扫码与实物托盘组成查询,客户归属始终从箱唛事实取得。 */ +@Repository +public class DropshipWorkspaceRepository { + @Resource private DropshipPalletizationMapper mapper; + + /** 按仓库和稳定箱码查找作业任务,不接受客户端指定客户归属。 */ + public List taskIdsForBox(long warehouseId, String boxCode) { + return mapper.workspaceTaskIds(warehouseId, boxCode); + } + + /** 只读查询托盘,不因扫描托盘产生绑定或状态变化。 */ + public PalletRow pallet(long warehouseId, String palletNo) { + return mapper.workspacePallet(warehouseId, palletNo); + } + + /** 保留每票客户与任务身份,供混托核对和按原任务移箱。 */ + public List members(long palletId) { + return mapper.workspaceMembers(palletId); + } + + /** 归托前锁定实物托盘,与封托互斥。 */ + public PalletRow lockPallet(String palletNo) { + return mapper.palletByNo(palletNo); + } + + /** 按原始入仓快照检查运输兼容性,不接受客户端声明。 */ + public boolean compatible(long taskId, long palletId) { + return mapper.workspaceIncompatibleMembers(taskId, palletId) == 0; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java index 049625e77..81e88c854 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -21,6 +21,34 @@ import java.util.*; public class DropshipPalletizationService { @Resource private DropshipPalletizationMapper mapper; @Resource private FirstlegDocumentNumberGenerator documentNumbers; + @Resource private com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspaceRepository; + + /** 只读识别其他客户的箱码;不自动领取、清点或绑定托盘。 */ + public ScanContext resolveWorkspaceCarton(long warehouseId,String boxCode,long operatorId){ + String code=normalize(boxCode); + if(code==null) throw invalid("箱码不能为空"); + List ids=workspaceRepository.taskIdsForBox(warehouseId,code); + if(ids.isEmpty()) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该一件代发箱码"); + if(ids.size()!=1) throw conflict("箱码匹配多个任务,请联系后台核对"); + Task target=task(ids.get(0),warehouseId,operatorId); + Carton carton=resolveCarton(target.taskId(),warehouseId,code,operatorId); + // 未领取任务仅允许预览,员工确认后由正式领取与清点接口再次检查权限和状态。 + if("PENDING".equals(target.taskStatus()) && Boolean.TRUE.equals(target.palletSetupReady()) + && "请先领取任务".equals(carton.blockReason())){ + carton=new Carton(carton.cartonId(),carton.boxCode(),carton.inboundId(),carton.inboundCode(), + carton.skuLines(),carton.countStatus(),carton.palletId(),carton.palletNo(),true,null); + } + return new ScanContext(target,carton); + } + + /** 按仓库返回真实托盘状态及全部成员,跨客户不改变每箱的原归属。 */ + public PalletWorkspace workspacePallet(long warehouseId,String palletNo){ + String code=normalize(palletNo); + if(code==null) throw invalid("托盘码不能为空"); + PalletRow row=workspaceRepository.pallet(warehouseId,code); + if(row==null) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该托盘码"); + return new PalletWorkspace(palletView(row,null),workspaceRepository.members(row.getId())); + } @Transactional public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) { @@ -195,11 +223,13 @@ public class DropshipPalletizationService { .map(p->palletView(p,task.getInboundCode())).toList(); } + /** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */ private PalletRow requireAssignablePallet(TaskRow task,String palletNo){ - PalletRow existing=mapper.palletByNo(palletNo); + PalletRow existing=workspaceRepository.lockPallet(palletNo); if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码"); if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库"); if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱"); + if(!workspaceRepository.compatible(task.getId(),existing.getId())) throw conflict("托内货物与当前票的渠道或目的仓不同,不能混托"); return existing; } diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index 5ce98de85..eae296386 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -4,6 +4,26 @@ info: version: 3.0.0 description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。 paths: + /api/platform/v1/first-leg/pda-dropship/cartons/resolve: + get: + summary: 同仓跨客户识别箱码并预览原任务 + description: 只读,不领取或清点;返回 task 与 carton,确认时仍须领取原任务后按原 taskId 清点。拒绝其他作业员已领取任务及箱码歧义。 + x-required-authority: platform:firstleg:pda:view + parameters: + - {$ref: '#/components/parameters/WarehouseId'} + - {name: boxCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 80}} + responses: + '200': {description: 原任务和箱内 SKU 预览} + /api/platform/v1/first-leg/pda-dropship/pallets/resolve: + get: + summary: 查询当前仓库真实托盘状态与逐客户逐票组成 + description: 扫描不产生归托;返回 pallet 与 members,成员保留 taskId、inboundId、inboundCode、customerId、customerName、cartonCount。不存在或跨仓的托盘拒绝,已封托返回真实状态。 + x-required-authority: platform:firstleg:pda:view + parameters: + - {$ref: '#/components/parameters/WarehouseId'} + - {name: palletNo, in: query, required: true, schema: {type: string, pattern: 'DP-[0-9]{6}-[0-9]{4}'}} + responses: + '200': {description: 实物托盘状态和成员汇总} /api/platform/v1/first-leg/pda-dropship/tasks/pending: get: summary: 查询待领取或本人已领取任务 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java index 33b31b3e7..760c1e4c9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -24,7 +24,56 @@ class DropshipPalletizationServiceTest { private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class); private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class); private final DropshipPalletizationService service=new DropshipPalletizationService(); - DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); } + private final com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspace=mock(com.baiyue.firstleg.repository.DropshipWorkspaceRepository.class); + /** 使用正式仓储入口的等价锁定查询,保留既有扫码测试的事实准备。 */ + DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); ReflectionTestUtils.setField(service,"workspaceRepository",workspace); when(workspace.lockPallet(anyString())).thenAnswer(call -> mapper.palletByNo(call.getArgument(0))); when(workspace.compatible(anyLong(),anyLong())).thenReturn(true); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); } + + /** 跨客户预览不领取任务,不写入清点事实。 */ + @Test void workspaceResolvesPendingCustomerWithoutMutating(){ + ReflectionTestUtils.setField(service,"workspaceRepository",workspace); + TaskRow other=task(); other.setTenantId(2L); other.setTaskStatus("PENDING"); other.setAssigneeId(null); + when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L)); + when(mapper.task(10L,100L)).thenReturn(other); + when(mapper.label(10L,"BOX-1")).thenReturn(label()); + when(mapper.palletCountByInbound(2L,30L)).thenReturn(other.getExpectedPalletCount()); + var result=service.resolveWorkspaceCarton(100L," BOX-1 ",7L); + assertThat(result.task().tenantId()).isEqualTo(2L); + assertThat(result.carton().canCount()).isTrue(); + verify(mapper,never()).claim(anyLong(),anyLong(),anyLong(),anyString()); + verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt()); + } + + /** 不允许从跨票扫码接管其他作业员的任务。 */ + @Test void workspaceRejectsOtherOperatorsTaskAndAmbiguousBox(){ + ReflectionTestUtils.setField(service,"workspaceRepository",workspace); + when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L)); + TaskRow other=task(); other.setAssigneeId(9L); when(mapper.task(10L,100L)).thenReturn(other); + assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("其他人员"); + when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L,11L)); + assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("多个任务"); + } + + /** 托盘预览必须返回已封托的真实状态,跨仓不存在时拒绝。 */ + @Test void workspacePalletNeverInventsOpenState(){ + ReflectionTestUtils.setField(service,"workspaceRepository",workspace); + when(workspace.pallet(100L,"DP-260911-0001")).thenReturn(sealedPallet()); + when(workspace.members(20L)).thenReturn(java.util.List.of()); + when(mapper.cartons(20L)).thenReturn(java.util.List.of()); + assertThat(service.workspacePallet(100L,"DP-260911-0001").pallet().status()).isEqualTo("SEALED"); + assertThatThrownBy(()->service.workspacePallet(101L,"DP-260911-0001")).isInstanceOf(BizException.class).hasMessageContaining("未找到"); + } + + /** 即使不同客户允许混托,运输渠道或目的仓不同仍须拒绝。 */ + @Test void incompatibleDestinationCannotBeMixed(){ + when(mapper.task(10L,100L)).thenReturn(task()); + when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); + when(mapper.label(10L,"BOX-1")).thenReturn(label()); + when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet()); + when(workspace.compatible(10L,20L)).thenReturn(false); + assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),"DP-260911-0001","wrong-destination"),7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("渠道或目的仓不同"); + verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt()); + } @Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){ when(mapper.inboundCode(1L,30L)).thenReturn("IN-1"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java new file mode 100644 index 000000000..27f9a21bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.HashSet; + +/** 出库查询按实物箱汇总,退货混装 SKU 不重复计箱。 */ +@Repository +public class OutboundQuantityRepository { + @Resource private OutboundPalletItemMapper pallets; + + /** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */ + public Integer boxes(OutboundOrder outbound) { + if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount(); + var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId()); + if (lines.isEmpty()) return outbound.getPackageCount(); + var returnBoxes = new HashSet(); + int count = 0; + for (var line : lines) { + if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) { + returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks()); + } else { + count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0)); + } + } + return Math.addExact(count, returnBoxes.size()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index a8c9e3496..2f54fb267 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -94,6 +94,7 @@ import java.util.stream.Collectors; @Service public class DeliveryTransferService { + @Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities; @Resource private DispatchOrderMapper dispatchOrders; @Resource private DispatchItemMapper dispatchItems; @Resource private OutboundOrderMapper outboundOrders; @@ -1631,7 +1632,8 @@ public class DeliveryTransferService { d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); } - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } + /** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */ + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java new file mode 100644 index 000000000..7c4e06f51 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.discard.model; + +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import java.util.HashSet; +import java.util.List; + +/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */ +public final class CargoDiscardQuantities { + /** 数量规则不需要实例化。 */ + private CargoDiscardQuantities() { } + + /** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */ + public static int boxes(List lines, boolean actual) { + var returnBoxes = new HashSet(); + int total = 0; + for (var line : lines) { + int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages(); + if (line.getReturnCartonId() != null) { + if (quantity > 0) returnBoxes.add(line.getReturnCartonId()); + } else { + total = Math.addExact(total, quantity); + } + } + return Math.addExact(total, returnBoxes.size()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java index 24b6a37fe..7ac9e9f50 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java @@ -19,8 +19,9 @@ public final class PdaCargoDiscardDtos { List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) { public Confirm(List lines, List photoFileIds) { this(lines, photoFileIds, null, false); } } + /** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */ public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, - Integer actualPackages, String status) { } + Integer actualPackages, String status, String quantityUnit) { } public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, String palletNo, Long profileId, String status, List skus, String outboundNo, String scanMode, List cartonMarks) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index ac5707bfa..b17b3483e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -117,8 +117,8 @@ public class PdaCargoDiscardService { .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); if (items.listApplication(application.getCustomerId(), application.getId()).stream() .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { - int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream() - .mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum(); + int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes( + items.listApplication(application.getCustomerId(), application.getId()), true); application.setTotalPackages(actualTotal); application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal))); discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, @@ -160,16 +160,20 @@ public class PdaCargoDiscardService { } private static final String LOOSE = "散箱"; + /** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */ private List palletTasks(CargoDiscardApplication application) { var allItems = items.listApplication(application.getCustomerId(), application.getId()); var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList())); return grouped.entrySet().stream().map(entry -> { CargoDiscardItem first = entry.getValue().get(0); var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(), - i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList(); + i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(), + isReturnItem(i) ? "件" : "箱")).toList(); String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList(); - boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired())); + // 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 + boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream() + .anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired())); if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛"); return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(), entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java new file mode 100644 index 000000000..64acc82c5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.model.CargoDiscardQuantities; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +/** 从申请明细读取物理箱汇总,不修改历史库存或件数。 */ +@Repository +public class CargoDiscardQuantityRepository { + @Resource private CargoDiscardItemMapper items; + + /** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */ + public int boxes(CargoDiscardApplication application) { + var lines = items.listApplication(application.getCustomerId(), application.getId()); + return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index 0fc044fe6..b7cd7fc4c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -39,7 +39,7 @@ public class CargoDiscardOutboundService { return outbound == null ? null : outbound.getOutboundNo(); } - // The caller holds the application lock and owns the approval transaction. + /** 审核事务持有申请锁时创建出库投影,汇总箱数与退货件数分开保存。 */ public void create(CargoDiscardApplication application, String operatorId) { if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return; var lines = items.listApplication(application.getCustomerId(), application.getId()); @@ -51,12 +51,13 @@ public class CargoDiscardOutboundService { outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE); outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); - outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages()); + outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false)); outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application)); outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at); var snapshots = new java.util.ArrayList(); var palletCodes = new java.util.HashSet(); + var returnBoxIds = new java.util.HashSet(); for (var line : lines) { boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType()); String palletNo; @@ -83,11 +84,12 @@ public class CargoDiscardOutboundService { } if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo); OutboundPalletItem snapshot = new OutboundPalletItem(); - // Stable one-to-one projection key in a separate table, never used to deduct stock. + // 出库明细与申请明细一一对应,仅作投影,不通过此表扣减库存。 snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId()); snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode()); snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks); - snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK"); + snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages()); + snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK"); if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages()); snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at); snapshots.add(snapshot); @@ -96,15 +98,21 @@ public class CargoDiscardOutboundService { snapshots.forEach(pallets::insert); } + /** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */ public void sync(CargoDiscardApplication application) { OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application)); if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果"); var lines = items.listApplication(application.getCustomerId(), application.getId()); + var returnBoxIds = new java.util.HashSet(); for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) { var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst() .orElseThrow(() -> conflict("废弃出库明细关联失效")); + boolean returned = line.getReturnCartonId() != null; + int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages(); + snapshot.setPackageCount(boxes); if ("COMPLETED".equals(line.getPdaStatus())) { - snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); + snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0) + : Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); snapshot.setStatus("PICKED"); } if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED"); @@ -117,7 +125,7 @@ public class CargoDiscardOutboundService { }; outbound.setStatus(status); outbound.setLxCodes(null); - if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages()); + outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status))); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index ad35cd767..dcc4c3df0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -50,6 +50,7 @@ import java.util.Set; @Service public class CargoDiscardService { + @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; @@ -138,6 +139,7 @@ public class CargoDiscardService { supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃"); } + /** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */ @Transactional public CargoDiscardResponses.Detail create(String customerId, String operatorId, CargoDiscardRequests.Create request) { @@ -198,7 +200,8 @@ public class CargoDiscardService { } else if (!warehouseId.equals(itemWarehouseId)) { throw conflict("One cargo discard application cannot span operating warehouses"); } - total = Math.addExact(total, input.discardPackages()); + // 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。 + if (!returnSource) total = Math.addExact(total, input.discardPackages()); } for (Long cartonId : returnCartonCache.keySet()) { @@ -209,6 +212,7 @@ public class CargoDiscardService { .map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet()); if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存"); } + total = Math.addExact(total, returnCartonCache.size()); LocalDateTime now = LocalDateTime.now(); if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); CargoDiscardApplication application = new CargoDiscardApplication(); @@ -506,10 +510,12 @@ public class CargoDiscardService { return names; } + /** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */ private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map names) { + int boxes = quantities.boxes(value); return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), - value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(), - value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), + value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes, + value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index d2684922e..b16a4b0f1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -134,6 +134,9 @@ class DeliveryTransferServiceTest { @BeforeEach void setUp() { service = new DeliveryTransferService(); + var outboundQuantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository(); + ReflectionTestUtils.setField(outboundQuantities, "pallets", outboundPallets); + ReflectionTestUtils.setField(service, "outboundQuantities", outboundQuantities); ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders))); var actualSources = mock(DispatchCargoSourceQuery.class); ReflectionTestUtils.setField(service, "cargoSources", actualSources); @@ -731,6 +734,20 @@ class DeliveryTransferServiceTest { assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes()); } + /** 已有废弃出库即便快照误存五件,列表及详情汇总也应显示一箱。 */ + @Test + void discardOutboundSummaryCountsReturnCartonOnceAcrossSkuLines() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301"); + outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + var first = pallet(501L, "PALLET-1", null, null, "RT-1", 2); + first.setCartonMarks("BOX-1"); first.setReturnSkuQuantity(2L); + var second = pallet(502L, "PALLET-1", null, null, "RT-1", 3); + second.setCartonMarks("BOX-1"); second.setReturnSkuQuantity(3L); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(first, second)); + assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount()); + } + @Test void dispatchOutboundStillRequiresItsDeliveryDocument() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index 6d10762ba..f558e0df9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -189,6 +189,28 @@ class PdaCargoDiscardServiceTest { verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false)); } + /** 退货一箱五件按指定箱作业,不得按五箱或整托处理。 */ + @Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() { + item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5); + var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1"); + when(returnCartons.selectById(31L)).thenReturn(carton); + when(applications.selectList(any())).thenReturn(List.of(application)); + var task = service.resolve(identity, "PALLET-1", "CD-1"); + assertEquals("CARTON", task.scanMode()); + assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks()); + assertEquals(5, task.skus().get(0).plannedPackages()); + assertEquals("件", task.skus().get(0).quantityUnit()); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5))); + verifyNoInteractions(adjustments); + var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)), + List.of(101L), List.of("RETURN-BOX-1"), false); + service.confirm(identity, "CD-1", "PALLET-1", confirm); + assertEquals(1, application.getTotalPackages()); + assertEquals(new BigDecimal("5.00"), application.getTotalFee()); + verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any()); + } + @Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() { looseTask(); item.setCartonScanRequired(true); var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java index dc5997d36..4a9d22b56 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java @@ -104,4 +104,23 @@ class CargoDiscardOutboundServiceTest { assertThrows(ResponseStatusException.class, () -> service.create(application, "OP")); verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class)); } + + /** 同一退货箱含多个 SKU 时箱数只记一次,件数仍逐 SKU 保留。 */ + @Test void returnCartonProjectionSeparatesOneBoxFromFivePiecesAndSyncsCompletion() { + first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); first.setDiscardPackages(2); + second.setReturnCartonId(31L); second.setSourceType("RETURN_CARTON"); second.setDiscardPackages(3); + var carton = new ReturnInventoryCarton(); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("99"); + carton.setCartonMark("BOX-31"); carton.setPalletCode("PALLET-1"); + when(returnCartons.selectById(31L)).thenReturn(carton); + service.create(application, "OP"); + assertEquals(1, document.getPackageCount()); + assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPackageCount).sum()); + assertEquals(5, snapshots.stream().mapToLong(OutboundPalletItem::getReturnSkuQuantity).sum()); + first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2); + second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(3); + application.setStatus("PDA_COMPLETED"); + service.sync(application); + assertEquals(1, document.getPackageCount()); + assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPickedPackageCount).sum()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index 6ee27dd9f..1915af51e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -99,6 +99,9 @@ class CargoDiscardServiceTest { @BeforeEach void setUp() { service = new CargoDiscardService(); + var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository(); + ReflectionTestUtils.setField(quantities, "items", items); + ReflectionTestUtils.setField(service, "quantities", quantities); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of( new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE")))); @@ -227,6 +230,32 @@ class CargoDiscardServiceTest { assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode()); } + /** 创建和已有单据查询均按退货箱去重,五件不可计为五箱。 */ + @Test void returnMixedSkuCartonCreatesOneBoxAndExistingSummaryReadsOneBox() { + var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100"); + carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("RT-1"); carton.setCartonMark("BOX-1"); + carton.setStatus("AVAILABLE"); + var skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("A"); skuA.setQuantity(2L); + var skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("B"); skuB.setQuantity(3L); + when(returnCartons.lockId(901L)).thenReturn(carton); + when(returnCartons.selectById(901L)).thenReturn(carton); + when(returnCartonItems.lockSku(901L, "A")).thenReturn(skuA); + when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB); + when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB)); + when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1); + var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of( + new CargoDiscardRequests.Item(null, 901L, "A", 2), + new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null)); + assertEquals(1, storedApplication.get().getTotalPackages()); + assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee()); + assertEquals(1, detail.summary().totalPackages()); + assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum()); + storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00")); + var existing = service.detail("100", storedApplication.get().getDiscardNo()); + assertEquals(1, existing.summary().totalPackages()); + assertEquals(new BigDecimal("5.00"), existing.summary().totalFee()); + } + @Test void approvalPushesToPdaWithoutDeductingInventory() { CargoDiscardApplication application = pendingApplication(); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index b38392d65..c95e05789 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 @@ -61,6 +61,7 @@ const MODULE_DEPENDENCIES = new Map([ ['pda-loading-link-regression', ['merchant-qualification-fixture']], ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], ['dropship-pallet-matrix', ['merchant-qualification-fixture']], + ['dropship-mixed-palletization', ['merchant-qualification-fixture']], ['dropship-physical-carton-chain', ['merchant-qualification-fixture']], ['dropship-browser-fixture', ['merchant-qualification-fixture']], ['mixed-container-receiving', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 2fdcca5df..a98160ebb 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -172,6 +172,8 @@ export async function run(context) { `dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。 +`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。 + `dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。 `dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。 diff --git a/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs new file mode 100644 index 000000000..db9f86a2e --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs @@ -0,0 +1,134 @@ +import { run as createOrder } from './orders.mjs' +import { run as measure } from './inbound-measurement.mjs' +import { run as createCustomer } from './merchant-qualification-fixture.mjs' + +const MODULE = 'dropship-mixed-palletization' +const BASE = '/api/platform/v1/first-leg/pda-dropship' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } + +// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。 +export async function run(context) { + const originalMarker = context.marker + const savedContext = { ...context.context } + const originalManualCount = context.options.prepareDropshipManualCount + const records = [] + const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false } + context.createdResources.dropshipMixedPalletization = result + function check(name, response, passed, details = '') { + context.assert(MODULE, name, response, passed, details, OPTIONS) + if (!passed) throw new Error(name) + } + async function command(name, method, path, body, headers = {}) { + const response = await context.request(method, path, body, undefined, headers) + const value = context.apiSuccess(MODULE, name, response, '', OPTIONS) + if (String(response?.body?.code) !== '200') throw new Error(`${name}: ${response?.body?.code}: ${response?.body?.message}`) + return value + } + async function rejected(name, method, path, body) { + const response = await context.request(method, path, body) + check(name, response, response.http === 200 && ['A0404', 'A0409'].includes(String(response.body?.code)), response.body?.message) + } + async function readTask(record) { + return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`) + } + try { + check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId)) + for (const [index, count] of [2, 3, 1].entries()) { + const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index] + context.marker = `${originalMarker}-${label}` + if (index === 1) await createCustomer(context, { publishEnvironment: false }) + for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key] + for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] + context.context.orderDestinationType = 'OVERSEAS' + context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP' + context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' } + context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count], + totalPackageCount: count, palletCount: 1, skuCodes: [`MIX-SAME-${originalMarker}`.slice(-60), `MIX-SECOND-${originalMarker}`.slice(-60)] } + if (records.length) context.context.waitSplitRoute = { originWarehouseName: records[0].order.receivingWarehouseName, + channelCode: records[0].order.channelCode, destinationIndex: 0 } + context.options.prepareDropshipManualCount = true + const start = context.steps.length + await createOrder(context) + await measure(context) + const source = context.createdResources.dropshipManualCount + check(`${label}真实下单审核测量全部通过`, null, Boolean(source?.taskId) + && !context.steps.slice(start).some((step) => !step.passed)) + records.push({ label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source } }) + check(`${label}实测物理箱数正确`, null, source.totalCartons === count) + } + check('两客户三票保持独立归属', null, records.length === 3 && new Set(records.map((r) => r.order.tenantId)).size === 2) + check('三票同仓同渠道同目的仓', null, records.every((r) => r.source.warehouseId === records[0].source.warehouseId + && r.order.channelCode === records[0].order.channelCode && r.order.destinationWarehouseId === records[0].order.destinationWarehouseId)) + context.marker = originalMarker + const warehouseId = records[0].source.warehouseId + const query = `warehouseId=${warehouseId}` + const generated = await command('读取第一票预生成托盘', 'GET', `${BASE}/inbound-orders/${records[0].source.inboundId}/pallets?accountId=${records[0].order.tenantId}`) + const palletNo = generated?.[0]?.palletNo + check('取得空的预生成DP托盘', null, Boolean(palletNo) && generated[0].status === 'OPEN' && generated[0].cartonCount === 0) + result.palletNo = palletNo; result.warehouseId = warehouseId + const palletPath = `${BASE}/pallets/resolve?${query}&palletNo=${encodeURIComponent(palletNo)}` + const initial = await command('先扫空托盘只读识别', 'GET', palletPath) + check('空托盘不会虚构客户组成', null, initial.pallet.cartonCount === 0 && initial.members.length === 0) + await rejected('无效托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?${query}&palletNo=DP-000000-0000`) + await rejected('跨仓托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?warehouseId=1&palletNo=${encodeURIComponent(palletNo)}`) + await rejected('无效箱码被拒绝', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=NO-SUCH-${originalMarker}`) + result.stage = 'COUNTING' + const counted = [] + // 交替扫描客户 A/B,证明页面无需先手工切换入仓单。 + const scans = records.flatMap((record) => Array.from({ length: record.source.totalCartons }, (_, index) => ({ record, boxCode: `${record.source.inboundCode}#${record.source.totalCartons}-${index + 1}` }))) + scans.sort((a, b) => Number(a.boxCode.split('-').at(-1)) - Number(b.boxCode.split('-').at(-1))) + for (const [index, { record, boxCode }] of scans.entries()) { + const resolved = await command('跨客户只读扫码预览', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=${encodeURIComponent(boxCode)}`) + check('箱码返回原客户和原入仓任务', null, String(resolved.task.taskId) === record.source.taskId + && String(resolved.task.tenantId) === record.order.tenantId && resolved.carton.skuLines.length === 2) + const before = await readTask(record) + check('读取箱码未改变清点和归托事实', null, before.countedCartons === counted.filter((x) => x.taskId === record.source.taskId).length) + if (before.taskStatus === 'PENDING') await command('确认时领取原任务', 'POST', `${BASE}/tasks/${record.source.taskId}/claim`, { warehouseId }) + const body = { warehouseId, boxCode, palletNo: index === 0 ? null : palletNo, + skuLines: resolved.carton.skuLines.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode, + actualQuantity: line.expectedQuantity, discrepancyAction: 'MATCH' })), operationId: `${originalMarker}-count-${index}` } + const submitted = await command('按原任务确认逐箱实收', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body) + const replayed = await command('清点同键重放不重复入账', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body) + check('重放保持箱身份和清点数量', null, submitted.carton.cartonId === replayed.carton.cartonId && submitted.countedCartons === replayed.countedCartons) + counted.push({ taskId: record.source.taskId, boxCode, cartonId: submitted.carton.cartonId, inboundId: record.source.inboundId, skuLines: body.skuLines }) + if (index === 0) await command('先扫箱再扫托盘批量归托', 'POST', `${BASE}/tasks/${record.source.taskId}/pallets/bind-pending`, { warehouseId, palletNo, operationId: `${originalMarker}-bind` }) + } + const beforeSeal = await command('封托前逐客户汇总核对', 'GET', palletPath) + check('六个实物箱两客户三票汇总守恒', null, beforeSeal.pallet.cartonCount === 6 && beforeSeal.members.length === 3 + && new Set(beforeSeal.members.map((m) => String(m.customerId))).size === 2 + && beforeSeal.members.every((m) => m.cartonCount === records.find((r) => r.source.taskId === String(m.taskId))?.source.totalCartons)) + const moved = counted.find((item) => item.taskId === records[1].source.taskId) + await command('按客户B原任务移回待绑托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-move-out` }) + const movedTask = await readTask(records[1]) + check('只改变原票归托数量', null, movedTask.unboundCartons === 1 && movedTask.countedCartons === 3) + await command('客户B已点箱归回当前混托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: palletNo, operationId: `${originalMarker}-move-back` }) + const open = await command('归回后读取权威托盘', 'GET', palletPath) + const sealPath = `${BASE}/tasks/${records[0].source.taskId}/pallets/${open.pallet.palletId}/seal` + await command('混托整托封托', 'POST', sealPath, { warehouseId, expectedVersion: open.pallet.version, operationId: `${originalMarker}-seal` }) + let sealed = await command('回查已封托状态', 'GET', palletPath) + check('托盘已封托且六箱完整', null, sealed.pallet.status === 'SEALED' && sealed.pallet.cartonCount === 6) + for (const record of records) { + record.task = await readTask(record) + const inbound = await command('封托后回查原票待分柜', 'GET', `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`) + check('参与票全部完成且进入待分柜', null, record.task.taskStatus === 'COMPLETED' && inbound.summary.processStatus === 'WAIT_SPLIT') + } + const actual = sealed.pallet.cartons + check('每箱客户归属和双SKU实收未合并', null, actual.length === counted.length && actual.every((carton) => { + const source = counted.find((item) => item.boxCode === carton.boxCode) + return source && String(carton.inboundId) === source.inboundId && carton.skuLines.length === 2 + && carton.skuLines.every((line) => source.skuLines.some((expected) => String(line.inboundDetailId) === String(expected.inboundDetailId) && line.actualQuantity === expected.actualQuantity)) + })) + await rejected('已封托禁止移箱', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-sealed-move` }) + await command('未入装柜计划允许解除封托', 'POST', `${BASE}/tasks/${records[0].source.taskId}/pallets/${sealed.pallet.palletId}/unseal`, { warehouseId, expectedVersion: sealed.pallet.version, operationId: `${originalMarker}-unseal` }) + for (const record of records) check('解封同步恢复所有参与任务', null, (await readTask(record)).taskStatus === 'CLAIMED') + const reopened = await command('回查解封后开放状态', 'GET', palletPath) + await command('核对后重新封托', 'POST', sealPath, { warehouseId, expectedVersion: reopened.pallet.version, operationId: `${originalMarker}-reseal` }) + sealed = await command('最终托盘事实', 'GET', palletPath) + result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted + result.stage = 'MIXED_PALLET_SEALED_VERIFIED' + for (const record of records) record.task = await readTask(record) + } finally { + context.marker = originalMarker; context.context = savedContext + context.options.prepareDropshipManualCount = originalManualCount + } +} From 920a7e083dc39807f43c8bd34f28be662e387d96 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 17:02:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/README.md | 2 +- ...ship-mixed-palletization-contract.test.mjs | 33 +++++++++++++++++++ .../dropship-mixed-palletization.mjs | 32 +++++++++++++++++- .../orders-contract.test.mjs | 2 +- scripts/admin-test-env-modules/orders.mjs | 19 +++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index a98160ebb..9efc81f42 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -172,7 +172,7 @@ export async function run(context) { `dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。 -`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。 +`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。另外保留四票已测量、未领取未组托样本:同客户两票(2/1 箱)和跨客户两票(2/3 箱),逐票核对 Admin `WAIT_PALLETIZE` 列表可见,保存 `manualRecords` 和 `manualStage=WAIT_PALLETIZE_VISIBLE`。显式启用 `orders` 的 `orderFixtureOnly` 来源生产范围,保留审核收敛、重放与受理启动,默认订单全量验收仍覆盖申报修改;称重复用当前仓已有托盘,不要求固定编号。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。离线合同为 `dropship-mixed-palletization-contract.test.mjs`。 `dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。 diff --git a/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs b/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs new file mode 100644 index 000000000..22b568ccc --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const source = readFileSync(new URL('./dropship-mixed-palletization.mjs', import.meta.url), 'utf8') +const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') + +test('mixed pallet acceptance uses formal producers and fails on any failed preparation assertion', () => { + assert.match(source, /await createOrder\(context\)/) + assert.match(source, /await measure\(context\)/) + assert.match(source, /!context.steps.slice\(start\).some\(\(step\) => !step.passed\)/) + assert.match(source, /if \(!passed\) throw new Error/) + assert.match(source, /context.context.allowAnyExistingPallet = true/) +}) + +test('retained manual fixtures cover same and different customers and remain visible and unclaimed', () => { + assert.match(source, /\[2, 1, 2, 3\].entries\(\)/) + assert.match(source, /index < 3 \? savedContext.merchantQualificationFixture : secondCustomer/) + assert.match(source, /index < 2 \? 'SAME_CUSTOMER' : 'CROSS_CUSTOMER'/) + assert.match(source, /mainStatus=WAIT_PALLETIZE&inboundCode=/) + assert.match(source, /task.taskStatus === 'PENDING' && task.countedCartons === 0 && task.boundCartons === 0/) + assert.match(source, /result.manualStage = 'WAIT_PALLETIZE_VISIBLE'/) +}) + +test('explicit order producer scope preserves approval convergence and default declaration acceptance', () => { + const branch = orders.indexOf('if (context.context.orderFixtureOnly === true)') + assert.ok(branch > orders.indexOf('const inboundResult = await waitForInbound')) + assert.ok(branch > orders.indexOf('await verifyOrderApprovalReplay(context')) + assert.ok(branch < orders.indexOf('const editableDetailResponse')) + assert.match(orders.slice(branch, orders.indexOf('const editableDetailResponse')), /await finishOrderLifecycle\(context/) + assert.match(orders, /申报修改保持订单号入仓码和渠道且版本递增/) + assert.match(orders, /申报修改异步同步到Firstleg入库事实/) +}) diff --git a/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs index db9f86a2e..a732f79de 100644 --- a/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs +++ b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs @@ -12,6 +12,7 @@ export async function run(context) { const savedContext = { ...context.context } const originalManualCount = context.options.prepareDropshipManualCount const records = [] + let secondCustomer = null const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false } context.createdResources.dropshipMixedPalletization = result function check(name, response, passed, details = '') { @@ -32,11 +33,13 @@ export async function run(context) { return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`) } try { + context.context.orderFixtureOnly = true + context.context.allowAnyExistingPallet = true check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId)) for (const [index, count] of [2, 3, 1].entries()) { const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index] context.marker = `${originalMarker}-${label}` - if (index === 1) await createCustomer(context, { publishEnvironment: false }) + if (index === 1) { await createCustomer(context, { publishEnvironment: false }); secondCustomer = context.context.merchantQualificationFixture } for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key] for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] context.context.orderDestinationType = 'OVERSEAS' @@ -127,6 +130,33 @@ export async function run(context) { result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted result.stage = 'MIXED_PALLET_SEALED_VERIFIED' for (const record of records) record.task = await readTask(record) + // 保留同客户两票和跨客户两票独立样本,均停在未领取、未组托状态。 + result.manualRecords = [] + for (const [index, count] of [2, 1, 2, 3].entries()) { + context.marker = `${originalMarker}-MANUAL-${index + 1}` + context.context.merchantQualificationFixture = index < 3 ? savedContext.merchantQualificationFixture : secondCustomer + for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key] + for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] + context.context.orderDestinationType = 'OVERSEAS' + context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP' + context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count], + totalPackageCount: count, palletCount: 1, skuCodes: [`MANUAL-COMMON-${originalMarker}`.slice(-60), `MANUAL-SECOND-${originalMarker}`.slice(-60)] } + context.options.prepareDropshipManualCount = true + const start = context.steps.length + await createOrder(context) + await measure(context) + const source = context.createdResources.dropshipManualCount + check('人工打托样本正式下单测量通过', null, Boolean(source?.taskId) && !context.steps.slice(start).some((step) => !step.passed)) + const order = { ...context.createdResources.orders } + const list = await command('回查一件代发打托列表可见', 'GET', `/api/platform/v1/orders?mainStatus=WAIT_PALLETIZE&inboundCode=${encodeURIComponent(source.inboundCode)}&page=1&size=20`) + const row = list.records?.find((item) => String(item.orderId) === order.orderId) + check('人工样本出现在用户的一件代发打托页签', null, Boolean(row) && row.mainStatus === 'WAIT_PALLETIZE') + const task = await readTask({ label: `MANUAL-${index + 1}`, source }) + check('人工样本未领取未清点未归托', null, task.taskStatus === 'PENDING' && task.countedCartons === 0 && task.boundCartons === 0) + result.manualRecords.push({ label: `MANUAL-${index + 1}`, group: index < 2 ? 'SAME_CUSTOMER' : 'CROSS_CUSTOMER', + order, source: { ...source }, measurement: { ...context.createdResources.inboundMeasurement } }) + } + result.manualStage = 'WAIT_PALLETIZE_VISIBLE' } finally { context.marker = originalMarker; context.context = savedContext context.options.prepareDropshipManualCount = originalManualCount diff --git a/scripts/admin-test-env-modules/orders-contract.test.mjs b/scripts/admin-test-env-modules/orders-contract.test.mjs index 00f019527..597b210df 100644 --- a/scripts/admin-test-env-modules/orders-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-contract.test.mjs @@ -57,7 +57,7 @@ for (const [status, history] of [['PROCESSING', ['APPROVED', 'ACCEPTED', 'PROCES test('approved stop runs after full approval replay, inbound readiness and shared measurement handoff', () => { const source = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') const run = source.slice(source.indexOf('export async function run(')) - const finish = run.indexOf('await finishOrderLifecycle(') + const finish = run.lastIndexOf('await finishOrderLifecycle(') for (const fragment of ['await verifyOrderApprovalReplay(', '隔离入库单 marker 来源与可测量状态正确', 'context.createdResources.orders.inboundId = inboundId', 'context.context.createdInbound =', '履约任务与本轮订单和客户一致']) { assert.ok(run.indexOf(fragment) >= 0 && run.indexOf(fragment) < finish, fragment) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index a727e8dd2..06f97550a 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -1110,6 +1110,25 @@ export async function run(context) { await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId, inboundCode: id(inboundResult.detail?.summary?.inboundCode) }) + // 专项造单仅生产已审核的真实入库来源;默认全量验收仍继续验证申报修改。 + if (context.context.orderFixtureOnly === true) { + const inbound = inboundResult.detail + Object.assign(context.createdResources.orders, { + inboundId, inboundCode: inbound.summary.inboundCode, + customerRemark: inbound.publicRemark, + internalRemark: `${context.marker} Admin 订单链路验收`, + cargoRemark: payload.cargoes?.[0]?.cargoRemark || null, + processStatus: inbound.summary.processStatus, taskId: id(inboundResult.task.id), + fixtureOnly: true, + }) + const source = { tenantId, inboundId, marker: context.marker } + context.createdResources.ocrInbound = source + context.createdResources.testInbound = source + for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) context.context[key] = source + await finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath }) + return + } + const editableDetailResponse = await context.request('GET', firstLegDetailPath) const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse) const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail