From 5c54785d7717f0c15a2de5ec8e92427ac3f21165 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:28:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/海外区域统一增量-MySQL8.sql | 24 ++- .../ReceivingContainerController.java | 8 + .../pda/mapper/ReceivingSkuReceiptMapper.java | 6 + .../service/InboundStockReceiptService.java | 54 ++++++ .../service/ReceivingContainerService.java | 174 ++++++++++++++++-- .../src/main/resources/db/lastleg.sql | 16 +- 6 files changed, 266 insertions(+), 16 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 3c8be6dd6..3c07f7500 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -390,13 +390,35 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', - photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'), + 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''', + 'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status')); +PREPARE phase4_receiving_sign_photos_stmt FROM @ddl; EXECUTE phase4_receiving_sign_photos_stmt; DEALLOCATE PREPARE phase4_receiving_sign_photos_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='photo_file_ids'), + 'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1')); +PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt; + +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index 47ba2aae9..fa2816d4f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -86,6 +86,14 @@ public class ReceivingContainerController { return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request)); } + @PostMapping("/current/skus/resolve") + public ApiResponse resolveSku( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { + Identity identity = identity(user); + return ApiResponse.success(service.resolveSku(identity.customerId(), identity.operatorKey(), request)); + } + @PostMapping("/current/pallets/resolve") public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java index 6e444f8e1..fa28ae74e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java @@ -16,6 +16,12 @@ public interface ReceivingSkuReceiptMapper extends BaseMapper listLineSku(@Param("customerId") String customerId, + @Param("lineId") Long lineId, + @Param("skuCode") String skuCode); + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + "AND plan_id=#{planId} ORDER BY line_id,pallet_id,sku_code,id") List listPlan(@Param("customerId") String customerId, 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 5bb25ffc9..f236833d4 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 @@ -9,7 +9,9 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; @@ -38,6 +40,7 @@ public class InboundStockReceiptService { @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderDetailMapper orderDetails; @Resource private InboundStockReceiptMapper receipts; + @Resource private ReceivingSkuReceiptMapper receivingSkuReceipts; @Resource private InventoryCommandService inventory; @Resource private DropshipOrderService dropshipOrders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; @@ -56,6 +59,9 @@ public class InboundStockReceiptService { .filter(allocation -> value(allocation.getPickedPackageCount()) > 0) .sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)) .collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId)); + Map> skuReceiptsByLine = receivingSkuReceipts + .listPlan(customerId, plan.getId()).stream() + .collect(Collectors.groupingBy(ReceivingSkuReceipt::getLineId)); Set expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream) .map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet()); @@ -82,6 +88,12 @@ public class InboundStockReceiptService { if (pickedPackages != value(line.getRequiredPackageCount())) { throw conflict("PDA picked packages must equal the inbound order package total"); } + List exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of()); + if (!exactSkuReceipts.isEmpty()) { + created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations, + palletById, order, exactSkuReceipts, pickedPackages, receivedAt)); + continue; + } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) ? List.of() : skuRemainders(order.getId(), pickedPackages); for (InboundPlanPalletAllocation allocation : lineAllocations) { @@ -104,6 +116,48 @@ public class InboundStockReceiptService { completeOrders(customerId, plan, orderById, receivedAt); } + private List persistExactSkuReceipts(String customerId, InboundPlan plan, + InboundPdaTask task, InboundPlanLine line, List lineAllocations, + Map palletById, LastlegOrder order, + List exactReceipts, long pickedPackages, LocalDateTime receivedAt) { + long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); + if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total"); + + Map expectedBySku = skuRemainders(order.getId(), pickedPackages).stream() + .collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum, + java.util.LinkedHashMap::new)); + Map actualBySku = exactReceipts.stream().collect(Collectors.groupingBy( + value -> value.getSkuCode().toUpperCase(java.util.Locale.ROOT), java.util.LinkedHashMap::new, + Collectors.summingLong(value -> value(value.getReceivedPackages())))); + Map normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap( + value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum, + java.util.LinkedHashMap::new)); + if (!actualBySku.equals(normalizedExpected)) { + throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots"); + } + + Map allocationByPallet = lineAllocations.stream() + .collect(Collectors.toMap(InboundPlanPalletAllocation::getPalletId, value -> value)); + Map> receiptsByPallet = exactReceipts.stream() + .collect(Collectors.groupingBy(ReceivingSkuReceipt::getPalletId)); + List created = new java.util.ArrayList<>(); + for (Map.Entry> entry : receiptsByPallet.entrySet()) { + InboundPlanPalletAllocation allocation = allocationByPallet.get(entry.getKey()); + if (allocation == null) throw conflict("FBA SKU receipt pallet has no matching allocation"); + long palletPackages = entry.getValue().stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); + if (palletPackages != value(allocation.getPickedPackageCount())) { + throw conflict("FBA SKU receipt pallet total must match the allocation total"); + } + InboundPlanPallet pallet = requirePallet(palletById, allocation); + for (ReceivingSkuReceipt exact : entry.getValue()) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + new SkuSnapshot(exact.getSkuCode(), exact.getProductName(), null, + value(exact.getReceivedPackages())), receivedAt)); + } + } + return created; + } + private void completeOrders(String customerId, InboundPlan plan, Map orderById, LocalDateTime completedAt) { for (LastlegOrder snapshot : orderById.values()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index c7cdf8f7a..124ddec0d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; @@ -18,10 +19,13 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -47,6 +51,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; @Service public class ReceivingContainerService { @@ -56,8 +62,10 @@ public class ReceivingContainerService { @Resource private InboundPlanPalletAllocationMapper allocations; @Resource private InboundPlanCartonMapper cartons; @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; @Resource private ReceivingContainerSessionMapper sessions; @Resource private ReceivingContainerOperationMapper operations; + @Resource private ReceivingSkuReceiptMapper skuReceipts; @Resource private WarehouseResourceGateway warehouse; @Resource private UnloadPhotoFileGateway unloadPhotos; @Resource private InboundPdaWorkflowService workflow; @@ -85,7 +93,8 @@ public class ReceivingContainerService { Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", - entry.getValue().size(), totals.inboundCount(), totals.total(), totals.received(), active); + entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(), + totals.total(), totals.received(), active); }).toList(); } @@ -144,14 +153,14 @@ public class ReceivingContainerService { if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); - List photos = request.photoFileIds().stream().distinct().sorted().toList(); - if (photos.size() != request.photoFileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); - photos.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); + List photos = validateSignPhotos(request); + List photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList(); + photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); LocalDateTime now = LocalDateTime.now(); - session.setPhotoFileIds(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); + session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { - workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photos, now); + workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now); } ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo())); save(session, "SIGN", request.idempotencyKey(), request, result); return result; @@ -181,6 +190,19 @@ public class ReceivingContainerService { inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } + public ReceivingContainerResponses.SkuResolved resolveSku(String customerId, String operatorKey, + ReceivingContainerRequests.SkuResolve request) { + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + List matches = refs(customerId, session.getContainerNo()).stream() + .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) + .map(ref -> skuRef(ref, request.skuCode())) + .filter(Objects::nonNull) + .toList(); + if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container"); + if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container"); + return skuResponse(matches.get(0)); + } + public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey, ReceivingContainerRequests.PalletResolve request) { ReceivingContainerSession session = requireSigned(customerId, operatorKey); @@ -201,14 +223,17 @@ public class ReceivingContainerService { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } + SkuRef sku = requireSku(ref, request.skuCode()); + validateSkuPending(customerId, sku, request.packageCount()); + workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); validatePallet(ref, resource); InboundPlanPallet pallet = choosePallet(ref, resource); - workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); try { int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, request.packageCount(), operatorKey, operatorName); + recordSkuReceipt(session, sku, pallet, accepted); markReceiving(session); ReceivingContainerResponses.Inbound inbound = inbound(ref); ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", @@ -267,6 +292,7 @@ public class ReceivingContainerService { List containerPlans = requirePlans(customerId, session.getContainerNo()); Totals totals = totals(containerPlans); if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); + validateFbaSkuBalance(customerId, session.getContainerNo()); workflow.completeContainer(customerId, containerPlans); LocalDateTime now = LocalDateTime.now(); session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); @@ -383,7 +409,7 @@ public class ReceivingContainerService { int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(), - value(session.getVersion()), photoIds(session), session.getPreparedAt(), session.getSignedAt(), + value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(), session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); } @@ -403,10 +429,125 @@ public class ReceivingContainerService { private Totals totals(List containerPlans) { List values = refs(containerPlans.get(0).getCustomerId(), containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); - return new Totals(values.size(), values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), + List destinationCodes = values.stream().map(this::destinationCode) + .filter(Objects::nonNull).distinct().sorted().toList(); + return new Totals(values.size(), destinationCodes, + values.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(), + values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum()); } + private String destinationCode(ReceivingContainerResponses.Inbound inbound) { + return trim("FBA".equals(inbound.destinationType()) ? inbound.fbaWarehouseCode() + : inbound.destinationWarehouseCode()); + } + + private List validateSignPhotos(ReceivingContainerRequests.Sign request) { + if (request.photos() == null || request.photos().size() != 3) { + throw conflict("Exactly three typed unload photos are required"); + } + if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null"); + Set roles = request.photos().stream() + .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); + if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); + Set fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId) + .boxed().collect(Collectors.toSet()); + if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) { + throw conflict("Unload photo file IDs must be positive and unique"); + } + return request.photos().stream() + .sorted(Comparator.comparing(value -> value.role().ordinal())) + .map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList(); + } + + private SkuRef requireSku(InboundRef ref, String skuCode) { + SkuRef result = skuRef(ref, skuCode); + if (result == null) throw notFound("SKU is not part of the selected inbound code"); + return result; + } + + private SkuRef skuRef(InboundRef ref, String skuCode) { + String requested = trim(skuCode); + if (requested == null) return null; + List details = orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId()) + .eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId)); + List matched = details.stream() + .filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList(); + int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum(); + if (expected <= 0) return null; + String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).findFirst().orElse(requested); + String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn())) + .filter(Objects::nonNull).findFirst().orElse(null); + return new SkuRef(ref, canonicalSku, productName, expected); + } + + private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) { + ReceivingContainerResponses.Inbound inbound = inbound(sku.ref()); + int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode()); + return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(), + inbound.destinationType(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), + inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), + sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received)); + } + + private void validateSkuPending(String customerId, SkuRef sku, int packageCount) { + int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode()); + if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) { + throw conflict("Received package count exceeds the SKU pending total"); + } + } + + private int receivedSkuPackages(String customerId, Long lineId, String skuCode) { + return skuReceipts.listLineSku(customerId, lineId, skuCode).stream() + .mapToInt(value -> value(value.getReceivedPackages())).sum(); + } + + private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) { + List existing = skuReceipts.lockLineSku(session.getCustomerId(), + sku.ref().line().getId(), sku.skuCode()); + ReceivingSkuReceipt receipt = existing.stream() + .filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null); + LocalDateTime now = LocalDateTime.now(); + if (receipt == null) { + receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId()); + receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId()); + receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); + receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); + receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); + receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages); + receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0); + skuReceipts.insert(receipt); + } else { + receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages); + receipt.setUpdatedAt(now); + if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently"); + } + } + + private void validateFbaSkuBalance(String customerId, String containerNo) { + for (InboundRef ref : refs(customerId, containerNo)) { + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; + List details = orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId())); + Set skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode) + .map(ReceivingContainerService::trim).filter(Objects::nonNull) + .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + int expected = 0; + int received = 0; + for (String skuCode : skuCodes) { + SkuRef sku = requireSku(ref, skuCode); + int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode()); + if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container"); + expected += sku.expectedPackages(); received += skuReceived; + } + if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) { + throw conflict("FBA SKU package snapshots must exactly match the inbound code total"); + } + } + } + private String destinationType(InboundPlanLine line, LastlegOrder order) { String value = line.getDestinationType(); if (value == null || value.isBlank()) value = order.getDestinationType(); @@ -453,10 +594,11 @@ public class ReceivingContainerService { value.setCreatedAt(LocalDateTime.now()); operations.insert(value); } - private List photoIds(ReceivingContainerSession session) { - if (session.getPhotoFileIds() == null || session.getPhotoFileIds().isBlank()) return List.of(); - try { return objectMapper.readValue(session.getPhotoFileIds(), new TypeReference>() { }); } - catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read receiving photo IDs", exception); } + private List signPhotos(ReceivingContainerSession session) { + if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of(); + try { return objectMapper.readValue(session.getSignPhotos(), + new TypeReference>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read signed receiving photos", exception); } } private String hash(Object value) { @@ -484,10 +626,14 @@ public class ReceivingContainerService { return value; } private static int value(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } - private record Totals(int inboundCount, int total, int received) { } + private record SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { } + private record Totals(int inboundCount, List destinationCodes, int plannedPalletCount, + int total, int received) { } private record PrepareCommand(String containerNo, String idempotencyKey) { } } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index c02cca9d0..430e98bf9 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -682,7 +682,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', - photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', @@ -690,6 +690,20 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',