diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index e576142c9..82631be1a 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -36,6 +36,7 @@ | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | +| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index 60088ca91..1405a5e66 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -122,7 +122,7 @@ public interface DispatchAggregateMapper { estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, - delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount}, + delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount}, shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt} WHERE id=#{r.id} AND record_version=#{version} """) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index e2a2baf4b..6e6863d6f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -33,7 +33,7 @@ public interface DispatchSourceMapper { FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id - WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL + WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1 AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1 @@ -41,6 +41,23 @@ public interface DispatchSourceMapper { @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) List confirmedPlan(@Param("customer") String customer,@Param("id") long id); + /** 只有原客户、原计划、原仓已结束且逐项明确为零的来源可移除;缺记录不代表短少。 */ + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_pallet p + JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id + WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan} + AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse} + AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') + AND COALESCE(p.actual_package_count,0)=0 + AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id) + AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id + AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0)) + AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id + WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id) + """) + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse); + /** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */ @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p " + "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id") @@ -115,7 +132,7 @@ public interface DispatchSourceMapper { /** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */ @Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id " - + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')") + + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')") int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id); /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 3e7f1328c..c8a85d1ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -68,6 +68,8 @@ public class DispatchSourceRepository { public List selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } /** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */ public List confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); } + /** 空预排来源必须具备已完成的零实收事实,不能把来源缺失当作零。 */ + public boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; } /** 取得已有真实退回候选。 */ public List selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } /** 同托真实来源订单组成。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 2380891d3..7a5724880 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -733,6 +733,25 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */ void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) { long version=row.getRecordVersion(); + // 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。 + var all=repository.cargo(row.getId()); + boolean removedItems=!Objects.equals(row.getItemCount(),all.size()); + int previousPackages=row.getPackageCount(); + row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum())); + row.setItemCount(all.size()); + row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); + if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(","))); + var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList(); + row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1)); + if(removedItems || previousPackages!=row.getPackageCount()) { + var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key())) + .collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops); + } + if(all.isEmpty()) { + invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核"); + row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false; + } if(preserveReview)row.setReviewBasisHash(basis(row)); row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java index 3970a395d..9a0d9e7cf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java @@ -67,7 +67,13 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接"); boolean preserveReview=workflow.currentReceiptBasis(row); var received=new LinkedHashMap(); - for(var item:pending) { + var empty=new ArrayList(); + for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { + if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) { + empty.add(item); + continue; + } var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources) .orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库")); received.put(item,actual); @@ -79,14 +85,25 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg()); entry.getKey().setVolumeCbm(entry.getValue().volumeCbm()); } - for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { + for(var item:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId()); if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0)) || !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库"); } - var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList(); + // 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。 + if(!empty.isEmpty()) { + var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null); + var original=physicalClaims.lockOwner(owner); + for(var item:empty) { + if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId()))) + throw conflict("空托盘已有真实拣货,不能按短少移除"); + if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化"); + } + physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at); + } + var pallets=received.keySet().stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList(); var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at); for(var entry:received.entrySet()) { var item=entry.getKey();var actual=entry.getValue(); @@ -95,16 +112,8 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存"); } - appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList()); - int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()); - if(total!=row.getPackageCount()) { - var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop( - i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key())) - .collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); - fleet.updateDispatchCargo(number,total,stops); - } - row.setPackageCount(total); - row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); + appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList()); + for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化"); workflow.saveReceivedCargo(row,operator,at,preserveReview); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index ef92711ee..88f56a6ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -38,6 +38,23 @@ public class InboundPlanController { @Resource private InboundPlanService service; @Resource private ContainerInboundPlanConversionCoordinator containerConversion; @Resource private InboundFbaAppointmentService appointmentService; + @Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; + + /** 平台核对现场短少后按实收入库,不修改原应收数量。 */ + @PostMapping("/{planNo}/confirm-shortage") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "确认入库短少", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + public ApiResponse confirmShortage( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可确认入库短少"); + } + String customerId = convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(inboundWorkflow.confirmShortage(customerId, planNo, operatorId, idempotencyKey, request)); + } /** 按入仓计划明细分页查询可预约的入库订单行。 */ @GetMapping diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java new file mode 100644 index 000000000..50bd4ff5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** 平台确认现场短少,数量仅用于核对当前收货事实,不能补写实收。 */ +public record ConfirmInboundShortageRequest( + @NotBlank String customerId, + @NotNull @Min(0) Integer version, + @NotNull @Min(1) Integer expectedPackageCount, + @NotNull @Min(1) Integer receivedPackageCount, + @NotBlank @Size(max = 200) String reason +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java index 18257f5bd..79697a56c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java @@ -11,7 +11,7 @@ public interface InboundDispatchReceiptGateway { /** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */ void complete(List planIds, String operatorId, LocalDateTime at); - /** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */ + /** 只传原计划真实数量;零表示已确认无实收,须释放该托的原预约分配。 */ record Item(long itemId, String customerId, long planPalletId, int packages) { } /** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java index fa28ae74e..f35614d60 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java @@ -10,6 +10,32 @@ import java.util.List; @Mapper public interface ReceivingSkuReceiptMapper extends BaseMapper { + record FrozenSku(String skuCode, long packages) { } + + /** 查询冻结申报的全部 SKU,不能从已自动分摊的实收反推缺失商品。 */ + @Select("SELECT UPPER(TRIM(d.sku_code)) AS skuCode,SUM(d.package_count) AS packages " + + "FROM ll_inbound_plan_line l JOIN ll_order_detail d ON d.order_id=l.order_id AND d.customer_id=l.customer_id " + + "WHERE l.customer_id=#{customerId} AND l.plan_id=#{planId} AND l.id=#{lineId} GROUP BY UPPER(TRIM(d.sku_code))") + List frozenSkus(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId); + + /** 同柜次同 FBA 仓按总箱数收货,跨客户只返回成员数用于拒绝有歧义的短少确认。 */ + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_line selected + JOIN ll_inbound_plan sp ON sp.id=selected.plan_id AND sp.customer_id=selected.customer_id + JOIN ll_order so ON so.id=selected.order_id AND so.customer_id=selected.customer_id + JOIN ll_inbound_plan p ON p.container_no=sp.container_no + JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.customer_id=p.customer_id + JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id + WHERE selected.customer_id=#{customerId} AND selected.plan_id=#{planId} AND selected.id=#{lineId} + AND NULLIF(TRIM(so.source_container_booking_id),'') IS NOT NULL + AND TRIM(o.source_container_booking_id)=TRIM(so.source_container_booking_id) + AND TRIM(o.container_no)=sp.container_no + AND COALESCE(o.order_type,'')<>'ONE_CLICK_DROPSHIP' + AND UPPER(TRIM(l.fba_warehouse_code))=UPPER(TRIM(selected.fba_warehouse_code)) + AND UPPER(COALESCE(NULLIF(TRIM(l.destination_type),''),NULLIF(TRIM(o.destination_type),''),'FBA'))='FBA' + """) + long fbaGroupLineCount(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId); + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + "AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE") List lockLineSku(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index dbd9b4615..3e5d3927b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -36,6 +36,22 @@ public class PdaWorkflowRepository { @Resource private InboundPalletMergeMapper merges; @Resource private InboundPlanCartonMapper cartons; @Resource private LastlegOrderMapper orders; + @Resource private com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper skuReceipts; + + /** 短少确认只能采用本客户本计划已登记的逐 SKU 实收。 */ + public List skuReceipts(String customerId, Long planId) { + return skuReceipts.listPlan(customerId, planId); + } + + /** 冻结申报用于判断总箱数能否唯一确定实际 SKU。 */ + public List frozenSkus(String customerId, Long planId, Long lineId) { + return skuReceipts.frozenSkus(customerId, planId, lineId); + } + + /** 返回整组来源数,不把其他客户的货物明细暴露给操作端。 */ + public long fbaGroupLineCount(String customerId, Long planId, Long lineId) { + return skuReceipts.fbaGroupLineCount(customerId, planId, lineId); + } /** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */ public InboundPlan findPlan(String customerId, String planNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index bcd469f2a..7b6d6bf6e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -716,7 +716,87 @@ public class InboundPdaWorkflowService { repository.update(plan); } - /** Explicit operator decision to receive only the cartons actually picked. */ + /** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */ + @Transactional + public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId, + String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { + if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128 + || operatorId == null || operatorId.isBlank() || request.reason() == null || request.reason().isBlank()) { + throw conflict("短少确认必须提供操作人、请求键和确认原因"); + } + InboundPlan original = repository.findPlan(customerId, planNo); + if (original == null) throw notFound("入库单不存在或不属于当前客户"); + dispatchReceipts.lock(List.of(original.getId())); + InboundPlan plan = requirePlan(customerId, planNo); + // 将单号和操作人纳入幂等摘要,避免跨单或跨操作人复用请求键。 + Map command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request); + InboundPdaOperation replay = replay(customerId, "CONFIRM_SHORTAGE", idempotencyKey, command); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (!"PENDING_RECONCILIATION".equals(plan.getStatus()) || task == null + || !"PENDING_RECONCILIATION".equals(task.getStatus())) { + throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试"); + } + if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少"); + if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单"); + if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) { + throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少"); + } + List lines = repository.lines(plan.getId()); + List allocations = repository.allocations(plan.getId()); + if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) { + throw conflict("短少确认要求每条明细均为 FBA 实收货物"); + } + int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); + int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (!Objects.equals(expected, request.expectedPackageCount()) || !Objects.equals(received, request.receivedPackageCount())) { + throw conflict("应收或实收数量已变化,请刷新后重新确认"); + } + if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少"); + var skuReceipts = repository.skuReceipts(customerId, plan.getId()); + // 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。 + if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量"); + var onlyLine = lines.get(0); + var frozenSkus = repository.frozenSkus(customerId, plan.getId(), onlyLine.getId()); + if (repository.fbaGroupLineCount(customerId, plan.getId(), onlyLine.getId()) != 1 + || frozenSkus.size() != 1 || frozenSkus.get(0).skuCode() == null || frozenSkus.get(0).skuCode().isBlank() + || frozenSkus.get(0).packages() != expected) { + throw conflict("当前仅支持同柜同目的仓单票、单 SKU 短少确认;多票或多 SKU 请先核实实际货物归属"); + } + if (skuReceipts.stream().anyMatch(r -> r.getSkuCode() == null + || !frozenSkus.get(0).skuCode().equalsIgnoreCase(r.getSkuCode().trim()))) { + throw conflict("实收 SKU 与冻结申报不一致,不能确认短少"); + } + for (InboundPlanLine line : lines) { + int picked = allocations.stream().filter(a -> Objects.equals(a.getLineId(), line.getId())) + .mapToInt(a -> value(a.getPickedPackageCount())).sum(); + int skuPackages = skuReceipts.stream().filter(r -> Objects.equals(r.getLineId(), line.getId())) + .mapToInt(r -> value(r.getReceivedPackages())).sum(); + if (picked < 0 || picked > value(line.getRequiredPackageCount()) || picked != skuPackages) { + throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认"); + } + } + if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0 + || value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) { + throw conflict("清点与收货数量不一致,不能确认短少"); + } + List pallets = new ArrayList<>(repository.pallets(plan.getId())); + Set palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet()); + pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId) + .filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet()))); + // 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。 + LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true); + task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan); + dispatchReceipts.complete(List.of(plan.getId()), operatorId, now); + PdaResponses.Task result = taskResponse(task, plan, planNo); + saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result); + return result; + } + + /** 操作员明确选择仅将当前已收箱数入库。 */ @Transactional public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) { InboundPlan plan = requirePlan(customerId, planNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index d28ad8587..03971b733 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -48,6 +48,7 @@ public class InboundStockReceiptService { @Resource private DropshipOrderService dropshipOrders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; + /** 按完整实收生成库存凭证。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, @@ -55,6 +56,7 @@ public class InboundStockReceiptService { stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); } + /** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, @@ -103,7 +105,7 @@ public class InboundStockReceiptService { List exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of()); if (!exactSkuReceipts.isEmpty()) { created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations, - palletById, order, exactSkuReceipts, pickedPackages, receivedAt)); + palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial)); continue; } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) @@ -143,14 +145,17 @@ public class InboundStockReceiptService { completeOrders(customerId, plan, orderById, receivedAt, allowPartial); } + /** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */ private List persistExactSkuReceipts(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, List lineAllocations, Map palletById, LastlegOrder order, - List exactReceipts, long pickedPackages, LocalDateTime receivedAt) { + List exactReceipts, long pickedPackages, LocalDateTime receivedAt, boolean allowPartial) { + if (exactReceipts.stream().anyMatch(r -> r.getSkuCode() == null || r.getSkuCode().isBlank() + || value(r.getReceivedPackages()) <= 0)) throw conflict("SKU 实收必须包含有效 SKU 和正数箱数"); long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total"); - Map expectedBySku = skuRemainders(order.getId(), pickedPackages).stream() + Map expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream() .collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum, java.util.LinkedHashMap::new)); Map actualBySku = exactReceipts.stream().collect(Collectors.groupingBy( @@ -159,7 +164,11 @@ public class InboundStockReceiptService { Map normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap( value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum, java.util.LinkedHashMap::new)); - if (!actualBySku.equals(normalizedExpected)) { + boolean invalid = allowPartial + ? actualBySku.entrySet().stream().anyMatch(entry -> !normalizedExpected.containsKey(entry.getKey()) + || entry.getValue() > normalizedExpected.get(entry.getKey())) + : !actualBySku.equals(normalizedExpected); + if (invalid) { throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 949763110..71619b2f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -738,7 +738,7 @@ public class ReceivingContainerService { boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders && plannedOrders == totalOrders && planCount == totalOrders; boolean allPlansCompleted = !matchedPlans.isEmpty() - && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") + && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = facts == null ? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1 @@ -812,7 +812,7 @@ public class ReceivingContainerService { if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { throw conflict("Every inbound plan in the receiving container must have an owning customer"); } - if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION") + if (values.stream().allMatch(value -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION") .contains(value.getStatus()))) { throw conflict("Receiving container is already completed"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java index 144d78424..44337f458 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java @@ -37,6 +37,14 @@ public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceipt for(var item:items) { var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList(); var physical=repository.physical(item.planPalletId(),item.customerId()); + // 零实收不再占用任何批次额度;明确核对数量和归属后仅移除本车本托分配。 + if(item.packages()==0) { + if(physical.isEmpty() || physical.stream().anyMatch(p->p.countedPackages()==null || p.countedPackages()!=0) + || original.stream().anyMatch(r->!r.customerId().equals(item.customerId()) || r.palletId()!=item.planPalletId())) + throw conflict("空托盘实收或原预约归属不一致,不能释放预约分配"); + binding.removeItem(dispatch,item.itemId()); + continue; + } var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); var quantities=new LinkedHashMap,Integer>(); for(var part:physical) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java index dc8dc34e8..058efb950 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java @@ -20,7 +20,7 @@ public interface OrderWorkflowFactMapper { WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address) AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering, - EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded + EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')) unloaded FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER') AND (o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey}) diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 54830a9ff..71bc1af38 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -667,6 +667,32 @@ paths: responses: '200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } } '404': { description: 入库订单不存在或不属于指定客户 } + /api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage: + post: + summary: 确认FBA入库短少并按实收入库 + description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。仅支持同柜次同目的仓收货组单票、冻结申报单SKU;多SKU或跨票合收不能按自动分摊确认。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。 + security: [{ bearerAuth: [] }] + parameters: + - { name: planNo, in: path, required: true, schema: { type: string } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customerId, version, expectedPackageCount, receivedPackageCount, reason] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + version: { type: integer, minimum: 0 } + expectedPackageCount: { type: integer, minimum: 1 } + receivedPackageCount: { type: integer, minimum: 1 } + reason: { type: string, minLength: 1, maxLength: 200 } + responses: + '200': { description: ApiResponse包装的收货任务结果,status为PARTIAL_INBOUND } + '403': { description: 非平台身份或无入库编辑权限 } + '404': { description: 入库单不存在或客户不匹配 } + '409': { description: 版本、状态、实收数量、SKU或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/manual: post: summary: 平台按单票手工生成入库计划 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java index 412c304be..f64d8e8b5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java @@ -24,6 +24,74 @@ import static org.mockito.Mockito.*; /** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */ class DispatchInboundReceiptPersistenceTest { + /** 两托原预排21箱,短少后20+0:空托、预约和占用一起移除,20箱可继续整托拣货。 */ + @Test void shortageRemovesEmptyPreplannedPalletAndKeepsTwentyPickable() throws Exception { + var f=new Fixture(); + f.p.f.db.update("UPDATE ll_dispatch_item SET package_count=11 WHERE plan_pallet_id=2"); + f.p.f.db.update("UPDATE ll_dispatch_order SET package_count=21"); + f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET package_count=11 WHERE plan_pallet_id=2"); + f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2"); + f.p.f.candidates.get(1L).packageCount(20); + f.p.f.candidates.get(2L).packageCount(0); + doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9")); + f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number))); + f.confirm(20,0); + assertThat(f.quantities()).containsExactly(20); + assertThat(f.allocations()).containsExactly(20); + var row=f.p.f.repository.find(f.p.number); + assertThat(row.getPackageCount()).isEqualTo(20);assertThat(row.getPalletCount()).isEqualTo(1);assertThat(row.getItemCount()).isEqualTo(1); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("RELEASED"); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("RELEASED"); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=501",String.class)).isEqualTo("ACTIVE"); + var version=f.version();f.confirm(20,0);assertThat(f.version()).isEqualTo(version); + var picked=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(version,"shortage-twenty",11L,20,0,null,null))); + assertThat(((DispatchResults.Picked)picked.result()).pickingReconciled()).isTrue(); + verify(f.p.inventory).ship(eq("101"),eq("picker"),any()); + } + + /** 原占用身份不匹配时,库存和预约也必须随空托移除失败一起回滚。 */ + @Test void failedEmptyPalletReleaseRollsBackStockAndDispatch() throws Exception { + var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20); + doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9")); + f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE plan_pallet_id=2"); + assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("逻辑占用"); + assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10); + assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); + } + + /** 零候选但没有明确零实收证明,不能作为空托删除。 */ + @Test void missingEmptyReceiptEvidenceStillRejects() throws Exception { + var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);f.p.f.candidates.get(2L).packageCount(0); + assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收托盘"); + assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10); + } + + /** 本车只预排了未收到的托盘时,收货后保留空车壳并清除货物与旧审核。 */ + @Test void dispatchWithOnlyEmptyPreplannedCargoReturnsToDraft() throws Exception { + var f=new Fixture(); + f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2"); + doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),anyLong(),anyLong(),eq("9")); + f.confirm(0,0); + assertThat(f.quantities()).isEmpty();assertThat(f.allocations()).isEmpty(); + var row=f.p.f.repository.find(f.p.number); + assertThat(row.getPackageCount()).isZero();assertThat(row.getPalletCount()).isZero();assertThat(row.getItemCount()).isZero(); + assertThat(row.getRoute()).isEmpty();assertThat(row.getStage()).isEqualTo("BEFORE_LOADING");assertThat(row.getReviewState()).isEqualTo("DRAFT"); + assertThat(row.getReviewBasisHash()).isNull(); + } + + /** 空托释放后若其他托盘预约交接失败,释放和库存仍须全部回滚。 */ + @Test void appointmentFailureRestoresReleasedEmptyClaim() throws Exception { + var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20); + f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2"); + doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9")); + f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:wrong' WHERE plan_pallet_id=1"); + assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收货物来源"); + assertThat(f.quantities()).containsExactly(10,10); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("ACTIVE"); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("ACTIVE"); + assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); + } + /** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */ @Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception { var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); @@ -53,6 +121,8 @@ class DispatchInboundReceiptPersistenceTest { db.update("DELETE FROM ll_order_detail WHERE customer_id='101'"); assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull(); assertThat(mapper.confirmedPlan("other",1)).isEmpty(); + db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'"); + assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9); db.update("UPDATE ll_inbound_plan SET status='RECEIVING'"); assertThat(mapper.confirmedPlan("101",1)).isEmpty(); } @@ -199,6 +269,7 @@ class DispatchInboundReceiptPersistenceTest { doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L); doReturn(true).when(p.sources).receivedPlanCompleted("101",2L); for(long id=1;id<=2;id++) { + doReturn(false).when(p.sources).confirmedEmptyPlan(eq("101"),eq(id),anyLong(),eq("9")); doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id); doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id); doReturn(id).when(p.sources).lockPlan("101",id); @@ -237,10 +308,15 @@ class DispatchInboundReceiptPersistenceTest { /** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */ void confirm() { + confirm(9,11); + } + + /** 按指定真实数量验证短少和空托交接的同事务持久化。 */ + void confirm(int first,int second) { p.f.tx.executeWithoutResult(s->{ receipt.lock(List.of(1L,2L)); - p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END"); - p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END"); + p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN ? ELSE ? END",first,second); + p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN ? ELSE ? END",first,second); receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC)); }); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index d116164cb..0e44c61cd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -26,7 +26,7 @@ class DispatchPlanPdaReadPersistenceTest { f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')"); var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class); assertThat(mapper.receivedPlanCompleted("101",2L)).isZero(); - for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) { + for(var status:List.of("COMPLETED","PARTIAL_INBOUND","PENDING_RECONCILIATION")) { f.f.db.update("UPDATE ll_inbound_plan SET status=?",status); assertThat(mapper.receivedPlanCompleted("101",2L)).isOne(); assertThat(mapper.receivedPlanCompleted("other",2L)).isZero(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index c34060f95..3912dbdfd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -31,6 +31,7 @@ class InboundPlanControllerTest { @Mock InboundPlanService service; @Mock InboundFbaAppointmentService appointmentService; @Mock ContainerInboundPlanConversionCoordinator containerConversion; + @Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; private InboundPlanController controller; @BeforeEach @@ -39,6 +40,18 @@ class InboundPlanControllerTest { ReflectionTestUtils.setField(controller, "service", service); ReflectionTestUtils.setField(controller, "appointmentService", appointmentService); ReflectionTestUtils.setField(controller, "containerConversion", containerConversion); + ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow); + } + + /** 短少确认只允许平台指定具体客户,并保留真实操作人。 */ + @Test void platformConfirmsShortageWithExplicitCustomer() { + var request = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("100",4,21,20,"实收短少"); + controller.confirmShortage(platform(), "IP-1", "key", request); + verify(inboundWorkflow).confirmShortage("100", "IP-1", "7", "key", request); + var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + assertThatThrownBy(() -> controller.confirmShortage(merchant, "IP-1", "key", request)).isInstanceOf(ResponseStatusException.class); + var noCustomer = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("0",4,21,20,"实收短少"); + assertThatThrownBy(() -> controller.confirmShortage(platform(), "IP-1", "key", noCustomer)).isInstanceOf(ResponseStatusException.class); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java new file mode 100644 index 000000000..bc06cb1fa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java @@ -0,0 +1,153 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.pda.entity.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InboundShortageConfirmationTest { + @Mock PdaWorkflowRepository repository; + @Mock InboundStockReceiptService stock; + @Mock InboundDispatchReceiptGateway dispatch; + @Mock InboundUnloadCompletedOutboxService outbox; + InboundPdaWorkflowService service; + InboundPlan plan; + InboundPdaTask task; + InboundPlanLine line; + InboundPlanPalletAllocation allocation; + final LocalDateTime receivedAt = LocalDateTime.of(2026, 10, 20, 12, 0); + + /** 准备已结束现场收货、应收21实收20的冻结事实。 */ + @BeforeEach void setup() { + service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "stockReceipts", stock); + ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch); + ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100"); + plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(4); + task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100"); + task.setStatus("PENDING_RECONCILIATION"); task.setVersion(2); task.setCompletedAt(receivedAt); + task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt); + line = new InboundPlanLine(); line.setId(3L); line.setOrderId(9L); line.setFbaWarehouseCode("ONT8"); line.setRequiredPackageCount(21); + allocation = new InboundPlanPalletAllocation(); allocation.setId(4L); allocation.setLineId(3L); + allocation.setPalletId(5L); allocation.setPickedPackageCount(20); allocation.setCountedPackageCount(20); + var sku = new ReceivingSkuReceipt(); sku.setLineId(3L); sku.setPalletId(5L); sku.setSkuCode("SKU-1"); sku.setReceivedPackages(20); + when(repository.findPlan("100", "IP-1")).thenReturn(plan); + lenient().when(repository.lockPlan("100", "IP-1")).thenReturn(plan); + lenient().when(repository.lockTask("100", 1L)).thenReturn(task); + lenient().when(repository.lines(1L)).thenReturn(List.of(line)); + lenient().when(repository.allocations(1L)).thenReturn(List.of(allocation)); + lenient().when(repository.skuReceipts("100", 1L)).thenReturn(List.of(sku)); + lenient().when(repository.frozenSkus("100", 1L, 3L)).thenReturn(List.of( + new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",21))); + lenient().when(repository.fbaGroupLineCount("100", 1L, 3L)).thenReturn(1L); + } + + /** 确认后保留原应收,按原收货时点入库并交接运输,重放不会重复记库存。 */ + @Test void stocksActualTwentyOnceAndPreservesShortageAndReceiptTime() { + var request = request(4, 20); + var result = service.confirmShortage("100", "IP-1", "admin", "key", request); + assertEquals("PARTIAL_INBOUND", result.status()); assertEquals(21, line.getRequiredPackageCount()); + assertEquals(receivedAt, task.getCompletedAt()); + var sequence = inOrder(dispatch, repository, stock); + sequence.verify(dispatch).lock(List.of(1L)); sequence.verify(repository).lockPlan("100", "IP-1"); + sequence.verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)), eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true)); + sequence.verify(dispatch).complete(eq(List.of(1L)), eq("admin"), any()); + var audit = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(audit.capture()); + assertTrue(audit.getValue().getRequestJson().contains("现场确认短少1箱")); + when(repository.operation("100", "key")).thenReturn(audit.getValue()); + assertEquals(result, service.confirmShortage("100", "IP-1", "admin", "key", request)); + verify(stock, times(1)).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true)); + verifyNoInteractions(outbox); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "new-key", request)); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); + } + + /** 页面旧版本或旧实收数不能确认库存。 */ + @Test void rejectsStaleVersionAndCounts() { + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20))); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); + verifyNoInteractions(stock); + } + + /** 没有逐 SKU 实收时不能按计划猜测缺少哪个商品。 */ + @Test void rejectsMissingSkuReceipts() { + when(repository.skuReceipts("100", 1L)).thenReturn(List.of()); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock); + } + + /** 即使自动分摊后的实收只有一个 SKU,冻结申报中的另一 SKU 仍使短少归属不明确。 */ + @Test void rejectsMultipleFrozenSkusEvenWhenOnlyOneWasAllocated() { + when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of( + new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",20), + new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-2",1))); + var error=assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20))); + assertTrue(error.getReason().contains("单 SKU")); + verifyNoInteractions(stock);verify(dispatch,never()).complete(anyList(),anyString(),any()); + assertEquals("PENDING_RECONCILIATION",plan.getStatus()); + } + + /** 同柜同目的仓跨票合收时,即使每票单 SKU,也不能猜测短少属于哪个客户。 */ + @Test void rejectsSharedFbaReceivingGroup() { + when(repository.fbaGroupLineCount("100",1L,3L)).thenReturn(2L); + assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20))); + verifyNoInteractions(stock);verify(repository,never()).insert(any(InboundPdaOperation.class)); + } + + /** 客户归属不匹配时不触发任何库存或运输操作。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {0, 21, 22}) + void rejectsZeroCompleteAndExcessReceipts(int received) { + allocation.setPickedPackageCount(received); allocation.setCountedPackageCount(received); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, received))); + verifyNoInteractions(stock); + } + + /** FBA与逐箱入库混合时不能用本确认操作推断SKU归属。 */ + @Test void rejectsMixedDestinations() { + var other = new InboundPlanLine(); other.setId(6L); other.setDestinationType("OVERSEAS"); + when(repository.lines(1L)).thenReturn(List.of(line, other)); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock); + } + + /** 库存落账失败时不推进计划状态和运输交接。 */ + @Test void doesNotAdvanceWhenStockInFails() { + doThrow(new IllegalStateException("库存写入失败")).when(stock).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true)); + assertThrows(IllegalStateException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + assertEquals("PENDING_RECONCILIATION", plan.getStatus()); + verify(dispatch, never()).complete(anyList(), anyString(), any()); + verify(repository, never()).insert(any(InboundPdaOperation.class)); + } + + /** 客户归属不匹配时不触发任何库存或运输操作。 */ + @Test void rejectsWrongCustomer() { + when(repository.findPlan("100", "IP-1")).thenReturn(null); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock, dispatch); + } + + /** 当前实收值随请求一起参与版本及幂等校验。 */ + private ConfirmInboundShortageRequest request(int version, int received) { + return new ConfirmInboundShortageRequest("100", version, 21, received, "现场确认短少1箱"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 79430bd4c..6f82dc31c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -324,6 +324,34 @@ class InboundStockReceiptServiceTest { List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10))); } + /** FBA确认短少后只把20箱登记到库存,不修改21箱的冻结申报。 */ + @Test void exactSkuShortageStocksOnlyActualPackages() { + Fixture f = fixture(21, 21, 20, 210); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 20))); + when(inventory.stockIn(eq("100"), eq("operator"), any())) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 20, 0, 0, 0, false, false)); + service.stockIn("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true); + var request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("100"), eq("operator"), request.capture()); + assertEquals(20, request.getValue().packages()); + assertEquals(21, f.detail.getPackageCount()); + assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus()); + assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus()); + } + + /** 一个SKU少收不能抵消另一个SKU超收,即使总箱数仍少于应收。 */ + @Test void partialSkuReceiptRejectsOverReceiptOfAnotherSku() { + Fixture f = fixture(21, 21, 20, 210); + f.detail.setPackageCount(10); + when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110))); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 11), skuReceipt(12, 21, "SKU-2", 9))); + assertThrows(ResponseStatusException.class, () -> service.stockIn("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true)); + verify(inventory, never()).stockIn(any(), any(), any()); + } + private Fixture fixture(int required, int planned, int picked, int quantity) { InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ShortageReceiptEvidencePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ShortageReceiptEvidencePersistenceTest.java new file mode 100644 index 000000000..d4fe3645f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ShortageReceiptEvidencePersistenceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +/** 真实 Mapper 验证短少准入使用冻结申报、整组来源以及明确零实收,避免 Mock 掩盖取数错误。 */ +class ShortageReceiptEvidencePersistenceTest { + /** 尚未分摊到实收的 SKU 仍须被识别,不能把 A20+B1 的短少误判为单 SKU。 */ + @Test void frozenSkusIncludeCompletelyMissingSkuAndRespectCustomer() throws Exception { + var f=new Fixture(); + f.db.update("INSERT INTO ll_order_detail VALUES('101',11,'A',20),('101',11,'B',1),('other',11,'C',99)"); + assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactlyInAnyOrder( + new ReceivingSkuReceiptMapper.FrozenSku("A",20),new ReceivingSkuReceiptMapper.FrozenSku("B",1)); + assertThat(f.skus.frozenSkus("other",1L,10L)).isEmpty(); + f.db.update("UPDATE ll_order_detail SET sku_code=' a ' WHERE sku_code='B'"); + assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactly(new ReceivingSkuReceiptMapper.FrozenSku("A",21)); + } + + /** 同柜同目的仓另一客户也属于合收组;另一柜次、目的仓和一件代发不参与此分摊。 */ + @Test void countsCrossCustomerSourcesOnlyWithinSameBulkReceiptGroup() throws Exception { + var f=new Fixture();assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1); + f.db.update("INSERT INTO ll_inbound_plan VALUES(2,'202',9,'CONT','COMPLETED')"); + f.db.update("INSERT INTO ll_inbound_plan_line VALUES(20,2,'202',22,'FBA',' ont8 ')"); + f.db.update("INSERT INTO ll_order VALUES(22,'202','BOOK','CONT','FIRST_LEG','FBA')"); + assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(2); + f.db.update("UPDATE ll_order SET source_container_booking_id='ANOTHER' WHERE id=22"); + assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1); + f.db.update("UPDATE ll_order SET source_container_booking_id='BOOK',order_type='ONE_CLICK_DROPSHIP' WHERE id=22"); + assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1); + f.db.update("UPDATE ll_order SET order_type='FIRST_LEG' WHERE id=22"); + f.db.update("UPDATE ll_inbound_plan_line SET fba_warehouse_code='LAX9' WHERE id=20"); + assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1); + assertThat(f.skus.fbaGroupLineCount("202",1L,10L)).isZero(); + } + + /** 缺记录、未结束、非零或已有库存凭据都不能释放预排,只有明确零实收可以。 */ + @Test void emptyPlanRequiresTerminalZeroEvidenceAndOriginalIdentity() throws Exception { + var f=new Fixture(); + f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(100,1,'101',0)"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1000,100,'101',0,0)"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isEqualTo(1); + assertThat(f.dispatch.confirmedEmptyPlan("other",100,1,"9")).isZero(); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,2,"9")).isZero(); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"10")).isZero(); + f.db.update("UPDATE ll_inbound_plan SET status='PENDING_RECONCILIATION'"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + f.db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'"); + f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0,picked_package_count=1"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET picked_package_count=0"); + f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1000,'101')"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + } + + private static final class Fixture { + final JdbcTemplate db; + final ReceivingSkuReceiptMapper skus; + final DispatchSourceMapper dispatch; + + /** 每个测试使用独立内存库,只建立当前查询所需的真实字段。 */ + Fixture() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:shortage-evidence-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,container_no VARCHAR,status VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,destination_type VARCHAR,fba_warehouse_code VARCHAR)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,order_type VARCHAR,destination_type VARCHAR)"); + db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,sku_code VARCHAR,package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,actual_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT,pallet_id BIGINT,customer_id VARCHAR,counted_package_count INT,picked_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_stock_receipt(allocation_id BIGINT,customer_id VARCHAR)"); + db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'CONT','PARTIAL_INBOUND')"); + db.update("INSERT INTO ll_inbound_plan_line VALUES(10,1,'101',11,'FBA','ONT8')"); + db.update("INSERT INTO ll_order VALUES(11,'101','BOOK','CONT','FIRST_LEG','FBA')"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.addMapper(ReceivingSkuReceiptMapper.class);config.addMapper(DispatchSourceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet(); + var session=new SqlSessionTemplate(factory.getObject());skus=session.getMapper(ReceivingSkuReceiptMapper.class);dispatch=session.getMapper(DispatchSourceMapper.class); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java index 6daca4429..472137f4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java @@ -33,6 +33,8 @@ class OrderWorkflowFactMapperTest { db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)"); db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')"); assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();}); + db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.unloaded()).isTrue()); db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'"); db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')"); assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());