diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 70b8b97f2..194087e67 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -37,6 +37,7 @@ | 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` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 | | 普通散货收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。FBA仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;普通OVERSEAS按已扫箱码精确核对SKU与逐托实收,可处理多SKU短少,一件代发整托货物拒绝此操作。逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,OVERSEAS已收箱转AVAILABLE、未收箱保留原状态,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 | +| 收货异常统一核对 | Admin 主入口为 `/{planNo}/receiving-reconciliation` GET/POST → `InboundReceivingReconciliationService`,契约 `inbound-reconciliation-v1.yaml`;按来源柜次、柜号和实际收货仓核对整柜,独立展示实盘与已归属数量。普通FBA支持明确逐托逐票SKU确认短少、超收、零实收和误录更正,每托守恒,原申报和现场时间保留;完成票据及普通地址逐箱身份只读,一件代发独立分组。旧生产 `confirm-shortage` 保留原语义。确认采用当前事实摘要、原因和幂等键,库存、实际体积、托盘、原预排和原始/确认后审计同事务提交,逐SKU短少不能被其他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/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 88f56a6ed..f2bf9391c 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 @@ -39,6 +39,39 @@ public class InboundPlanController { @Resource private ContainerInboundPlanConversionCoordinator containerConversion; @Resource private InboundFbaAppointmentService appointmentService; @Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; + @Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundReceivingReconciliationService receivingReconciliation; + + /** 平台读取同一来源柜次的现场清点与逐票商品归属。 */ + @GetMapping("/{planNo}/receiving-reconciliation") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "查询收货异常核对", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse receivingReconciliation( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @RequestParam String customerId) { + requireReconciliationPlatform(user); + return ApiResponse.success(receivingReconciliation.snapshot(convertScope(user, customerId), planNo)); + } + + /** 逐托逐商品确认短少、超收或误录,保留原申报和完整核对证据。 */ + @PostMapping("/{planNo}/receiving-reconciliation") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "确认收货异常", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + public ApiResponse confirmReceivingReconciliation( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Confirm request) { + requireReconciliationPlatform(user); + String customerId = convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(receivingReconciliation.confirm(customerId, planNo, operatorId, idempotencyKey, request)); + } + + /** 共托核对可能涉及多个客户,仅平台作业域允许读取和确认。 */ + private void requireReconciliationPlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可核对收货异常"); + } + } /** 平台核对现场短少后按实收入库,不修改原应收数量。 */ @PostMapping("/{planNo}/confirm-shortage") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundReceivingReconciliation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundReceivingReconciliation.java new file mode 100644 index 000000000..be4c70b11 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundReceivingReconciliation.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +/** 平台按同柜收货事实核对异常,申报数量与确认实收分别保留。 */ +public final class InboundReceivingReconciliation { + /** 仅承载请求和响应类型。 */ + private InboundReceivingReconciliation() { } + + public record Snapshot(String planNo, String containerNo, String snapshotHash, + int expectedPackageCount, int countedPackageCount, int attributedPackageCount, + boolean canConfirm, String blockedReason, List plans, + List pallets, List receipts) { } + + public record Plan(String customerId, String planNo, String customerName, String status, + int expectedPackageCount, int attributedPackageCount) { } + + public record Pallet(Long palletId, String palletNo, String groupKey, + int countedPackageCount, int attributedPackageCount, boolean editable) { } + + public record Receipt(String customerId, String planNo, Long lineId, String inboundCode, + Long palletId, String palletNo, String skuCode, String productName, + int expectedPackageCount, int receivedPackageCount, boolean editable) { } + + public record Confirm(@NotBlank String customerId, @NotBlank String snapshotHash, + @NotBlank @Size(max = 200) String reason, + @NotNull List<@NotNull @Valid Count> pallets, + @NotNull List<@NotNull @Valid Quantity> receipts) { } + + public record Count(@NotNull Long palletId, + @NotNull @Min(0) Integer countedPackageCount) { } + + public record Quantity(@NotBlank String customerId, @NotBlank String planNo, + @NotNull Long lineId, @NotNull Long palletId, @NotBlank String skuCode, + @NotNull @Min(0) Integer receivedPackageCount) { } + + public record Result(String planNo, String status, int confirmedPackageCount, + List reconciledPlanNos) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundReceivingReconciliationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundReceivingReconciliationMapper.java new file mode 100644 index 000000000..dd30deda0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundReceivingReconciliationMapper.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 异常核对严格使用来源柜次、柜号与实际接柜仓共同确定作业范围。 */ +@Mapper +public interface InboundReceivingReconciliationMapper { + /** 同柜共托需要一起核对,平台确认时按计划主键顺序锁定。 */ + @Select(""" + + """) + @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false) + List scopePlans(@Param("customerId") String customerId, + @Param("planNo") String planNo, @Param("lock") boolean lock); + + /** 完成会话已释放活动键,仍按完整来源身份定位并锁定原会话。 */ + @Select(""" + + """) + @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false) + ReceivingContainerSession session(@Param("bookingId") String bookingId, + @Param("containerNo") String containerNo, @Param("warehouseId") Long warehouseId, + @Param("lock") boolean lock); + + /** 现场实盘与货件分配独立读取,不能将申报内分配数当作完整实盘。 */ + @Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} ORDER BY group_key,pallet_code,id") + List physicalCounts(Long sessionId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/InboundReceivingReconciliationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/InboundReceivingReconciliationRepository.java new file mode 100644 index 000000000..181a930e4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/InboundReceivingReconciliationRepository.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.inboundplan.pda.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.*; +import com.baiyue.lastleg.inboundplan.pda.mapper.*; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Collection; + +/** 将异常核对的持久化访问集中到 Mapper,业务确认规则由服务负责。 */ +@Repository +public class InboundReceivingReconciliationRepository { + @Resource private InboundReceivingReconciliationMapper queries; + @Resource private ReceivingFbaPalletCountMapper physical; + @Resource private ReceivingSkuReceiptMapper skus; + @Resource private ReceivingContainerSessionMapper sessions; + @Resource private InboundStockReceiptMapper stockReceipts; + @Resource private LastlegOrderMapper orders; + + /** 查询或顺序锁定同一来源柜次的全部计划。 */ + public List plans(String customerId, String planNo, boolean lock) { + return queries.scopePlans(customerId, planNo, lock); + } + + /** 精确读取已结束的原收货会话。 */ + public ReceivingContainerSession session(String bookingId, String containerNo, Long warehouseId, boolean lock) { + return queries.session(bookingId, containerNo, warehouseId, lock); + } + + /** 读取独立保存的现场实盘。 */ + public List physicalCounts(Long sessionId) { + return queries.physicalCounts(sessionId); + } + + /** 已完成票据采用真实入库凭证还原 SKU,不以空收货行推断零实收。 */ + public List stockReceipts(String customerId, Long taskId) { + return stockReceipts.listTask(customerId, taskId); + } + + /** 异常入库复用原订单快照,业务服务继续复核每条客户归属。 */ + public List orders(Collection orderIds) { + return orders.selectBatchIds(orderIds); + } + + /** 查询本票已经明确保存的逐托 SKU 实收。 */ + public List skuReceipts(String customerId, Long planId) { + return skus.listPlan(customerId, planId); + } + + /** 仅替换本客户本计划待核对的 SKU 归属,原操作日记保留完整前后证据。 */ + public void replaceSkuReceipts(String customerId, Long planId, List confirmed) { + skus.delete(new LambdaQueryWrapper() + .eq(ReceivingSkuReceipt::getCustomerId, customerId).eq(ReceivingSkuReceipt::getPlanId, planId)); + confirmed.forEach(skus::insert); + } + + /** 确认实盘时更新当前清点,历史原始输入保留在收货与核对操作日记。 */ + public void savePhysical(ReceivingFbaPalletCount count, boolean existing) { + if (!existing) physical.insert(count); + else if (physical.updateById(count) != 1) throw new IllegalStateException("现场清点记录已变化"); + } + + /** 会话版本使并发核对和旧页面快照失效。 */ + public void updateSession(ReceivingContainerSession session) { + if (sessions.updateById(session) != 1) throw new IllegalStateException("收货会话已变化"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationService.java new file mode 100644 index 000000000..083c323d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationService.java @@ -0,0 +1,472 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation; +import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*; +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.mapper.ReceivingSkuReceiptMapper.FrozenSku; +import com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** 平台显式核对逐托、逐票和逐 SKU 实收,统一处理短少、超收及清点误录。 */ +@Service +public class InboundReceivingReconciliationService { + @Resource private InboundReceivingReconciliationRepository reconciliation; + @Resource private PdaWorkflowRepository repository; + @Resource private InboundStockReceiptService stockReceipts; + @Resource private InboundDispatchReceiptGateway dispatchReceipts; + @Resource private ObjectMapper objectMapper; + private static final String ACTION = "CONFIRM_RECEIVING_DIFFERENCE"; + + /** 展示独立实盘和已归属数量,共托来源在同一快照中逐项核对。 */ + @Transactional(readOnly = true) + public Snapshot snapshot(String customerId, String planNo) { + List plans = reconciliation.plans(customerId, planNo, false); + return present(planNo, load(customerId, planNo, plans, false)); + } + + /** 运输父锁先于会话和来源锁;库存、清点更正、状态和审计同事务提交。 */ + @Transactional(isolation = Isolation.READ_COMMITTED) + public Result confirm(String customerId, String planNo, String operatorId, String idempotencyKey, Confirm request) { + if (!Objects.equals(customerId, request.customerId()) || operatorId == null || operatorId.isBlank() + || idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128 + || request.reason() == null || request.reason().isBlank()) { + throw conflict("异常确认必须提供客户、操作人、确认原因及有效请求键"); + } + List original = reconciliation.plans(customerId, planNo, false); + requireSelected(original, customerId, planNo); + List planIds = original.stream().map(InboundPlan::getId).toList(); + dispatchReceipts.lock(planIds); + Scope scope = load(customerId, planNo, original, true); + Map command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request); + InboundPdaOperation replay = repository.operation(customerId, idempotencyKey); + if (replay != null) { + if (!ACTION.equals(replay.getAction()) || !hash(command).equals(replay.getRequestHash())) { + throw conflict("请求键已用于其他异常确认,请刷新后重新操作"); + } + return read(replay.getResultJson(), Result.class); + } + Snapshot before = present(planNo, scope); + if (!before.canConfirm()) throw conflict(before.blockedReason()); + if (!before.snapshotHash().equals(request.snapshotHash())) throw conflict("收货记录已变化,请刷新后重新核对"); + validate(before, request); + String sourceFacts = write(scope); + LocalDateTime now = LocalDateTime.now(); + applyPhysical(scope, before, request, now); + for (PlanState state : scope.states()) { + if (pending(state.plan())) applyReceipts(scope, state, request, now); + } + for (Pallet row : before.pallets()) { + if (!row.editable()) continue; + InboundPlanPallet pallet = scope.pallets().get(row.palletId()); + int count = request.pallets().stream().filter(value -> value.palletId().equals(row.palletId())) + .findFirst().orElseThrow().countedPackageCount(); + pallet.setActualPackageCount(count); + if (count > 0 && row.groupKey().startsWith("FBA:")) pallet.setAssignmentStatus("ASSIGNED"); + BigDecimal actualVolume = scope.states().stream().flatMap(state -> repository.allocations(state.plan().getId()).stream()) + .filter(allocation -> row.palletId().equals(allocation.getPalletId())) + .map(allocation -> Objects.requireNonNullElse(allocation.getActualVolumeCbm(), BigDecimal.ZERO)) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP); + pallet.setActualVolumeCbm(actualVolume); + repository.update(pallet); + } + List completed = new ArrayList<>(); + for (PlanState state : scope.states()) { + if (!pending(state.plan())) continue; + boolean shortage = hasShortage(state, request.receipts()); + stockReceipts.stockInReconciled(state.plan().getCustomerId(), state.plan(), state.task(), + state.lines(), repository.allocations(state.plan().getId()), + new ArrayList<>(scope.pallets().values()), state.task().getCompletedAt()); + state.task().setStatus(shortage ? "PARTIAL_INBOUND" : "COMPLETED"); + state.task().setUpdatedAt(now); + state.task().setVersion(value(state.task().getVersion()) + 1); + repository.update(state.task()); + state.plan().setStatus(state.task().getStatus()); + state.plan().setUpdatedAt(now); + repository.update(state.plan()); + completed.add(state.plan().getPlanNo()); + } + dispatchReceipts.complete(scope.states().stream().filter(state -> completed.contains(state.plan().getPlanNo())) + .map(state -> state.plan().getId()).toList(), operatorId, now); + scope.session().setUpdatedAt(now); + reconciliation.updateSession(scope.session()); + InboundPlan selected = requireSelected(scope.states().stream().map(PlanState::plan).toList(), customerId, planNo); + int confirmed = request.receipts().stream().filter(row -> planNo.equals(row.planNo())) + .mapToInt(Quantity::receivedPackageCount).sum(); + Result result = new Result(planNo, selected.getStatus(), confirmed, List.copyOf(completed)); + InboundPdaOperation audit = new InboundPdaOperation(); + audit.setId(IdWorker.getId()); audit.setCustomerId(customerId); + audit.setTaskId(scope.states().stream().filter(state -> state.plan().getId().equals(selected.getId())) + .findFirst().orElseThrow().task().getId()); + audit.setAction(ACTION); audit.setIdempotencyKey(idempotencyKey); audit.setRequestHash(hash(command)); + audit.setRequestJson(write(Map.of("command", command, "before", before, "sourceFacts", sourceFacts))); + audit.setResultJson(write(result)); audit.setCreatedAt(now); repository.insert(audit); + return result; + } + + /** 先锁原会话,再按主键锁齐计划、任务和托盘来源,阻断并发重新分配。 */ + private Scope load(String customerId, String planNo, List original, boolean lock) { + InboundPlan selected = requireSelected(original, customerId, planNo); + LastlegOrder source = repository.order(selected.getSourceOrderId()); + if (source == null || source.getSourceContainerBookingId() == null) throw conflict("来源柜次信息缺失"); + ReceivingContainerSession session = reconciliation.session(source.getSourceContainerBookingId(), + selected.getContainerNo(), selected.getOverseasWarehouseId(), lock); + List plans = lock ? reconciliation.plans(customerId, planNo, true) : original; + if (!plans.stream().map(InboundPlan::getId).toList().equals(original.stream().map(InboundPlan::getId).toList())) { + throw conflict("本柜入库计划已变化,请刷新后重新核对"); + } + List states = new ArrayList<>(); + Map pallets = new LinkedHashMap<>(); + for (InboundPlan plan : plans) { + InboundPdaTask task = lock ? repository.lockTask(plan.getCustomerId(), plan.getId()) + : repository.task(plan.getCustomerId(), plan.getId()); + List lines = repository.lines(plan.getId()).stream() + .sorted(Comparator.comparing(InboundPlanLine::getId)).toList(); + List allocations = repository.allocations(plan.getId()).stream() + .sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)).toList(); + if (lock) allocations = allocations.stream().map(row -> repository.lockAllocation(plan.getId(), row.getId())).toList(); + for (InboundPlanPallet pallet : repository.pallets(plan.getId()).stream() + .sorted(Comparator.comparing(InboundPlanPallet::getId)).toList()) { + pallets.put(pallet.getId(), lock ? repository.lockPallet(plan.getId(), pallet.getId()) : pallet); + } + Map> frozen = new LinkedHashMap<>(); + for (InboundPlanLine line : lines) frozen.put(line.getId(), repository + .frozenSkus(plan.getCustomerId(), plan.getId(), line.getId()).stream() + .sorted(Comparator.comparing(row -> normalize(row.skuCode()))).toList()); + LastlegOrder order = repository.order(plan.getSourceOrderId()); + List skus = readReceipts(plan, task, allocations); + if (!pending(plan) && order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + for (InboundPlanLine line : lines) frozen.put(line.getId(), skus.stream() + .filter(row -> line.getId().equals(row.getLineId())).collect(Collectors.groupingBy( + row -> normalize(row.getSkuCode()), TreeMap::new, Collectors.summingLong(row -> value(row.getReceivedPackages())))) + .entrySet().stream().map(entry -> new FrozenSku(entry.getKey(), entry.getValue())).toList()); + } + states.add(new PlanState(plan, order, task, lines, allocations, skus, frozen)); + } + List physical = session == null ? List.of() : reconciliation.physicalCounts(session.getId()); + return new Scope(session, states, pallets, physical); + } + + /** 单票单 SKU 的归属唯一时展示完整实盘,其余组合保留分配值供人工逐项核实。 */ + private Snapshot present(String planNo, Scope scope) { + List plans = scope.states().stream().map(state -> new Plan(state.plan().getCustomerId(), + state.plan().getPlanNo(), state.order() == null ? null : state.order().getCustomerName(), state.plan().getStatus(), + state.lines().stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(), + state.allocations().stream().mapToInt(row -> value(row.getPickedPackageCount())).sum())).toList(); + List pallets = new ArrayList<>(); + List receipts = new ArrayList<>(); + for (InboundPlanPallet pallet : scope.pallets().values()) { + String groupKey = groupKey(scope, pallet); + List members = scope.states().stream().filter(state -> state.lines().stream() + .anyMatch(line -> groupKey(scope, line, state.order()).equals(groupKey))).toList(); + int attributed = scope.states().stream().flatMap(state -> state.allocations().stream()) + .filter(row -> pallet.getId().equals(row.getPalletId())) + .mapToInt(row -> value(row.getPickedPackageCount())).sum(); + ReceivingFbaPalletCount physical = scope.physical().stream() + .filter(row -> groupKey.equals(row.getGroupKey()) && Objects.equals(pallet.getPalletNo(), row.getPalletCode())) + .findFirst().orElse(null); + int counted = physical == null ? attributed : value(physical.getActualPackages()); + boolean editable = groupKey.startsWith("FBA:") && members.stream().anyMatch(state -> pending(state.plan())); + pallets.add(new Pallet(pallet.getId(), pallet.getPalletNo(), groupKey, counted, attributed, editable)); + int identities = members.stream().mapToInt(state -> state.lines().stream() + .filter(line -> groupKey.equals(groupKey(scope, line, state.order()))) + .mapToInt(line -> state.frozen().get(line.getId()).size()).sum()).sum(); + for (PlanState state : members) for (InboundPlanLine line : state.lines()) { + if (!groupKey.equals(groupKey(scope, line, state.order()))) continue; + for (FrozenSku sku : state.frozen().get(line.getId())) { + String code = normalize(sku.skuCode()); + int allocated = state.skus().stream().filter(row -> line.getId().equals(row.getLineId()) + && pallet.getId().equals(row.getPalletId()) && code.equals(normalize(row.getSkuCode()))) + .mapToInt(row -> value(row.getReceivedPackages())).sum(); + boolean receiptEditable = pending(state.plan()) && groupKey.startsWith("FBA:"); + int received = identities == 1 && receiptEditable ? counted : allocated; + String product = state.skus().stream().filter(row -> code.equals(normalize(row.getSkuCode()))) + .map(ReceivingSkuReceipt::getProductName).filter(Objects::nonNull).findFirst().orElse(null); + receipts.add(new Receipt(state.plan().getCustomerId(), state.plan().getPlanNo(), line.getId(), + line.getInboundCode(), pallet.getId(), pallet.getPalletNo(), code, product, + Math.toIntExact(sku.packages()), received, receiptEditable)); + } + } + } + String blocked = blocker(scope, pallets, receipts); + return new Snapshot(planNo, scope.states().get(0).plan().getContainerNo(), hash(scope), + plans.stream().mapToInt(Plan::expectedPackageCount).sum(), + pallets.stream().mapToInt(Pallet::countedPackageCount).sum(), + plans.stream().mapToInt(Plan::attributedPackageCount).sum(), blocked == null, blocked, + plans, pallets, receipts); + } + + /** 证据缺失明确提示,避免把未知清点、未知 SKU 或其他作业来源默认为零。 */ + private String blocker(Scope scope, List pallets, List receipts) { + if (scope.session() == null || scope.session().getCompletedAt() == null + || !List.of("COMPLETED", "COMPLETED_WITH_DIFFERENCE").contains(scope.session().getStatus())) { + return "本柜现场收货尚未结束,请先完成现场收货"; + } + if (scope.states().stream().noneMatch(state -> pending(state.plan()))) return "本柜收货异常已经核对完成"; + for (PlanState state : scope.states()) { + if (!List.of("PENDING_RECONCILIATION", "PARTIAL_INBOUND", "COMPLETED").contains(state.plan().getStatus())) { + return "本柜仍有未结束的入库作业,请先完成现场收货"; + } + if (!pending(state.plan())) continue; + if (state.order() != null && "ONE_CLICK_DROPSHIP".equals(state.order().getOrderType())) { + return "一件代发须按冻结来源整托核对,请先核实原托盘及实物一致性"; + } + if (state.task() == null || !"PENDING_RECONCILIATION".equals(state.task().getStatus()) + || state.task().getCompletedAt() == null || state.task().getUnloadPhotoUploadedAt() == null + || state.task().getUnloadPhotoFileIds() == null || "[]".equals(state.task().getUnloadPhotoFileIds())) { + return "待核对入库单缺少现场结束或卸柜凭证"; + } + if (state.lines().isEmpty() || state.frozen().values().stream().anyMatch(rows -> rows.isEmpty() + || rows.stream().anyMatch(row -> normalize(row.skuCode()).isEmpty() || row.packages() < 0))) { + return "冻结商品资料缺失,请先核实每票货物的 SKU 归属"; + } + if (state.lines().stream().anyMatch(line -> value(line.getRequiredPackageCount()) <= 0)) { + return "原应收箱数缺失,请先核实来源入库资料"; + } + if (state.lines().stream().anyMatch(line -> state.frozen().get(line.getId()).stream() + .mapToLong(FrozenSku::packages).sum() != value(line.getRequiredPackageCount()))) { + return "冻结 SKU 箱数与原应收不一致,请先核实来源商品资料"; + } + } + for (ReceivingFbaPalletCount physical : scope.physical()) { + if (pallets.stream().noneMatch(pallet -> Objects.equals(pallet.palletNo(), physical.getPalletCode()) + && Objects.equals(pallet.groupKey(), physical.getGroupKey()))) { + return "存在未关联计划托盘的现场清点记录,请先核实托盘归属"; + } + } + if (pallets.isEmpty() || receipts.isEmpty()) return "收货托盘或商品明细缺失,请先核实来源"; + return null; + } + + /** 请求必须覆盖当前全部明细,每托数量守恒,已完成票据保持原库存事实。 */ + private void validate(Snapshot before, Confirm request) { + Map counts = new LinkedHashMap<>(); + for (Count row : request.pallets()) { + if (row.palletId() == null || row.countedPackageCount() == null || row.countedPackageCount() < 0 + || counts.put(row.palletId(), row) != null) { + throw conflict("逐托清点数量必须为 0 至 66 的整数且不能重复"); + } + } + Map quantities = new LinkedHashMap<>(); + for (Quantity row : request.receipts()) { + if (row.receivedPackageCount() == null || row.receivedPackageCount() < 0 + || quantities.put(key(row.customerId(), row.planNo(), row.lineId(), row.palletId(), row.skuCode()), row) != null) { + throw conflict("逐票 SKU 数量必须为 0 至 66 的整数且不能重复"); + } + } + if (counts.size() != before.pallets().size() || quantities.size() != before.receipts().size()) { + throw conflict("核对明细不完整,请刷新后逐项核对"); + } + for (Receipt row : before.receipts()) { + Quantity submitted = quantities.get(key(row.customerId(), row.planNo(), row.lineId(), row.palletId(), row.skuCode())); + if (submitted == null || (row.editable() && submitted.receivedPackageCount() > 66) + || (!row.editable() && submitted.receivedPackageCount() != row.receivedPackageCount())) { + throw conflict("收货归属已变化或已入库票据被修改,请刷新后重新核对"); + } + } + for (Pallet pallet : before.pallets()) { + Count count = counts.get(pallet.palletId()); + if (count == null || (pallet.editable() && count.countedPackageCount() > 66) + || (!pallet.editable() && count.countedPackageCount() != pallet.countedPackageCount())) { + throw conflict("已完成的托盘清点不能在本次异常确认中修改"); + } + int total = request.receipts().stream().filter(row -> pallet.palletId().equals(row.palletId())) + .mapToInt(Quantity::receivedPackageCount).sum(); + if (total != count.countedPackageCount()) throw conflict("托盘 " + pallet.palletNo() + " 的 SKU 归属合计必须等于核实清点箱数"); + } + } + + /** 现场原始输入已有收货日记,本次审计另保留更新前快照和明确更正原因。 */ + private void applyPhysical(Scope scope, Snapshot before, Confirm request, LocalDateTime now) { + for (Pallet pallet : before.pallets()) { + if (!pallet.editable() || !pallet.groupKey().startsWith("FBA:")) continue; + int count = request.pallets().stream().filter(row -> row.palletId().equals(pallet.palletId())) + .findFirst().orElseThrow().countedPackageCount(); + ReceivingFbaPalletCount physical = scope.physical().stream().filter(row -> row.getGroupKey().equals(pallet.groupKey()) + && row.getPalletCode().equals(pallet.palletNo())).findFirst().orElse(null); + boolean existing = physical != null; + if (!existing) { + physical = new ReceivingFbaPalletCount(); physical.setId(IdWorker.getId()); + physical.setSessionId(scope.session().getId()); physical.setGroupKey(pallet.groupKey()); + physical.setPalletCode(pallet.palletNo()); physical.setCreatedAt(now); + } + physical.setActualPackages(count); physical.setUpdatedAt(now); + reconciliation.savePhysical(physical, existing); + } + } + + /** 将已明确确认的逐 SKU 事实和逐托合计同时写入,原申报分配保持可追溯。 */ + private void applyReceipts(Scope scope, PlanState state, Confirm request, LocalDateTime now) { + List rows = request.receipts().stream().filter(row -> row.planNo().equals(state.plan().getPlanNo()) + && row.customerId().equals(state.plan().getCustomerId())).toList(); + List confirmed = new ArrayList<>(); + for (InboundPlanLine line : state.lines()) for (InboundPlanPallet pallet : scope.pallets().values()) { + List linePallet = rows.stream().filter(row -> row.lineId().equals(line.getId()) + && row.palletId().equals(pallet.getId())).toList(); + if (linePallet.isEmpty()) continue; + int packages = linePallet.stream().mapToInt(Quantity::receivedPackageCount).sum(); + if (packages > 0 && (pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null + || pallet.getWarehouseResourceLockId() == null)) throw conflict("托盘 " + pallet.getPalletNo() + " 缺少实际仓储绑定"); + InboundPlanPalletAllocation allocation = state.allocations().stream() + .filter(row -> line.getId().equals(row.getLineId()) && pallet.getId().equals(row.getPalletId())).findFirst().orElse(null); + if (allocation == null && packages == 0 && !state.plan().getId().equals(pallet.getPlanId())) continue; + if (allocation == null) { + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(state.plan().getCustomerId()); allocation.setPlanId(state.plan().getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setPlannedPackageCount(0); + allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); allocation.setActualVolumeCbm(BigDecimal.ZERO); + allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); allocation.setStatus("PICKED"); + allocation.setVersion(0); repository.insert(allocation); + } + allocation.setCountedPackageCount(packages); allocation.setPickedPackageCount(packages); + allocation.setActualVolumeCbm(Objects.requireNonNullElse(line.getVolumeCbm(), BigDecimal.ZERO) + .divide(BigDecimal.valueOf(line.getRequiredPackageCount()), 8, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(packages)).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus("PICKED"); allocation.setVersion(value(allocation.getVersion()) + 1); repository.update(allocation); + for (Quantity row : linePallet) { + if (row.receivedPackageCount() == 0) continue; + ReceivingSkuReceipt sku = new ReceivingSkuReceipt(); sku.setId(IdWorker.getId()); + sku.setCustomerId(state.plan().getCustomerId()); sku.setSessionId(scope.session().getId()); + sku.setPlanId(state.plan().getId()); sku.setLineId(line.getId()); sku.setPalletId(pallet.getId()); + sku.setOrderId(line.getOrderId()); sku.setInboundCode(line.getInboundCode()); sku.setSkuCode(normalize(row.skuCode())); + sku.setProductName(state.skus().stream().filter(old -> normalize(old.getSkuCode()).equals(sku.getSkuCode())) + .map(ReceivingSkuReceipt::getProductName).filter(Objects::nonNull).findFirst().orElse(null)); + sku.setReceivedPackages(row.receivedPackageCount()); sku.setCreatedAt(state.task().getCompletedAt()); + sku.setUpdatedAt(now); sku.setVersion(0); confirmed.add(sku); + } + } + reconciliation.replaceSkuReceipts(state.plan().getCustomerId(), state.plan().getId(), confirmed); + for (InboundPlanCarton carton : repository.cartons(state.plan().getId())) { + if ("ON_PALLET".equals(carton.getStatus())) { + carton.setStatus("AVAILABLE"); carton.setReceivedAt(state.task().getCompletedAt()); + carton.setUpdatedAt(now); carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + } + } + } + + /** 后台核对复用已入库凭证和逐箱扫描身份,不按申报顺序猜测普通地址货物。 */ + private List readReceipts(InboundPlan plan, InboundPdaTask task, + List allocations) { + List rows = repository.skuReceipts(plan.getCustomerId(), plan.getId()); + if (!rows.isEmpty()) return rows; + List restored = new ArrayList<>(); + if (!pending(plan) && task != null) { + for (InboundStockReceipt stock : reconciliation.stockReceipts(plan.getCustomerId(), task.getId())) { + InboundPlanPalletAllocation allocation = allocations.stream() + .filter(row -> Objects.equals(row.getId(), stock.getAllocationId())).findFirst().orElse(null); + if (allocation == null) throw conflict("已入库凭证缺少原托盘来源,请核实库存事实"); + ReceivingSkuReceipt row = new ReceivingSkuReceipt(); row.setLineId(stock.getLineId()); + row.setPalletId(allocation.getPalletId()); row.setSkuCode(stock.getSkuCode()); + row.setProductName(stock.getProductNameZh()); row.setReceivedPackages(Math.toIntExact(stock.getReceivedPackages())); + restored.add(row); + } + } else { + for (InboundPlanCarton carton : repository.cartons(plan.getId())) { + if (!"ON_PALLET".equals(carton.getStatus())) continue; + ReceivingSkuReceipt row = new ReceivingSkuReceipt(); row.setLineId(carton.getLineId()); + row.setPalletId(carton.getPlanPalletId()); row.setSkuCode(carton.getSkuCode()); row.setReceivedPackages(1); + restored.add(row); + } + } + return restored; + } + + /** SKU 短少单独判定,不能被另一商品超收抵消。 */ + private boolean hasShortage(PlanState state, List rows) { + for (InboundPlanLine line : state.lines()) for (FrozenSku sku : state.frozen().get(line.getId())) { + long actual = rows.stream().filter(row -> row.planNo().equals(state.plan().getPlanNo()) + && row.customerId().equals(state.plan().getCustomerId()) && row.lineId().equals(line.getId()) + && normalize(row.skuCode()).equals(normalize(sku.skuCode()))) + .mapToLong(Quantity::receivedPackageCount).sum(); + if (actual < sku.packages()) return true; + } + return false; + } + + /** 托盘所属目的组来自原计划,实物记录不能跨目的仓或来源柜次归属。 */ + private String groupKey(Scope scope, InboundPlanPallet pallet) { + PlanState owner = scope.states().stream().filter(state -> state.plan().getId().equals(pallet.getPlanId())).findFirst().orElseThrow(); + if (owner.order() != null && "ONE_CLICK_DROPSHIP".equals(owner.order().getOrderType())) { + return "DROPSHIP:" + owner.plan().getContainerNo() + ":" + owner.plan().getPlanNo(); + } + InboundPlanLine line = owner.lines().stream().filter(row -> owner.allocations().stream() + .anyMatch(a -> a.getLineId().equals(row.getId()) && a.getPalletId().equals(pallet.getId()))).findFirst().orElse(null); + if (line != null) return groupKey(scope, line, owner.order()); + String destination = normalize(pallet.getDestinationWarehouseCode()); + return ("FBA".equals(normalize(pallet.getDestinationType())) ? "FBA:" : "OVERSEAS:") + + owner.plan().getContainerNo() + ":" + destination; + } + + /** 普通 FBA 共用目的仓组,私人地址按原目的身份保留独立归属。 */ + private String groupKey(Scope scope, InboundPlanLine line, LastlegOrder order) { + if (order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + String planNo = scope.states().stream().filter(state -> state.lines().contains(line)) + .findFirst().orElseThrow().plan().getPlanNo(); + return "DROPSHIP:" + scope.states().get(0).plan().getContainerNo() + ":" + planNo; + } + String type = normalize(line.getDestinationType()); + if (type.isEmpty() && order != null) type = normalize(order.getDestinationType()); + if (type.isEmpty()) type = normalize(line.getFbaWarehouseCode()).isEmpty() ? "OVERSEAS" : "FBA"; + String destination = "FBA".equals(type) ? normalize(line.getFbaWarehouseCode()) + : normalize(Objects.toString(line.getDestinationWarehouseId(), line.getDestinationWarehouseCode())); + return type + ":" + scope.states().get(0).plan().getContainerNo() + ":" + destination; + } + + /** 先校验请求客户与计划,再允许展开该计划所属的同柜范围。 */ + private InboundPlan requireSelected(List plans, String customerId, String planNo) { + return plans.stream().filter(plan -> customerId.equals(plan.getCustomerId()) && planNo.equals(plan.getPlanNo())) + .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "入库单不存在或来源柜次信息缺失")); + } + /** 仅待核对票据可以更新收货归属。 */ + private static boolean pending(InboundPlan plan) { return "PENDING_RECONCILIATION".equals(plan.getStatus()); } + /** 空计数用于读取汇总,提交数量必须另行显式校验。 */ + private static int value(Integer value) { return value == null ? 0 : value; } + /** SKU 和目的仓采用与现场分组一致的标准化形式。 */ + private static String normalize(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); } + /** 同时限定客户、票、明细、托盘和 SKU,防止跨来源替换。 */ + private static String key(String customer, String plan, Long line, Long pallet, String sku) { + return customer + "/" + plan + "/" + line + "/" + pallet + "/" + normalize(sku); + } + /** 核对条件变化作为业务冲突返回,事务整体撤回。 */ + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + /** 全部版本及现场数量参与摘要,过期页面不能覆盖新的事实。 */ + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + /** 持久化原始事实、操作原因和回执,保证可追溯。 */ + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("异常核对证据序列化失败", exception); } + } + /** 同键重放直接读取原回执,避免再次记库存。 */ + private T read(String json, Class type) { + try { return objectMapper.readValue(json, type); } + catch (JsonProcessingException exception) { throw new IllegalStateException("异常核对回执读取失败", exception); } + } + private record PlanState(InboundPlan plan, LastlegOrder order, InboundPdaTask task, + List lines, List allocations, + List skus, Map> frozen) { } + private record Scope(ReceivingContainerSession session, List states, + Map pallets, List physical) { } +} 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 bf71bc5dc..567e6dff5 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 InboundStockReceiptMapper receipts; @Resource private ReceivingSkuReceiptMapper receivingSkuReceipts; @Resource private PdaWorkflowRepository workflowRepository; + @Resource private com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository reconciliationRepository; @Resource private InventoryCommandService inventory; @Resource private WarehouseResourceGateway warehouseGateway; @Resource private DropshipOrderService dropshipOrders; @@ -61,6 +62,99 @@ public class InboundStockReceiptService { stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); } + /** 平台核对异常后按明确的逐托、逐 SKU 实收入库,保留原申报和现场收货时间。 */ + @Transactional + public void stockInReconciled(String customerId, InboundPlan plan, InboundPdaTask task, + List lines, List allocations, + List pallets, LocalDateTime receivedAt) { + if (lines.isEmpty() || receivedAt == null) throw conflict("异常收货必须保留入库明细和现场收货时间"); + Map orderById = reconciliationRepository.orders(lines.stream() + .map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream() + .filter(order -> customerId.equals(order.getCustomerId())) + .collect(Collectors.toMap(LastlegOrder::getId, order -> order)); + Map palletById = pallets.stream() + .collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet)); + Map allocationById = allocations.stream() + .collect(Collectors.toMap(InboundPlanPalletAllocation::getId, allocation -> allocation)); + Map> exactByLine = reconciliationRepository.skuReceipts(customerId, plan.getId()) + .stream().collect(Collectors.groupingBy(ReceivingSkuReceipt::getLineId)); + Set lineIds = lines.stream().map(InboundPlanLine::getId).collect(Collectors.toSet()); + if (!lineIds.containsAll(exactByLine.keySet()) || allocations.stream() + .anyMatch(allocation -> !lineIds.contains(allocation.getLineId()) + || allocation.getPickedPackageCount() == null || allocation.getCountedPackageCount() == null + || value(allocation.getPickedPackageCount()) < 0 + || !Objects.equals(allocation.getPickedPackageCount(), allocation.getCountedPackageCount()))) { + throw conflict("异常收货的逐票、清点和收货数量不一致"); + } + List confirmed = new java.util.ArrayList<>(); + Map expectedReceipts = new java.util.HashMap<>(); + Map shortages = new java.util.HashMap<>(); + for (InboundPlanLine line : lines) { + LastlegOrder order = orderById.get(line.getOrderId()); + if (order == null || "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + throw conflict("异常收货只支持归属明确的普通入库订单"); + } + Map frozenSkus = skuRemainders(order.getId(), value(line.getRequiredPackageCount())) + .stream().collect(Collectors.toMap(sku -> sku.skuCode.toUpperCase(java.util.Locale.ROOT), + sku -> sku, (first, next) -> { + first.remaining = Math.addExact(first.remaining, next.remaining); + return first; + })); + Map lineAllocations = allocations.stream() + .filter(allocation -> Objects.equals(allocation.getLineId(), line.getId())) + .collect(Collectors.toMap(InboundPlanPalletAllocation::getPalletId, allocation -> allocation)); + Map packagesByPallet = new java.util.HashMap<>(); + Map packagesBySku = new java.util.HashMap<>(); + for (ReceivingSkuReceipt exact : exactByLine.getOrDefault(line.getId(), List.of())) { + String skuCode = required(exact.getSkuCode(), "异常收货必须明确实收 SKU").toUpperCase(java.util.Locale.ROOT); + SkuRemainder frozen = frozenSkus.get(skuCode); + InboundPlanPalletAllocation allocation = lineAllocations.get(exact.getPalletId()); + long packages = value(exact.getReceivedPackages()); + if (frozen == null || allocation == null || packages <= 0) { + throw conflict("异常收货必须使用原申报 SKU、真实托盘和正数箱数"); + } + ReceiptKey key = new ReceiptKey(allocation.getId(), skuCode); + if (expectedReceipts.putIfAbsent(key, packages) != null) throw conflict("同托同 SKU 的实收记录重复"); + packagesByPallet.merge(exact.getPalletId(), packages, Math::addExact); + packagesBySku.merge(skuCode, packages, Math::addExact); + confirmed.add(new ReconciledReceipt(line, allocation, requirePallet(palletById, allocation), order, + new SkuSnapshot(frozen.skuCode, frozen.productNameZh, frozen.productNameEn, packages))); + } + if (lineAllocations.values().stream().anyMatch(allocation -> + value(allocation.getPickedPackageCount()) != packagesByPallet.getOrDefault(allocation.getPalletId(), 0L))) { + throw conflict("异常收货的逐托 SKU 箱数必须等于该托确认实收箱数"); + } + boolean shortage = frozenSkus.entrySet().stream() + .anyMatch(entry -> packagesBySku.getOrDefault(entry.getKey(), 0L) < entry.getValue().remaining); + shortages.merge(order.getId(), shortage, (previous, current) -> previous || current); + } + // 重试只能重放同一份逐托 SKU 凭证,已入账数量发生变化时禁止二次入账或静默跳过。 + List existing = reconciliationRepository.stockReceipts(customerId, task.getId()); + Map existingReceipts = new java.util.HashMap<>(); + for (InboundStockReceipt receipt : existing) { + InboundPlanPalletAllocation allocation = allocationById.get(receipt.getAllocationId()); + ReceiptKey key = new ReceiptKey(receipt.getAllocationId(), + required(receipt.getSkuCode(), "原库存凭证缺少 SKU").toUpperCase(java.util.Locale.ROOT)); + if (allocation == null || !Objects.equals(receipt.getLineId(), allocation.getLineId()) + || existingReceipts.putIfAbsent(key, receipt.getReceivedPackages()) != null) { + throw conflict("原库存凭证与确认实收归属不一致"); + } + } + if (!existing.isEmpty() && !existingReceipts.equals(expectedReceipts)) { + throw conflict("原库存凭证与确认实收数量不一致,不能重复入账"); + } + if (existing.isEmpty()) { + for (ReconciledReceipt receipt : confirmed) { + persist(customerId, plan, task, receipt.line(), receipt.allocation(), receipt.pallet(), receipt.order(), + receipt.sku(), receivedAt); + } + } + // 零实收不产生零数量库存或计租凭证;逐 SKU 短少独立决定每票完成状态。 + for (LastlegOrder order : orderById.values()) { + completeOrders(customerId, plan, Map.of(order.getId(), order), receivedAt, shortages.get(order.getId())); + } + } + /** 按逐 SKU 实收生成凭证;普通海外仓货物以已扫描箱码和实托归属为准。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, @@ -435,5 +529,8 @@ public class InboundStockReceiptService { } } + private record ReceiptKey(Long allocationId, String skuCode) { } + private record ReconciledReceipt(InboundPlanLine line, InboundPlanPalletAllocation allocation, + InboundPlanPallet pallet, LastlegOrder order, SkuSnapshot sku) { } private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {} } diff --git a/lastleg-service/src/main/resources/openapi/inbound-reconciliation-v1.yaml b/lastleg-service/src/main/resources/openapi/inbound-reconciliation-v1.yaml new file mode 100644 index 000000000..86ac1f88d --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/inbound-reconciliation-v1.yaml @@ -0,0 +1,125 @@ +openapi: 3.0.3 +info: + title: 平台收货异常核对 + version: 1.0.0 +paths: + /api/platform/v1/last-leg/inbound-plans/{planNo}/receiving-reconciliation: + parameters: + - { name: planNo, in: path, required: true, schema: { type: string } } + get: + operationId: getInboundReceivingReconciliation + summary: 查询收货异常核对 + description: 仅平台入库列表权限。按所选客户计划的来源柜次、柜号和实际接柜仓查询同柜计划。独立返回现场实盘与已分配数量,完成票据只读。普通FBA单票单SKU组可唯一确定归属时预填完整实盘,其余逐票逐SKU由平台显式核对。 + parameters: + - { name: customerId, in: query, required: true, schema: { type: string } } + responses: + '200': + description: 当前事实快照和确认资格 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { $ref: '#/components/schemas/Snapshot' } + post: + operationId: confirmInboundReceivingReconciliation + summary: 确认收货异常 + description: 仅平台入库编辑权限。必须提交当前snapshotHash、明确原因以及快照内全部逐托和逐票SKU明细。普通FBA可确认短少、超收、零实收或更正误录,每托归属合计须等于核实清点数,单托可编辑数量0至66。普通地址逐箱来源和已入库票据保持只读。原申报不变,按原现场时间生成实际库存及计租批次,逐SKU短少进入PARTIAL_INBOUND,其余进入COMPLETED。库存、托盘、原预排交接和审计同事务提交,保留原清点及归属事实,不重复发布卸柜事件。已完成一件代发来源独立分组,待核对一件代发仍须核实冻结整托身份。 + parameters: + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Confirm' } + responses: + '200': + description: 确认结果,同键同操作人同载荷重放返回原结果 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: + type: object + properties: + planNo: { type: string } + status: { type: string } + confirmedPackageCount: { type: integer } + reconciledPlanNos: { type: array, items: { type: string } } + '409': { description: 旧快照、归属不完整、逐托不平、已完成来源变化或证据缺失 } +components: + schemas: + Snapshot: + type: object + required: [planNo, containerNo, snapshotHash, expectedPackageCount, countedPackageCount, attributedPackageCount, canConfirm, plans, pallets, receipts] + properties: + planNo: { type: string } + containerNo: { type: string } + snapshotHash: { type: string } + expectedPackageCount: { type: integer, description: 本柜原申报箱数 } + countedPackageCount: { type: integer, description: 独立现场实盘箱数 } + attributedPackageCount: { type: integer, description: 当前已归属货件的箱数 } + canConfirm: { type: boolean } + blockedReason: { type: string, nullable: true } + plans: + type: array + items: + type: object + properties: + customerId: { type: string } + planNo: { type: string } + customerName: { type: string, nullable: true } + status: { type: string } + expectedPackageCount: { type: integer } + attributedPackageCount: { type: integer } + pallets: + type: array + items: + allOf: + - { $ref: '#/components/schemas/Count' } + - type: object + properties: + palletNo: { type: string } + groupKey: { type: string } + attributedPackageCount: { type: integer } + editable: { type: boolean } + receipts: + type: array + items: + allOf: + - { $ref: '#/components/schemas/Quantity' } + - type: object + properties: + inboundCode: { type: string, nullable: true } + palletNo: { type: string } + productName: { type: string, nullable: true } + expectedPackageCount: { type: integer, description: 该票SKU原申报合计,同SKU跨托显示重复值 } + editable: { type: boolean } + Confirm: + type: object + required: [customerId, snapshotHash, reason, pallets, receipts] + properties: + customerId: { type: string } + snapshotHash: { type: string } + reason: { type: string, minLength: 1, maxLength: 200 } + pallets: { type: array, items: { $ref: '#/components/schemas/Count' } } + receipts: { type: array, items: { $ref: '#/components/schemas/Quantity' } } + Count: + type: object + required: [palletId, countedPackageCount] + properties: + palletId: { type: string } + countedPackageCount: { type: integer, minimum: 0 } + Quantity: + type: object + required: [customerId, planNo, lineId, palletId, skuCode, receivedPackageCount] + properties: + customerId: { type: string } + planNo: { type: string } + lineId: { type: string } + palletId: { type: string } + skuCode: { type: string } + receivedPackageCount: { type: integer, minimum: 0 } 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 3912dbdfd..9046641c4 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 @@ -32,6 +32,7 @@ class InboundPlanControllerTest { @Mock InboundFbaAppointmentService appointmentService; @Mock ContainerInboundPlanConversionCoordinator containerConversion; @Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; + @Mock com.baiyue.lastleg.inboundplan.pda.service.InboundReceivingReconciliationService receivingReconciliation; private InboundPlanController controller; @BeforeEach @@ -41,6 +42,24 @@ class InboundPlanControllerTest { ReflectionTestUtils.setField(controller, "appointmentService", appointmentService); ReflectionTestUtils.setField(controller, "containerConversion", containerConversion); ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow); + ReflectionTestUtils.setField(controller, "receivingReconciliation", receivingReconciliation); + } + + /** 平台统一核对短少或超收,客户范围、操作人与请求键原样交给事务服务。 */ + @Test void platformConfirmsReceivingDifferenceWithExplicitScopeAndActor() { + var request = new com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Confirm( + "100", "snapshot", "现场核对", List.of(), List.of()); + controller.receivingReconciliation(platform(), "IP-1", "100"); + verify(receivingReconciliation).snapshot("100", "IP-1"); + controller.confirmReceivingReconciliation(platform(), "IP-1", "key", request); + verify(receivingReconciliation).confirm("100", "IP-1", "7", "key", request); + var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + assertThatThrownBy(() -> controller.receivingReconciliation(merchant, "IP-1", "100")) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> controller.confirmReceivingReconciliation(merchant, "IP-1", "key", request)) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> controller.receivingReconciliation(platform(), "IP-1", "0")) + .isInstanceOf(ResponseStatusException.class); } /** 短少确认只允许平台指定具体客户,并保留真实操作人。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationPersistenceTest.java new file mode 100644 index 000000000..6f18c15ab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationPersistenceTest.java @@ -0,0 +1,152 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundReceivingReconciliationMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 使用真实 Mapper 校验整柜范围、历史完成会话和独立实盘取数。 */ +class InboundReceivingReconciliationPersistenceTest { + /** 同来源柜次同仓跨客户共同核对,复用柜号、其他仓及不匹配来源均排除。 */ + @Test + void scopeIncludesOtherCustomersOnlyWithinTheSameBookingContainerAndWarehouse() throws Exception { + var fixture = new Fixture(); + + assertThat(fixture.mapper.scopePlans("101", "IP-10", false)) + .extracting(InboundPlan::getId).containsExactly(10L, 20L); + assertThat(fixture.mapper.scopePlans("202", "IP-20", false)) + .extracting(InboundPlan::getId).containsExactly(10L, 20L); + var lockedPlans = fixture.transaction.execute(status -> fixture.mapper.scopePlans("101", "IP-10", true)); + assertThat(lockedPlans).extracting(InboundPlan::getId).containsExactly(10L, 20L); + } + + /** 必须先定位指定客户的真实计划,空客户、其他客户和缺失柜次均不能扩大范围。 */ + @Test + void scopeRequiresTheSelectedCustomerAndANonblankBooking() throws Exception { + var fixture = new Fixture(); + + assertThat(fixture.mapper.scopePlans(null, "IP-10", false)).isEmpty(); + assertThat(fixture.mapper.scopePlans("", "IP-10", false)).isEmpty(); + assertThat(fixture.mapper.scopePlans("202", "IP-10", false)).isEmpty(); + assertThat(fixture.mapper.scopePlans("101", "MISSING", false)).isEmpty(); + assertThat(fixture.mapper.scopePlans("101", "IP-70", false)).isEmpty(); + assertThat(fixture.mapper.scopePlans("101", "IP-80", false)).isEmpty(); + assertThat(fixture.mapper.scopePlans("101", "IP-90", false)).isEmpty(); + } + + /** 已完成会话释放活动键后,仍按柜次、柜号和仓定位,并能在事务中锁定。 */ + @Test + void completedSessionCanBeLocatedAndLockedAfterItsActiveKeysAreCleared() throws Exception { + var fixture = new Fixture(); + var originalTime = LocalDateTime.of(2026, 11, 6, 10, 0); + var latestTime = originalTime.plusHours(1); + fixture.session(100L, "BOOK-1", "CONT-1", 9L, "COMPLETED", originalTime); + fixture.session(110L, "BOOK-1", "CONT-1", 9L, "COMPLETED", latestTime); + fixture.session(111L, "BOOK-1", "CONT-1", 9L, "COMPLETED_WITH_DIFFERENCE", latestTime); + fixture.session(120L, "BOOK-2", "CONT-1", 9L, "COMPLETED", latestTime.plusHours(1)); + fixture.session(130L, "BOOK-1", "CONT-1", 10L, "COMPLETED", latestTime.plusHours(1)); + fixture.session(140L, "BOOK-1", "CONT-2", 9L, "COMPLETED", latestTime.plusHours(1)); + fixture.session(150L, "book-1", "CONT-1", 9L, "COMPLETED", latestTime.plusHours(1)); + + ReceivingContainerSession session = fixture.transaction.execute(status -> + fixture.mapper.session("BOOK-1", "CONT-1", 9L, true)); + + assertThat(session).isNotNull(); + assertThat(session.getId()).isEqualTo(111L); + assertThat(session.getStatus()).isEqualTo("COMPLETED_WITH_DIFFERENCE"); + assertThat(session.getActiveOperatorKey()).isNull(); + assertThat(session.getActiveContainerKey()).isNull(); + assertThat(session.getCompletedAt()).isEqualTo(latestTime); + assertThat(fixture.mapper.session("BOOK-1", "CONT-1", 9L, false).getId()).isEqualTo(111L); + assertThat(fixture.mapper.session("MISSING", "CONT-1", 9L, false)).isNull(); + assertThat(fixture.mapper.session(null, "CONT-1", 9L, false)).isNull(); + } + + /** 独立实盘保留超申报的完整数量及明确零记录,其他会话同码托盘不能混入。 */ + @Test + void physicalCountsRetainActualAndExplicitZeroWithinTheirOriginalSession() throws Exception { + var fixture = new Fixture(); + fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (2,111,'FBA:CONT-1:ONT8','PAL-2',0)"); + fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (1,111,'FBA:CONT-1:ONT8','PAL-1',22)"); + fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (3,222,'FBA:CONT-1:ONT8','PAL-1',66)"); + + var physical = fixture.mapper.physicalCounts(111L); + + assertThat(physical).extracting(ReceivingFbaPalletCount::getId).containsExactly(1L, 2L); + assertThat(physical).extracting(ReceivingFbaPalletCount::getActualPackages).containsExactly(22, 0); + assertThat(physical).extracting(ReceivingFbaPalletCount::getSessionId).containsOnly(111L); + assertThat(fixture.mapper.physicalCounts(999L)).isEmpty(); + } + + private static final class Fixture { + final JdbcTemplate db; + final TransactionTemplate transaction; + final InboundReceivingReconciliationMapper mapper; + + /** 仅建立本次生产 SQL 所需列,使用真实 MyBatis 和独立 READ_COMMITTED 事务。 */ + Fixture() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:receiving-reconciliation-" + + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", ""); + db = new JdbcTemplate(dataSource); + transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(128)," + + "customer_id VARCHAR(128),source_order_id BIGINT,container_no VARCHAR(64)," + + "overseas_warehouse_id BIGINT,status VARCHAR(32))"); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128)," + + "source_container_booking_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_receiving_container_session(id BIGINT PRIMARY KEY," + + "source_container_booking_id VARCHAR(128),container_no VARCHAR(64)," + + "overseas_warehouse_id BIGINT,status VARCHAR(32),active_operator_key VARCHAR(255)," + + "active_container_key VARCHAR(255),created_at TIMESTAMP,completed_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_receiving_fba_pallet_count(id BIGINT PRIMARY KEY,session_id BIGINT," + + "group_key VARCHAR(255),pallet_code VARCHAR(128),actual_packages INT)"); + plan(20L, "202", "BOOK-1", "CONT-1", 9L); + plan(10L, "101", "BOOK-1", "CONT-1", 9L); + plan(30L, "101", "BOOK-2", "CONT-1", 9L); + plan(40L, "101", "BOOK-1", "CONT-1", 10L); + plan(50L, "101", "BOOK-1", "CONT-2", 9L); + plan(60L, "101", "book-1", "CONT-1", 9L); + plan(70L, "101", "BOOK-1", "CONT-1", 9L); + db.update("UPDATE ll_order SET customer_id='202' WHERE id=1070"); + plan(80L, "101", null, "CONT-1", 9L); + plan(90L, "101", " ", "CONT-1", 9L); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(InboundReceivingReconciliationMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setConfiguration(configuration); + factory.afterPropertiesSet(); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundReceivingReconciliationMapper.class); + } + + /** 插入真实客户与来源订单的计划关系,测试只通过正式 Mapper 查询范围。 */ + private void plan(long id, String customerId, String bookingId, String containerNo, long warehouseId) { + db.update("INSERT INTO ll_order VALUES (?,?,?)", id + 1000, customerId, bookingId); + db.update("INSERT INTO ll_inbound_plan VALUES (?,?,?,?,?,?,?)", id, "IP-" + id, + customerId, id + 1000, containerNo, warehouseId, "PENDING_RECONCILIATION"); + } + + /** 历史完成会话的活动唯一键为空,原始完成时间仍可作为核对证据。 */ + private void session(long id, String bookingId, String containerNo, long warehouseId, + String status, LocalDateTime createdAt) { + db.update("INSERT INTO ll_receiving_container_session VALUES (?,?,?,?,?,?,?,?,?)", + id, bookingId, containerNo, warehouseId, status, null, null, createdAt, createdAt); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationRequestTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationRequestTest.java new file mode 100644 index 000000000..cb90ba2cf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationRequestTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import java.util.Arrays; +import java.util.List; +import java.util.stream.IntStream; +import static org.assertj.core.api.Assertions.assertThat; + +/** 请求层接受当前完整核对范围,数量守恒与只读事实继续由服务校验。 */ +class InboundReceivingReconciliationRequestTest { + /** 多票多商品的完整矩阵不能被任意一万行上限阻塞。 */ + @Test void acceptsCompleteLargeReconciliationSnapshot() { + List rows = IntStream.range(0, 10400).mapToObj(index -> + new Quantity("100", "IP-1", 3L, 10L + index / 400, "SKU-" + index % 400, 0)).toList(); + List pallets = IntStream.range(0, 26).mapToObj(index -> new Count(10L + index, 0)).toList(); + try (var factory = Validation.buildDefaultValidatorFactory()) { + assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", "逐托复核", pallets, rows))).isEmpty(); + } + } + + /** 空行必须在进入业务事务前拒绝,不能触发空引用错误。 */ + @Test void rejectsNullListElements() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + assertThat(validator.validate(new Confirm("100", "snapshot", "现场复核", Arrays.asList((Count) null), List.of()))) + .isNotEmpty(); + assertThat(validator.validate(new Confirm("100", "snapshot", "现场复核", List.of(), Arrays.asList((Quantity) null)))) + .isNotEmpty(); + } + } + + /** 已完成只读整托可以超过普通FBA单托上限,服务仍禁止改写这些数量。 */ + @Test void acceptsReadonlySourceQuantityAboveBulkLimit() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", "现场复核", + List.of(new Count(10L, 80)), List.of(new Quantity("100", "IP-1", 3L, 10L, "SKU-1", 80))))) + .isEmpty(); + } + } + + /** 原因必填,负数清点或SKU数量均在请求层拒绝。 */ + @Test void rejectsNegativeCountsAndMissingReason() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", " ", + List.of(new Count(10L, -1)), List.of(new Quantity("100", "IP-1", 3L, 10L, "SKU-1", -1))))) + .hasSize(3); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationServiceTest.java new file mode 100644 index 000000000..a3e37c500 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundReceivingReconciliationServiceTest.java @@ -0,0 +1,266 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*; +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.mapper.ReceivingSkuReceiptMapper.FrozenSku; +import com.baiyue.lastleg.inboundplan.pda.repository.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +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.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InboundReceivingReconciliationServiceTest { + @Mock InboundReceivingReconciliationRepository reconciliation; + @Mock PdaWorkflowRepository repository; + @Mock InboundStockReceiptService stock; + @Mock InboundDispatchReceiptGateway dispatch; + InboundReceivingReconciliationService service; + InboundPlan plan; + InboundPdaTask task; + InboundPlanLine line; + ReceivingContainerSession session; + List pallets; + List allocations; + final LocalDateTime receivedAt = LocalDateTime.of(2026, 11, 6, 11, 28); + + /** 现场5加20箱,申报内仅分配5加16箱,另有一个明确待核对的预留托盘。 */ + @BeforeEach void setup() { + service = new InboundReceivingReconciliationService(); + ReflectionTestUtils.setField(service, "reconciliation", reconciliation); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "stockReceipts", stock); + ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100"); + plan.setSourceOrderId(9L); plan.setContainerNo("BOX-1"); plan.setOverseasWarehouseId(99L); + plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(5); + LastlegOrder order = new LastlegOrder(); order.setId(9L); order.setCustomerId("100"); + order.setCustomerName("核对客户"); order.setSourceContainerBookingId("BOOKING-1"); + order.setOrderType("FIRST_LEG"); order.setDestinationType("FBA"); + task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100"); + task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(receivedAt); task.setVersion(4); + task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt); + line = new InboundPlanLine(); line.setId(3L); line.setPlanId(1L); line.setCustomerId("100"); + line.setOrderId(9L); line.setInboundCode("IN-1"); line.setFbaWarehouseCode("ACY2"); line.setRequiredPackageCount(21); + session = new ReceivingContainerSession(); session.setId(50L); session.setVersion(4); + session.setSourceContainerBookingId("BOOKING-1"); session.setContainerNo("BOX-1"); + session.setOverseasWarehouseId(99L); session.setStatus("COMPLETED_WITH_DIFFERENCE"); session.setCompletedAt(receivedAt); + pallets = new ArrayList<>(); allocations = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(10L+i); pallet.setPlanId(1L); + pallet.setCustomerId("100"); pallet.setPalletNo("PALLET-"+i); pallet.setActualPackageCount(i == 0 ? 5 : i == 1 ? 16 : 0); + pallet.setWarehousePalletId(100L+i); pallet.setWarehouseLocationId(200L+i); pallet.setWarehouseResourceLockId(300L+i); + pallet.setDestinationType("FBA"); pallet.setDestinationWarehouseCode("ACY2"); pallet.setVersion(1); pallets.add(pallet); + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(20L+i); + allocation.setCustomerId("100"); allocation.setPlanId(1L); allocation.setLineId(3L); allocation.setPalletId(pallet.getId()); + allocation.setPlannedPackageCount(7); allocation.setCountedPackageCount(pallet.getActualPackageCount()); + allocation.setPickedPackageCount(pallet.getActualPackageCount()); allocation.setVersion(1); allocations.add(allocation); + } + lenient().when(reconciliation.plans(eq("100"), eq("IP-1"), anyBoolean())).thenReturn(List.of(plan)); + when(repository.order(9L)).thenReturn(order); + lenient().when(reconciliation.session(eq("BOOKING-1"), eq("BOX-1"), eq(99L), anyBoolean())).thenReturn(session); + lenient().when(repository.task("100",1L)).thenReturn(task); + lenient().when(repository.lockTask("100",1L)).thenReturn(task); + when(repository.lines(1L)).thenReturn(List.of(line)); when(repository.pallets(1L)).thenReturn(pallets); + when(repository.allocations(1L)).thenReturn(allocations); + for (int i=0; i<3; i++) { + lenient().when(repository.lockAllocation(1L,20L+i)).thenReturn(allocations.get(i)); + lenient().when(repository.lockPallet(1L,10L+i)).thenReturn(pallets.get(i)); + } + when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(new FrozenSku("SKU-1",21))); + when(repository.skuReceipts("100",1L)).thenReturn(List.of(sku(10L,"SKU-1",5),sku(11L,"SKU-1",16))); + lenient().when(reconciliation.physicalCounts(50L)).thenReturn(List.of(physical("PALLET-0",5), physical("PALLET-1",20))); + } + + /** 页面保留21箱归属,独立展示25箱实盘,唯一SKU预填完整实盘。 */ + @Test void exposesPhysicalTwentyFiveSeparatelyFromAttributedTwentyOne() { + Snapshot snapshot = service.snapshot("100","IP-1"); + assertTrue(snapshot.canConfirm()); assertEquals(21,snapshot.expectedPackageCount()); + assertEquals(21,snapshot.attributedPackageCount()); assertEquals(25,snapshot.countedPackageCount()); + assertEquals(List.of(5,20,0), snapshot.receipts().stream().map(Receipt::receivedPackageCount).toList()); + verifyNoInteractions(stock,dispatch); + } + + /** 超收按25箱入库,原申报21与原现场时间保留,同时交接运输并记录完整前后证据。 */ + @Test void confirmsOverReceiptAndReplaysWithoutDuplicatingStock() { + Confirm request = request(service.snapshot("100","IP-1"), 5,20,0); + Result result = service.confirm("100","IP-1","admin","key",request); + assertEquals("COMPLETED",result.status()); assertEquals(25,result.confirmedPackageCount()); + assertEquals(21,line.getRequiredPackageCount()); assertEquals(receivedAt,task.getCompletedAt()); + assertEquals(20,allocations.get(1).getPickedPackageCount()); assertEquals(20,pallets.get(1).getActualPackageCount()); + var sequence = inOrder(dispatch,reconciliation,stock); + sequence.verify(dispatch).lock(List.of(1L)); + sequence.verify(reconciliation).session("BOOKING-1","BOX-1",99L,true); + sequence.verify(stock).stockInReconciled(eq("100"),eq(plan),eq(task),eq(List.of(line)),anyList(),anyList(),eq(receivedAt)); + 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("sourceFacts")); + assertTrue(audit.getValue().getRequestJson().contains("现场核实数量")); + when(repository.operation("100","key")).thenReturn(audit.getValue()); + assertEquals(result,service.confirm("100","IP-1","admin","key",request)); + verify(stock,times(1)).stockInReconciled(anyString(),any(),any(),anyList(),anyList(),anyList(),any()); + assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","other-admin","key",request)); + } + + /** 清点误录可在明确原因下更正为21箱,旧实盘完整留在审计中。 */ + @Test void correctsPhysicalEntryAndCompletesAtTwentyOne() { + Result result=service.confirm("100","IP-1","admin","key",request(service.snapshot("100","IP-1"),5,16,0)); + assertEquals(21,result.confirmedPackageCount()); assertEquals("COMPLETED",result.status()); + var saved=ArgumentCaptor.forClass(ReceivingFbaPalletCount.class); + verify(reconciliation,times(3)).savePhysical(saved.capture(),anyBoolean()); + assertEquals(List.of(5,16,0),saved.getAllValues().stream().map(ReceivingFbaPalletCount::getActualPackages).toList()); + } + + /** 短少与全无实收都可确认,预留托盘明确写零,不伪造库存箱数。 */ + @Test void confirmsShortageIncludingExplicitZero() { + Result result=service.confirm("100","IP-1","admin","key",request(service.snapshot("100","IP-1"),0,0,0)); + assertEquals("PARTIAL_INBOUND",result.status()); assertEquals(0,result.confirmedPackageCount()); + assertTrue(allocations.stream().allMatch(row->row.getCountedPackageCount()==0&&row.getPickedPackageCount()==0)); + verify(reconciliation).replaceSkuReceipts("100",1L,List.of()); + verify(dispatch).complete(eq(List.of(1L)),eq("admin"),any()); + } + + /** 每托归属必须与确认清点守恒,拒绝用21箱分配掩盖25箱实盘。 */ + @Test void rejectsUnattributedOverReceiptBeforeAnyWrite() { + Snapshot snapshot=service.snapshot("100","IP-1"); Confirm good=request(snapshot,5,20,0); + Confirm bad=new Confirm("100",snapshot.snapshotHash(),"现场核实数量",good.pallets(),request(snapshot,5,16,0).receipts()); + assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",bad)); + verifyNoInteractions(stock); verify(reconciliation,never()).savePhysical(any(),anyBoolean()); + } + + /** 旧快照不能覆盖另一操作的最新清点。 */ + @Test void rejectsStaleSnapshot() { + Snapshot snapshot=service.snapshot("100","IP-1"); Confirm good=request(snapshot,5,20,0); + session.setVersion(5); + assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",good)); + verifyNoInteractions(stock); verify(reconciliation,never()).replaceSkuReceipts(anyString(),anyLong(),anyList()); + } + + /** 多SKU必须显式核对,不将总箱数自动猜分给商品。 */ + @Test void explicitSkuShortageCannotBeOffsetByAnotherSkuOverReceipt() { + when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(new FrozenSku("SKU-1",10),new FrozenSku("SKU-2",11))); + when(repository.skuReceipts("100",1L)).thenReturn(List.of(sku(10L,"SKU-1",5),sku(11L,"SKU-2",16))); + Snapshot snapshot=service.snapshot("100","IP-1"); + assertEquals(21,snapshot.receipts().stream().mapToInt(Receipt::receivedPackageCount).sum()); + List quantities=snapshot.receipts().stream().map(row -> new Quantity(row.customerId(),row.planNo(),row.lineId(),row.palletId(),row.skuCode(), + row.palletId()==10L&&row.skuCode().equals("SKU-1")?5: + row.palletId()==11L&&row.skuCode().equals("SKU-1")?4: + row.palletId()==11L&&row.skuCode().equals("SKU-2")?16:0)).toList(); + Confirm request=new Confirm("100",snapshot.snapshotHash(),"逐商品核实",request(snapshot,5,20,0).pallets(),quantities); + Result result=service.confirm("100","IP-1","admin","key",request); + assertEquals("PARTIAL_INBOUND",result.status()); assertEquals(25,result.confirmedPackageCount()); + } + + /** 非本人范围和缺失原会话不能进入确认。 */ + @Test void rejectsForeignScopeAndMissingSession() { + assertThrows(ResponseStatusException.class,()->service.snapshot("200","IP-1")); + when(reconciliation.session(eq("BOOKING-1"),eq("BOX-1"),eq(99L),anyBoolean())).thenReturn(null); + Snapshot snapshot=service.snapshot("100","IP-1"); assertFalse(snapshot.canConfirm()); + assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",request(snapshot,5,20,0))); + verifyNoInteractions(stock); + } + + /** 原现场仍在收货时禁止后台抢先入库。 */ + @Test void blocksActiveOnsiteSession() { + session.setStatus("RECEIVING"); assertFalse(service.snapshot("100","IP-1").canConfirm()); + } + + /** 已完成的普通地址票据从真实库存凭证恢复,不能因缺少批量收货行阻塞本柜核对。 */ + @Test void completedOverseasOrderKeepsItsRealStockImmutable() { + addCompletedPlan(false); + Snapshot snapshot = service.snapshot("100", "IP-1"); + Receipt fixed = snapshot.receipts().stream().filter(row -> row.planNo().equals("IP-2")).findFirst().orElseThrow(); + assertEquals(8, fixed.receivedPackageCount()); + assertFalse(fixed.editable()); + Result result = service.confirm("100", "IP-1", "admin", "key", request(snapshot, 5, 20, 0)); + assertEquals("COMPLETED", result.status()); + assertEquals(List.of("IP-1"), result.reconciledPlanNos()); + verify(stock, never()).stockInReconciled(eq("200"), any(), any(), anyList(), anyList(), anyList(), any()); + } + + /** 同目的仓一件代发的原整托独立冻结,普通票不能把额外实收写入该托。 */ + @Test void completedDropshipPalletCannotReceiveOrdinaryFbaCargo() { + addCompletedPlan(true); + Snapshot snapshot = service.snapshot("100", "IP-1"); + Pallet dropship = snapshot.pallets().stream().filter(row -> row.palletId().equals(64L)).findFirst().orElseThrow(); + assertTrue(dropship.groupKey().startsWith("DROPSHIP:")); + assertFalse(dropship.editable()); + assertTrue(snapshot.receipts().stream().filter(row -> row.palletId().equals(64L)) + .allMatch(row -> row.planNo().equals("IP-2") && !row.editable())); + assertEquals("COMPLETED", service.confirm("100", "IP-1", "admin", "key", request(snapshot, 5, 20, 0)).status()); + } + + /** 清点量变化时逐来源及整托体积同步采用现有单箱体积口径。 */ + @Test void recalculatesActualVolumesFromConfirmedCounts() { + line.setVolumeCbm(new java.math.BigDecimal("2.1000")); + service.confirm("100", "IP-1", "admin", "key", request(service.snapshot("100", "IP-1"), 5, 20, 0)); + assertEquals(new java.math.BigDecimal("2.0000"), allocations.get(1).getActualVolumeCbm()); + assertEquals(new java.math.BigDecimal("2.0000"), pallets.get(1).getActualVolumeCbm()); + assertEquals(new java.math.BigDecimal("0.0000"), pallets.get(2).getActualVolumeCbm()); + } + + /** 按三托单 SKU 样本生成完整确认请求,其他只读票据采用原事实。 */ + private Confirm request(Snapshot snapshot,int first,int second,int third) { + Map counts=Map.of(10L,first,11L,second,12L,third); + return new Confirm("100",snapshot.snapshotHash(),"现场核实数量", + snapshot.pallets().stream().map(row->new Count(row.palletId(),counts.getOrDefault(row.palletId(), row.countedPackageCount()))).toList(), + snapshot.receipts().stream().map(row->new Quantity(row.customerId(),row.planNo(),row.lineId(),row.palletId(),row.skuCode(),counts.getOrDefault(row.palletId(), row.receivedPackageCount()))).toList()); + } + + /** 准备同柜已结束的另一客户票据,其库存来源不可被本次普通实收改写。 */ + private void addCompletedPlan(boolean dropship) { + InboundPlan other = new InboundPlan(); other.setId(60L); other.setPlanNo("IP-2"); other.setCustomerId("200"); + other.setSourceOrderId(69L); other.setContainerNo("BOX-1"); other.setOverseasWarehouseId(99L); + other.setStatus("COMPLETED"); other.setVersion(2); + LastlegOrder order = new LastlegOrder(); order.setId(69L); order.setCustomerId("200"); + order.setOrderType(dropship ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"); + order.setDestinationType(dropship ? "FBA" : "OVERSEAS"); + InboundPdaTask completed = new InboundPdaTask(); completed.setId(62L); completed.setPlanId(60L); + completed.setCustomerId("200"); completed.setStatus("COMPLETED"); completed.setCompletedAt(receivedAt); + InboundPlanLine otherLine = new InboundPlanLine(); otherLine.setId(63L); otherLine.setPlanId(60L); + otherLine.setOrderId(69L); otherLine.setRequiredPackageCount(8); otherLine.setDestinationType(order.getDestinationType()); + otherLine.setDestinationWarehouseId("DEST-2"); otherLine.setFbaWarehouseCode(dropship ? "ACY2" : null); + InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(64L); pallet.setPlanId(60L); + pallet.setCustomerId("200"); pallet.setPalletNo("OTHER-PALLET"); pallet.setActualPackageCount(8); + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(65L); + allocation.setPlanId(60L); allocation.setLineId(63L); allocation.setPalletId(64L); + allocation.setCountedPackageCount(8); allocation.setPickedPackageCount(8); + InboundStockReceipt stock = new InboundStockReceipt(); stock.setAllocationId(65L); stock.setLineId(63L); + stock.setSkuCode(dropship ? "MIXED" : "SKU-2"); stock.setReceivedPackages(8L); + when(reconciliation.plans(eq("100"), eq("IP-1"), anyBoolean())).thenReturn(List.of(plan, other)); + when(repository.order(69L)).thenReturn(order); + when(repository.task("200", 60L)).thenReturn(completed); + when(repository.lockTask("200", 60L)).thenReturn(completed); + when(repository.lines(60L)).thenReturn(List.of(otherLine)); + when(repository.pallets(60L)).thenReturn(List.of(pallet)); + when(repository.allocations(60L)).thenReturn(List.of(allocation)); + when(repository.lockAllocation(60L, 65L)).thenReturn(allocation); + when(repository.lockPallet(60L, 64L)).thenReturn(pallet); + when(repository.frozenSkus("200", 60L, 63L)).thenReturn(List.of(new FrozenSku("SKU-2", 8))); + when(reconciliation.stockReceipts("200", 62L)).thenReturn(List.of(stock)); + } + /** 构造已分配到货件的 SKU 数量。 */ + private ReceivingSkuReceipt sku(long palletId,String skuCode,int packages) { + ReceivingSkuReceipt row=new ReceivingSkuReceipt(); row.setLineId(3L); row.setPalletId(palletId); + row.setSkuCode(skuCode); row.setReceivedPackages(packages); return row; + } + /** 构造独立于申报内分配的完整现场清点。 */ + private ReceivingFbaPalletCount physical(String pallet,int packages) { + ReceivingFbaPalletCount row=new ReceivingFbaPalletCount(); row.setId(1000L+pallet.hashCode()); row.setSessionId(50L); + row.setGroupKey("FBA:BOX-1:ACY2"); row.setPalletCode(pallet); row.setActualPackages(packages); + row.setCreatedAt(receivedAt); row.setUpdatedAt(receivedAt); return row; + } +} 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 2a783e272..14fba490c 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 @@ -23,6 +23,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +import com.baiyue.lastleg.storagerent.service.StorageRentService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -61,18 +62,24 @@ class InboundStockReceiptServiceTest { @Mock WarehouseResourceGateway warehouseGateway; @Mock DropshipOrderService dropshipOrders; @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock StorageRentService storageRent; private InboundStockReceiptService service; @BeforeEach /** 组装库存、资源与逐箱事实的隔离依赖。 */ void setUp() { service = new InboundStockReceiptService(); - org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); + ReflectionTestUtils.setField(service, "storageRent", storageRent); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "receipts", receipts); ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts); ReflectionTestUtils.setField(service, "workflowRepository", workflowRepository); + var reconciliationRepository = new com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository(); + ReflectionTestUtils.setField(reconciliationRepository, "orders", orders); + ReflectionTestUtils.setField(reconciliationRepository, "stockReceipts", receipts); + ReflectionTestUtils.setField(reconciliationRepository, "skus", receivingSkuReceipts); + ReflectionTestUtils.setField(service, "reconciliationRepository", reconciliationRepository); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> { @@ -82,7 +89,6 @@ class InboundStockReceiptServiceTest { }); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); - when(receipts.listTask("100", 2L)).thenReturn(List.of()); when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of()); } @@ -476,6 +482,195 @@ class InboundStockReceiptServiceTest { verify(inventory, never()).stockIn(any(), any(), any()); } + /** 明确核对的超收按5加20分别入库,保留21箱申报和原现场计租时间。 */ + @Test void reconciledOverReceiptStocksAllConfirmedPallets() { + Fixture f = fixture(21, 21, 5, 210); + f.allocation.setCountedPackageCount(5); + var secondAllocation = allocation(32L, 12L, 22L, 20); + secondAllocation.setCountedPackageCount(20); + var secondPallet = pallet(22L, 2002L); + LocalDateTime receivedAt = LocalDateTime.of(2026, 11, 6, 12, 0); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of( + skuReceipt(12, 21, "SKU-1", 5), skuReceipt(12, 22, "SKU-1", 20))); + when(inventory.stockIn(eq("100"), eq("operator"), any())) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false)); + + service.stockInReconciled("100", f.plan, f.task, List.of(f.line), + List.of(f.allocation, secondAllocation), List.of(f.pallet, secondPallet), receivedAt); + + var commands = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), commands.capture()); + assertEquals(List.of(5L, 20L), commands.getAllValues().stream().map(InventoryCommandRequests.StockIn::packages).toList()); + var saved = ArgumentCaptor.forClass(InboundStockReceipt.class); + verify(receipts, times(2)).insert(saved.capture()); + assertEquals(List.of(receivedAt, receivedAt), saved.getAllValues().stream().map(InboundStockReceipt::getReceivedAt).toList()); + verify(storageRent, times(2)).registerInbound(any(InboundStockReceipt.class), eq(f.order)); + assertEquals(21, f.line.getRequiredPackageCount()); + assertEquals(21, f.detail.getPackageCount()); + assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus()); + verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any()); + } + + /** 零实收可以完成异常核对,不创建库存或计租凭证。 */ + @Test void reconciledZeroReceiptCompletesAsPartialWithoutStock() { + Fixture f = fixture(21, 21, 0, 210); + f.allocation.setCountedPackageCount(0); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + + service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), + List.of(f.pallet), LocalDateTime.now()); + + verify(inventory, never()).stockIn(any(), any(), any()); + verify(receipts, never()).insert(any(InboundStockReceipt.class)); + verify(storageRent, never()).registerInbound(any(), any()); + assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus()); + assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus()); + } + + /** 一个 SKU 超收不能掩盖另一个 SKU 短少,即使全票总数多于申报。 */ + @Test void reconciledKnownSkuOverReceiptStillPreservesOtherSkuShortage() { + Fixture f = fixture(21, 21, 25, 210); + f.allocation.setCountedPackageCount(25); + 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", 16), skuReceipt(12, 21, "SKU-2", 9))); + when(inventory.stockIn(eq("100"), eq("operator"), any())) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false)); + + service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), + List.of(f.pallet), LocalDateTime.now()); + + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any()); + assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus()); + } + + /** 多票分别判断短少,不能把另一票零实收的状态套给已核平的超收订单。 */ + @Test void reconciledReceiptCompletesEachOrderFromItsOwnSkuFacts() { + Fixture f = fixture(21, 21, 25, 210); + f.allocation.setCountedPackageCount(25); + var emptyLine = new InboundPlanLine(); + emptyLine.setId(13L); emptyLine.setOrderId(102L); emptyLine.setRequiredPackageCount(10); + var emptyOrder = new LastlegOrder(); + emptyOrder.setId(102L); emptyOrder.setCustomerId("100"); emptyOrder.setCurrentStatus("INBOUND_PLANNED"); + var emptyAllocation = allocation(32L, 13L, 22L, 0); + emptyAllocation.setCountedPackageCount(0); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order, emptyOrder)); + when(orders.lockById("100", 102L)).thenReturn(emptyOrder); + when(orderDetails.selectList(any())).thenReturn(List.of(f.detail), List.of(detail(202L, "SKU-2", 10, 100))); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25))); + when(inventory.stockIn(eq("100"), eq("operator"), any())) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false)); + + service.stockInReconciled("100", f.plan, f.task, List.of(f.line, emptyLine), + List.of(f.allocation, emptyAllocation), List.of(f.pallet, pallet(22L, 2002L)), LocalDateTime.now()); + + assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus()); + assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", emptyOrder.getSubOrderStatus()); + verify(inventory).stockIn(eq("100"), eq("operator"), any()); + } + + /** 缺少明确清点事实不能按零实收入库。 */ + @Test void reconciledReceiptRejectsUnknownZeroCount() { + Fixture f = fixture(21, 21, 0, 210); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + + assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now())); + + verify(inventory, never()).stockIn(any(), any(), any()); + } + + /** 异常确认入口不能绕过一件代发必须整托收货的冻结来源。 */ + @Test void reconciledReceiptRejectsDropshipOrder() { + Fixture f = fixture(21, 21, 0, 210); + f.allocation.setCountedPackageCount(0); + f.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + + assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now())); + + verify(inventory, never()).stockIn(any(), any(), any()); + } + + /** 未明确逐 SKU 实收时不按原申报顺序自动分摊超收。 */ + @Test void reconciledReceiptRequiresExactSkuFacts() { + Fixture f = fixture(21, 21, 25, 210); + f.allocation.setCountedPackageCount(25); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + + assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now())); + + verify(inventory, never()).stockIn(any(), any(), any()); + } + + /** 异常核对仍不能引入冻结申报以外的 SKU。 */ + @Test void reconciledReceiptRejectsUnknownSku() { + Fixture f = fixture(21, 21, 25, 210); + f.allocation.setCountedPackageCount(25); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "UNKNOWN", 25))); + + assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now())); + + verify(inventory, never()).stockIn(any(), any(), any()); + } + + /** 全票总数相等也须拒绝逐托实收与 SKU 实收错配。 */ + @Test void reconciledReceiptRejectsMismatchedPalletQuantities() { + Fixture f = fixture(21, 21, 5, 210); + f.allocation.setCountedPackageCount(5); + var secondAllocation = allocation(32L, 12L, 22L, 20); + secondAllocation.setCountedPackageCount(20); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of( + skuReceipt(12, 21, "SKU-1", 6), skuReceipt(12, 22, "SKU-1", 19))); + + assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation, secondAllocation), List.of(f.pallet, pallet(22L, 2002L)), LocalDateTime.now())); + + verify(inventory, never()).stockIn(any(), any(), any()); + } + + /** 同一份已入账实收可重放,不重复生成库存和计租。 */ + @Test void reconciledReceiptReplaysOnlyMatchingStockReceipts() { + Fixture f = fixture(21, 21, 25, 210); + f.allocation.setCountedPackageCount(25); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25))); + var existing = new InboundStockReceipt(); + existing.setAllocationId(31L); existing.setLineId(12L); existing.setSkuCode("SKU-1"); existing.setReceivedPackages(25L); + when(receipts.listTask("100", 2L)).thenReturn(List.of(existing)); + + service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()); + + verify(inventory, never()).stockIn(any(), any(), any()); + verify(receipts, never()).insert(any(InboundStockReceipt.class)); + verify(storageRent, never()).registerInbound(any(), any()); + assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus()); + } + + /** 已入账凭证数量变化必须拒绝,不能沿用仅比较 allocation ID 的普通重试分支。 */ + @Test void reconciledReceiptRejectsChangedStockReceiptQuantity() { + Fixture f = fixture(21, 21, 25, 210); + f.allocation.setCountedPackageCount(25); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25))); + var existing = new InboundStockReceipt(); + existing.setAllocationId(31L); existing.setLineId(12L); existing.setSkuCode("SKU-1"); existing.setReceivedPackages(21L); + when(receipts.listTask("100", 2L)).thenReturn(List.of(existing)); + + assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now())); + + 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");