From 8700fc34238bc33339351c73c15f3af8de8ce517 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 16:06:54 +0800 Subject: [PATCH] feat(lastleg): redispatch returned cargo and reconcile source signatures --- deploy/sql/README.md | 5 +- deploy/sql/海外区域统一增量-MySQL8.sql | 37 ++- lastleg-service/AGENTS.md | 2 +- .../mapper/DeliverySourceEvidenceMapper.java | 59 +++++ .../model/SourceDeliveryCompletionPolicy.java | 134 ++++++++++ .../DeliveryCompletedOutboxService.java | 35 ++- .../service/DeliveryDocumentService.java | 79 +++--- .../SourceDeliveryCompletionQuery.java | 84 +++++++ .../controller/SmartDispatchController.java | 6 +- .../dispatch/dto/DispatchRequests.java | 22 +- .../lastleg/dispatch/entity/DispatchItem.java | 1 + .../dispatch/entity/DispatchPalletLock.java | 1 + .../mapper/ReturnDispatchCandidateMapper.java | 44 ++++ .../model/DispatchPalletCandidate.java | 117 ++++++--- .../pda/entity/DispatchPdaPickOrder.java | 1 + .../service/DispatchPdaWorkflowService.java | 4 +- .../DispatchPickOrderAttributionService.java | 45 +++- .../dispatch/service/DispatchService.java | 63 +++-- .../service/InventoryCommandService.java | 22 ++ .../inventory/entity/ReturnStockReceipt.java | 27 +++ .../mapper/ReturnStockReceiptMapper.java | 34 +++ .../lastleg/mapper/LastlegOrderMapper.java | 12 +- .../src/main/resources/db/lastleg.sql | 35 ++- .../openapi/overseas-business-v1.yaml | 13 +- .../DeliverySourceEvidenceMapperTest.java | 60 +++++ ...iverySourcePublicationPersistenceTest.java | 228 ++++++++++++++++++ ...urceDeliveryCompletionConcurrencyTest.java | 82 +++++++ .../SourceDeliveryCompletionPolicyTest.java | 91 +++++++ .../SourceDeliveryCompletionQueryTest.java | 98 ++++++++ .../DeliveryCompletedOutboxServiceTest.java | 18 +- .../service/DeliveryDocumentServiceTest.java | 24 +- .../SmartDispatchControllerTest.java | 2 +- ...spatchPickOrderAttributionServiceTest.java | 48 ++++ ...urnedDispatchAllowancePersistenceTest.java | 67 +++++ .../dispatch/service/DispatchServiceTest.java | 54 +++++ ...eturnDispatchCandidatePersistenceTest.java | 55 +++++ .../command/InventoryCommandServiceTest.java | 11 + ...ventoryHoldConcurrencyPersistenceTest.java | 7 +- .../initialization-baseline-contract.test.mjs | 17 +- 39 files changed, 1605 insertions(+), 139 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b17f7ea9a..b5ddec95d 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -7,7 +7,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 152 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 153 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -16,6 +16,7 @@ - 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。 - 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。 - 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。 +- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;拣货`receipt_id`与`return_receipt_id`恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -28,7 +29,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/152`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/153`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 02edb3802..459cc0374 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -376,6 +376,8 @@ CREATE TABLE ll_dispatch_item ( source_business_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型', source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), KEY idx_ll_dispatch_item_order (customer_id,order_id), @@ -433,6 +435,8 @@ CREATE TABLE ll_dispatch_pallet_lock ( released_at datetime(3) DEFAULT 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 '更新时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), @@ -470,15 +474,44 @@ CREATE TABLE ll_dispatch_pda_pick ( KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA拣货记录'; +CREATE TABLE ll_return_stock_receipt ( + id BIGINT NOT NULL COMMENT '退回入仓实收凭据ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据归属', + receipt_no VARCHAR(64) NOT NULL COMMENT '退回实收业务编号', + inventory_ledger_id BIGINT NOT NULL COMMENT '唯一实际退回入账流水', + exception_order_id BIGINT NOT NULL COMMENT '退回异常订单', + order_id BIGINT NOT NULL COMMENT '原尾程订单', + inbound_order_id BIGINT NOT NULL COMMENT '原入仓单', + source_outbound_no VARCHAR(64) NOT NULL COMMENT '原发运出库业务编号', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + source_inventory_position_id BIGINT NOT NULL COMMENT '原拣货库存位置', + inventory_balance_id BIGINT NOT NULL COMMENT '实际退回库存余额', + inventory_position_id BIGINT NOT NULL COMMENT '实际退回库存位置', + received_packages BIGINT NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + received_at DATETIME(3) NOT NULL COMMENT '实际退回入账时间', + created_at DATETIME(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_stock_receipt_no(customer_id,receipt_no), + UNIQUE KEY uk_return_stock_receipt_ledger(customer_id,inventory_ledger_id), + KEY idx_return_stock_receipt_order(customer_id,order_id), + KEY idx_return_stock_receipt_position(customer_id,inventory_balance_id,inventory_position_id), + CONSTRAINT chk_return_stock_receipt_packages CHECK(received_packages>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; + CREATE TABLE ll_dispatch_pda_pick_order ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', pick_id bigint NOT NULL COMMENT 'PDA拣货记录ID', - receipt_id bigint NOT NULL COMMENT '入仓实收记录ID', + receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id bigint NOT NULL COMMENT '订单ID', packages bigint NOT NULL COMMENT '本次操作箱数', product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)), + + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) @@ -3392,4 +3425,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 152 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 153 AS initialized_tables; diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index d2f0489c0..3ce3b32a3 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -106,7 +106,7 @@ | 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 | | 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | | 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | -| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、按托盘/入仓单拆分的仓位、流水、命令幂等及实收快照;同一托盘可有多条入仓单仓位,汇总数量必须与各明细核平 | +| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 | | 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | | 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java new file mode 100644 index 000000000..d83da8796 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** Use SourceDeliveryCompletionQuery in an explicit READ_COMMITTED write transaction. + * lockSource is a current locking read; history queries deliberately do not lock delivery rows. + * No pagination or current ll_order.delivery_order_no association is used. + */ +@Mapper +public interface DeliverySourceEvidenceMapper { + @Select("SELECT source_inbound_id, id AS order_id, package_count AS packages FROM ll_order " + + "WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND source_inbound_id=#{sourceInboundId}") + Source source(@Param("customerId") String customerId, @Param("sourceInboundId") String sourceInboundId); + + @Select("SELECT source_inbound_id,id AS order_id,package_count AS packages FROM ll_order " + + "WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND id=#{orderId} FOR UPDATE") + Source lockSource(@Param("customerId") String customerId, @Param("orderId") long orderId); + + @Select("SELECT DISTINCT t.dispatch_order_id FROM ll_dispatch_pda_pick_order a " + + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + + "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "WHERE a.customer_id=#{customerId} AND a.order_id=#{orderId} ORDER BY t.dispatch_order_id") + List dispatchIds(@Param("customerId") String customerId, @Param("orderId") long orderId); + + @Select("SELECT d.id AS delivery_id,d.dispatch_order_id AS dispatch_id,d.delivery_group_key AS group_key," + + "d.package_count AS packages,d.signed_packages,d.signed_at,ds.depart_at AS departed_at FROM ll_delivery_order d " + + "LEFT JOIN ll_dispatch_order ds ON ds.customer_id=d.customer_id AND ds.id=d.dispatch_order_id " + + "WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY d.id") + List deliveries(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); + + @Select("SELECT a.id AS attribution_id,t.dispatch_order_id AS dispatch_id," + + "CASE WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL " + + "THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) " + + "WHEN COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')) IS NOT NULL " + + "THEN CONCAT('CODE:',UPPER(COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')))) " + + "WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) ELSE NULL END AS group_key," + + "a.order_id,a.packages,a.receipt_id,a.return_receipt_id,r.inventory_ledger_id AS return_ledger_id,r.received_packages AS return_packages FROM ll_dispatch_pda_pick_order a " + + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + + "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "LEFT JOIN ll_dispatch_item di ON di.customer_id=p.customer_id AND di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id " + + "LEFT JOIN ll_return_stock_receipt r ON r.customer_id=a.customer_id AND r.id=a.return_receipt_id AND r.order_id=a.order_id " + + "AND EXISTS (SELECT 1 FROM ll_inventory_ledger rl JOIN ll_exception_order re ON re.customer_id=rl.customer_id " + + "AND re.id=r.exception_order_id AND re.exception_no=rl.business_order_no AND re.last_mile_order_id=a.order_id " + + "WHERE rl.customer_id=r.customer_id AND rl.id=r.inventory_ledger_id AND rl.business_type='RETURN_STOCK_IN' " + + "AND rl.outbound_order_no=r.source_outbound_no AND rl.delta_packages=r.received_packages " + + "AND rl.source_event_id=CONCAT('RETURN_SOURCE:',r.source_outbound_pallet_item_id,':',r.source_inventory_position_id)) " + + "WHERE a.customer_id=#{customerId} AND t.dispatch_order_id=#{dispatchId} ORDER BY a.id") + List picks(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); + + @Select("SELECT l.id AS ledger_id,d.id AS delivery_id,e.last_mile_order_id AS order_id," + + "l.delta_packages AS packages,l.occurred_at AS received_at FROM ll_delivery_order d " + + "JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " + + "JOIN ll_inventory_ledger l ON l.customer_id=e.customer_id AND l.business_order_no=e.exception_no " + + "AND l.outbound_order_no=d.outbound_no AND l.business_type='RETURN_STOCK_IN' AND l.delta_packages>0 " + + "WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY l.id") + List returns(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java new file mode 100644 index 000000000..fa5255785 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java @@ -0,0 +1,134 @@ +package com.baiyue.lastleg.delivery.model; + +import java.time.LocalDateTime; +import java.util.*; + +/** Read-only proof of whole-source signature. Inputs must cover ALL history for the requested source. */ +public final class SourceDeliveryCompletionPolicy { + private SourceDeliveryCompletionPolicy() { } + public record Source(String sourceInboundId, long orderId, long packages) { } + public record Delivery(long deliveryId, long dispatchId, String groupKey, long packages, + Long signedPackages, LocalDateTime signedAt, LocalDateTime departedAt) { } + public record Pick(long attributionId, long dispatchId, String groupKey, long orderId, long packages, + Long receiptId, Long returnReceiptId, Long returnLedgerId, Long returnPackages) { } + public record Returned(long ledgerId, long deliveryId, long orderId, long packages, LocalDateTime receivedAt) { } + public record Result(String sourceInboundId, long originalPackages, long provenSignedPackages, + boolean fullySigned, List unresolvedReasons) { } + + public static Result evaluate(Source source, List deliveries, List picks, + List returns, boolean completeHistory) { + Objects.requireNonNull(source); + var reasons = new LinkedHashSet(); + if (!completeHistory) reasons.add("HISTORY_NOT_COMPLETE"); + if (source.sourceInboundId() == null || source.sourceInboundId().isBlank() || source.packages() <= 0) + reasons.add("SOURCE_QUANTITY_MISSING"); + // Only destinations containing this source can affect its proof. Keep co-shipped sources + // within those destinations to reconcile the exact delivery signature total. + var relevant = deliveries.stream().filter(d -> picks.stream().anyMatch(p -> p.orderId() == source.orderId() + && p.dispatchId() == d.dispatchId() && Objects.equals(p.groupKey(),d.groupKey()))).toList(); + var relevantIds = new HashSet(); + for (var d : relevant) if (!relevantIds.add(d.deliveryId())) reasons.add("DUPLICATE_DELIVERY"); + var attributionIds = new HashSet(); var ledgerIds = new HashSet(); + for (var p : picks) if (relevant.stream().anyMatch(d -> d.dispatchId()==p.dispatchId() && Objects.equals(d.groupKey(),p.groupKey()))) { + if (!attributionIds.add(p.attributionId()) || p.packages() <= 0 || p.groupKey() == null) reasons.add("INVALID_PICK_ATTRIBUTION"); + if ((p.receiptId() == null) == (p.returnReceiptId() == null) + || (p.receiptId()!=null && p.receiptId()<=0)) reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED"); + } + for (var r : returns) if (relevantIds.contains(r.deliveryId()) && (!ledgerIds.add(r.ledgerId()) + || r.packages()<=0 || r.receivedAt()==null)) reasons.add("INVALID_RETURN_LEDGER"); + long signed = 0; + for (var d : relevant) { + var matching = picks.stream().filter(p -> p.dispatchId() == d.dispatchId() && Objects.equals(p.groupKey(), d.groupKey())).toList(); + var quantities = new HashMap(); + for (var p : matching) quantities.merge(p.orderId(), p.packages(), Math::addExact); + if (d.groupKey() == null || d.packages() <= 0 || d.departedAt() == null + || matching.stream().mapToLong(Pick::packages).sum() != d.packages() + || deliveries.stream().filter(other -> other.dispatchId() == d.dispatchId() && Objects.equals(other.groupKey(), d.groupKey())).count() != 1) { + reasons.add("DELIVERY_SOURCE_COVERAGE_UNRESOLVED:" + d.deliveryId()); continue; + } + if (d.signedAt() == null) { + if (d.signedPackages() != null) reasons.add("SIGNATURE_TIME_MISSING:" + d.deliveryId()); + continue; + } + if (d.signedAt().isBefore(d.departedAt()) || d.signedPackages() == null || d.signedPackages() <= 0 || d.signedPackages() > d.packages()) { + reasons.add("SIGNATURE_QUANTITY_UNRESOLVED:" + d.deliveryId()); continue; + } + var before = new HashMap(); boolean valid = true; + for (var r : returns) if (r.deliveryId() == d.deliveryId()) { + if (r.receivedAt() == null || r.receivedAt().isBefore(d.departedAt()) || !quantities.containsKey(r.orderId())) { + valid = false; break; + } + // Millisecond-equal timestamps cannot prove which transaction happened first. + if (r.receivedAt().equals(d.signedAt())) { valid = false; break; } + if (r.receivedAt().isBefore(d.signedAt())) before.merge(r.orderId(), r.packages(), Math::addExact); + } + if (!valid || before.entrySet().stream().anyMatch(e -> e.getValue() > quantities.getOrDefault(e.getKey(), 0L)) + || d.packages() - before.values().stream().mapToLong(Long::longValue).sum() != d.signedPackages()) { + reasons.add("PRE_SIGNATURE_RETURN_UNRESOLVED:" + d.deliveryId()); continue; + } + long fresh = 0, previouslySigned = 0; boolean lineageValid = true; + for (var p : matching) if (p.orderId() == source.orderId()) { + Boolean eligible = freshOrigin(p,d,deliveries,picks,returns,new HashSet<>(),reasons); + if (eligible == null) lineageValid = false; + else if (eligible) fresh = Math.addExact(fresh,p.packages()); + else previouslySigned = Math.addExact(previouslySigned,p.packages()); + } + long returnedBefore = before.getOrDefault(source.orderId(),0L); + if (fresh>0 && previouslySigned>0 && returnedBefore>0) { + reasons.add("MIXED_RESIGNED_RETURN_UNRESOLVED:"+d.deliveryId()); lineageValid=false; + } + if (lineageValid) signed = Math.addExact(signed,fresh==0?0:fresh-returnedBefore); + } + for (var p : picks) if (p.orderId() == source.orderId() && deliveries.stream().noneMatch(d -> + d.dispatchId() == p.dispatchId() && Objects.equals(d.groupKey(), p.groupKey()))) reasons.add("PICK_DELIVERY_MISSING"); + // Repeated post-signature shipments cannot be treated as distinct originally loaded boxes. + if (signed > source.packages()) reasons.add("SIGNED_EXCEEDS_ORIGINAL_SOURCE"); + return new Result(source.sourceInboundId(), source.packages(), signed, + reasons.isEmpty() && signed == source.packages(), List.copyOf(reasons)); + } + /** Receipt identity is exact; package identity inside mixed fresh/already-signed cohorts is not. */ + private static Boolean freshOrigin(Pick pick, Delivery current, List deliveries, List picks, + List returns, Set visiting, Set reasons) { + if (!visiting.add(pick.attributionId())) { reasons.add("RETURN_LINEAGE_CYCLE"); return null; } + try { + if ((pick.receiptId()==null)==(pick.returnReceiptId()==null)) { reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED"); return null; } + if (pick.receiptId()!=null) { + if(pick.receiptId()<=0) { reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED"); return null; } + return true; + } + var receipts=returns.stream().filter(r->Objects.equals(pick.returnLedgerId(),r.ledgerId())).toList(); + if(pick.returnReceiptId()<=0 || receipts.size()!=1 || pick.returnPackages()==null + || receipts.get(0).orderId()!=pick.orderId() || receipts.get(0).packages()!=pick.returnPackages() + || picks.stream().filter(p->Objects.equals(p.returnLedgerId(),pick.returnLedgerId())).mapToLong(Pick::packages).sum()>pick.returnPackages()) { + reasons.add("RETURN_PICK_ORIGIN_UNRESOLVED:"+pick.attributionId()); return null; + } + var receipt=receipts.get(0); + var parents=deliveries.stream().filter(d->d.deliveryId()==receipt.deliveryId()).toList(); + if(parents.size()!=1 || receipt.receivedAt()==null || current.departedAt()==null + || !receipt.receivedAt().isBefore(current.departedAt())) { + reasons.add("RETURN_PICK_CHRONOLOGY_UNRESOLVED:"+pick.attributionId());return null; + } + var parent=parents.get(0); + if(parent.departedAt()==null || receipt.receivedAt().isBefore(parent.departedAt()) + || (parent.signedAt()!=null && receipt.receivedAt().equals(parent.signedAt()))) { + reasons.add("RETURN_PICK_LINEAGE_UNRESOLVED:"+pick.attributionId()); return null; + } + var parentPicks=picks.stream().filter(p->p.dispatchId()==parent.dispatchId() + && Objects.equals(p.groupKey(),parent.groupKey()) && p.orderId()==pick.orderId()).toList(); + if(parentPicks.isEmpty() || receipt.packages()>parentPicks.stream().mapToLong(Pick::packages).sum()) { + reasons.add("RETURN_PICK_ORIGIN_UNRESOLVED:"+pick.attributionId());return null; + } + // After a validated signature every box remaining in that movement is already signed. + if(parent.signedAt()!=null && parent.signedAt().isBefore(receipt.receivedAt())) return false; + Boolean eligibility=null; + for(var ancestor:parentPicks) { + Boolean value=freshOrigin(ancestor,parent,deliveries,picks,returns,visiting,reasons); + if(value==null)return null; + if(eligibility!=null && !eligibility.equals(value)) { reasons.add("RETURN_LINEAGE_MIXED_COHORT");return null; } + eligibility=value; + } + return eligibility; + } finally { visiting.remove(pick.attributionId()); } + } + +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 954dd8d36..10fc42300 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -35,25 +35,14 @@ public class DeliveryCompletedOutboxService { public enum Publication { PUBLISHED, NO_DOMESTIC_SOURCE, UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION, MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION } - public Publication publish(DeliveryOrder delivery, List orders, Instant completedAt) { + public Publication publish(DeliveryOrder delivery, List orders, Map proofs, Instant completedAt) { Objects.requireNonNull(delivery, "delivery"); Objects.requireNonNull(completedAt, "completedAt"); // The domestic consumer marks the whole inbound order DELIVERED (signed), not merely transport closed. // A mixed return/signature must not publish that full-signature fact. Map bySourceInbound = new LinkedHashMap<>(); for (LastlegOrder order : orders == null ? List.of() : orders) { - // Only orders projected from the domestic first-leg domain need an - // acknowledgement event. Customer/local overseas orders can still carry - // a local sourceInboundId, but deliberately have no domestic sourceOrderId. - if (StringUtils.hasText(order.getSourceRegion()) - && !"FIRSTLEG".equals(order.getSourceRegion())) continue; - // Relabel and warehouse-transfer deliveries are created and completed wholly - // in the overseas last-leg domain. They do not have a domestic source order - // to acknowledge, so publishing the cross-region event would make receipt - // completion fail on the missing source-order fields. - if ("RELABEL_APPLICATION".equals(order.getOrderType()) - || "WAREHOUSE_TRANSFER".equals(order.getOrderType())) continue; - if (!StringUtils.hasText(order.getSourceInboundId())) continue; + if (!domestic(order)) continue; requireText(order.getSourceOrderId(), "sourceOrderId"); requireText(order.getInboundCode(), "inboundCode"); requireText(order.getCustomerId(), "customerId"); @@ -67,15 +56,25 @@ public class DeliveryCompletedOutboxService { if (delivery.getPackageCount() == null || delivery.getPackageCount() <= 0 || delivery.getSignedPackages() == null || delivery.getSignedPackages() <= 0 || delivery.getSignedPackages() > delivery.getPackageCount()) return Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION; - if (!Objects.equals(delivery.getSignedPackages(), delivery.getPackageCount())) - return Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION; - for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt); - return Publication.PUBLISHED; + boolean pending = false; + for (LastlegOrder order : bySourceInbound.values()) { + var proof = proofs.get(order.getSourceInboundId()); + if (proof == null || !proof.fullySigned() || !proof.unresolvedReasons().isEmpty() + || !Objects.equals(proof.sourceInboundId(),order.getSourceInboundId()) + || order.getPackageCount() == null || proof.originalPackages() != order.getPackageCount() + || proof.provenSignedPackages() != proof.originalPackages()) { pending = true; continue; } + publishOne(delivery, order, completedAt); + } + return pending ? Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION : Publication.PUBLISHED; + } + static boolean domestic(LastlegOrder order) { + return "FIRSTLEG".equals(order.getSourceRegion()) && StringUtils.hasText(order.getSourceInboundId()) + && !"RELABEL_APPLICATION".equals(order.getOrderType()) && !"WAREHOUSE_TRANSFER".equals(order.getOrderType()); } private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) { long aggregateVersion = Math.max(1L, delivery.getVersion() == null ? 1L : delivery.getVersion()); - String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + delivery.getDeliveryNo() + String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + order.getCustomerId() + ':' + order.getSourceInboundId(); var payload = new OverseasDeliveryCompletedV1(String.valueOf(delivery.getId()), delivery.getDeliveryNo(), String.valueOf(order.getId()), order.getOrderNo(), order.getSourceInboundId(), order.getInboundCode(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 295c44f16..d774daa34 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -35,6 +35,8 @@ public class DeliveryDocumentService { @Resource private LastlegOrderMapper lastlegOrders; @Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox; @Resource private CustomerVisitService customerVisits; + @Resource private SourceDeliveryCompletionQuery sourceCompletion; + @Resource private DeliverySourceEvidenceMapper sourceEvidence; public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } @@ -56,7 +58,7 @@ public class DeliveryDocumentService { } /** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */ - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Detail save(String customerId, String deliveryNo, String operatorId, Save request) { DeliveryOrder order = orders.lockByNo(customerId, deliveryNo); if (order == null) throw missing(); @@ -110,19 +112,8 @@ public class DeliveryDocumentService { } orders.updateById(order); if (autoCompleted) { - List completedOrders = completeRelatedOrders(order, now); - var publication = deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC)); - if (publication == DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION - || publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION) { - DeliveryOperation pending = new DeliveryOperation(); - pending.setCustomerId(customerId); pending.setDeliveryOrderId(order.getId()); pending.setOperatorId(operatorId); - pending.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); - pending.setResult(publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION - ? "签收数量未确认或无效,国内整票签收事件未发布,须核对真实实签量后继续回写" - : "原实发" + order.getPackageCount() + "箱,本次签收" + order.getSignedPackages() - + "箱,其余已退回;国内整票签收事件未发布,须按来源入仓单核对签收与退回处置后继续回写"); - pending.setCreatedAt(now); operations.insert(pending); - } + var completion = completeRelatedOrders(order, operatorId, now); + deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.toInstant(ZoneOffset.UTC)); customerVisits.ensureForCompletedDelivery(order, now); } Detail result = detail(order, false); @@ -166,20 +157,54 @@ public class DeliveryDocumentService { (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); } private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } - private List completeRelatedOrders(DeliveryOrder delivery, LocalDateTime now) { - List completedOrders = lastlegOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()) - .eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo())); - for (LastlegOrder order : completedOrders) { - // Preserve the outbound leg when receipt completion closes delivery. - // Sub-order status is a composite fact; replacing it with DELIVERY alone - // makes a successfully departed outbound appear unfinished or disappear. - order.setSubOrderStatus(delivery.getSignedPackages().equals(delivery.getPackageCount()) - ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); - order.setUpdatedAt(now); - lastlegOrders.updateById(order); + private record Completion(List orders, Map proofs) { } + private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) { + var related = new TreeMap(); + if (delivery.getDispatchOrderId() != null) { + var ids = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() + .filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())) + .map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList(); + if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) { + if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致"); + related.put(tail.getId(), tail); + } } - return completedOrders; + // Non-dispatch local workflows retain their established relation; never use this to prove a domestic source. + for (var tail : lastlegOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()).eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()))) { + if (!DeliveryCompletedOutboxService.domestic(tail)) related.putIfAbsent(tail.getId(),tail); + else if (!related.containsKey(tail.getId())) pendingCompletion(delivery, operatorId, now, + inboundLabel(tail) + ":缺少历史实际拣货来源,未发布整来源签收事件"); + } + var sourceIds = related.values().stream().filter(DeliveryCompletedOutboxService::domestic) + .map(LastlegOrder::getSourceInboundId).distinct().toList(); + var proofs = sourceIds.isEmpty() ? Map.of() + : sourceCompletion.proveAll(delivery.getCustomerId(),sourceIds); + // All source locks are now held. Re-read current rows instead of overwriting with pre-lock snapshots. + var current = new ArrayList(); + for (long id : related.keySet()) { + var tail = lastlegOrders.selectById(id); + if (tail == null || !delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源已变化"); + if (DeliveryCompletedOutboxService.domestic(tail)) { + var proof = proofs.get(tail.getSourceInboundId()); + if (proof != null && proof.fullySigned()) tail.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED"); + else { + tail.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY"); + pendingCompletion(delivery, operatorId, now, inboundLabel(tail) + ":来源签收未核齐;" + + (proof == null ? "缺少来源证明" : "已证明" + proof.provenSignedPackages() + "/" + proof.originalPackages() + "箱;" + (proof.unresolvedReasons().isEmpty() ? "仍有货物未完成签收" : "签收与退回来源证据尚未核齐,请核对实发、实收及签收记录"))); + } + } else tail.setSubOrderStatus(Objects.equals(delivery.getSignedPackages(),delivery.getPackageCount()) + ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); + tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail); + } + return new Completion(List.copyOf(current),proofs); + } + private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); } + private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) { + var operation = new DeliveryOperation(); operation.setCustomerId(delivery.getCustomerId()); + operation.setDeliveryOrderId(delivery.getId()); operation.setOperatorId(operatorId); + operation.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); operation.setResult(reason); + operation.setCreatedAt(now); operations.insert(operation); } private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java new file mode 100644 index 000000000..0798e5a1e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.util.*; + +/** Caller locks its own delivery, then calls proveAll BEFORE any source-order updates. + * Explicit READ_COMMITTED is required. Lock source orders in numeric ID order until commit; + * never lock historical delivery/dispatch/exception/inventory rows from this query. + * RC evidence reads see the preceding source-lock owner's committed signatures without waiting + * for another transaction's delivery lock. All signature writers must follow this protocol. + */ +@Service +public class SourceDeliveryCompletionQuery { + private final DeliverySourceEvidenceMapper evidence; + public SourceDeliveryCompletionQuery(DeliverySourceEvidenceMapper evidence) { this.evidence = evidence; } + + /** Source uses UNIQUE(customer_id,source_region,source_inbound_id); mapper selectOne has no LIMIT. + * A broken uniqueness invariant therefore raises TooManyResultsException, never picks one row. + */ + @Transactional(propagation = Propagation.MANDATORY) + public Result prove(String customerId, String sourceInboundId) { + return proveAll(customerId, List.of(sourceInboundId)).get(sourceInboundId); + } + + /** One call with ALL affected sources; do not loop prove() for a multi-source delivery. */ + @Transactional(propagation = Propagation.MANDATORY) + public Map proveAll(String customerId, Collection sourceInboundIds) { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || TransactionSynchronizationManager.isCurrentTransactionReadOnly()) + throw new IllegalStateException("Source signature proof requires the caller's active write transaction"); + if (!Objects.equals(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel(), TransactionDefinition.ISOLATION_READ_COMMITTED)) + throw new IllegalStateException("Source signature proof requires explicit READ_COMMITTED on the outer transaction"); + if (customerId == null || customerId.isBlank() || sourceInboundIds == null || sourceInboundIds.isEmpty() + || sourceInboundIds.stream().anyMatch(id -> id == null || id.isBlank())) + throw new IllegalArgumentException("Customer and source inbound ID are required"); + var result = new LinkedHashMap(); var sources = new TreeMap(); + for (String id : new TreeSet<>(sourceInboundIds)) { + Source source = evidence.source(customerId, id); + if (source == null) { result.put(id, new Result(id,0,0,false,List.of("SOURCE_NOT_FOUND","HISTORY_NOT_COMPLETE"))); continue; } + if (!id.equals(source.sourceInboundId()) || source.orderId() <= 0 || sources.putIfAbsent(source.orderId(),source) != null) + throw new IllegalStateException("Source evidence identity mismatch"); + } + var locked = new ArrayList(); + for (var source : sources.values()) { + Source current = evidence.lockSource(customerId, source.orderId()); + if (current == null || current.orderId() != source.orderId() || !current.sourceInboundId().equals(source.sourceInboundId())) + throw new IllegalStateException("Source changed while acquiring completion lock"); + locked.add(current); + } + for (var source : locked) result.put(source.sourceInboundId(), proveLocked(customerId, source)); + return Collections.unmodifiableMap(result); + } + + private Result proveLocked(String customerId, Source source) { + String sourceInboundId = source.sourceInboundId(); + var ids = Objects.requireNonNull(evidence.dispatchIds(customerId, source.orderId()), "Dispatch history missing"); + if (ids.stream().anyMatch(id -> id == null || id <= 0) || new HashSet<>(ids).size() != ids.size()) + throw new IllegalStateException("Invalid or duplicate historical dispatch IDs"); + var deliveries = new ArrayList(); var picks = new ArrayList(); var returns = new ArrayList(); + for (long id : ids.stream().sorted().toList()) { + var dispatchDeliveries = Objects.requireNonNull(evidence.deliveries(customerId, id), "Delivery evidence missing"); + var dispatchPicks = Objects.requireNonNull(evidence.picks(customerId, id), "Pick evidence missing"); + var dispatchReturns = Objects.requireNonNull(evidence.returns(customerId, id), "Return evidence missing"); + if (dispatchDeliveries.isEmpty() || dispatchPicks.isEmpty() + || dispatchDeliveries.stream().anyMatch(row -> row == null || row.dispatchId() != id) + || dispatchPicks.stream().anyMatch(row -> row == null || row.dispatchId() != id) + || dispatchPicks.stream().noneMatch(row -> row.orderId() == source.orderId())) + return new Result(sourceInboundId, source.packages(), 0, false, + List.of("DISPATCH_HISTORY_INCOMPLETE:" + id, "HISTORY_NOT_COMPLETE")); + var deliveryIds = new HashSet(); dispatchDeliveries.forEach(row -> deliveryIds.add(row.deliveryId())); + if (dispatchReturns.stream().anyMatch(row -> row == null || !deliveryIds.contains(row.deliveryId()))) + throw new IllegalStateException("Return evidence belongs to another dispatch"); + deliveries.addAll(dispatchDeliveries); picks.addAll(dispatchPicks); returns.addAll(dispatchReturns); + } + return SourceDeliveryCompletionPolicy.evaluate(source, deliveries, picks, returns, true); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index 6f111fd19..df110692e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -75,7 +75,7 @@ public class SmartDispatchController { @Valid @RequestBody DispatchRequests.PreArrange request) { requirePlatform(user); String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank() - ? service.resolveCustomerScopeForPallets(request.planPalletIds()) + ? service.resolveCustomerScopeForSources(request.planPalletIds(), request.returnReceiptIds()) : service.resolveCustomerScopeForRelabel(request.relabelNo()); String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); @@ -88,7 +88,7 @@ public class SmartDispatchController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody DispatchRequests.DraftPallet request) { requirePlatform(user); - String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId())); + String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); service.lockDraftPallet(customerId, operatorId(user), request); return ApiResponse.success(null); } @@ -113,7 +113,7 @@ public class SmartDispatchController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody DispatchRequests.DraftPallet request) { requirePlatform(user); - String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId())); + String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); service.unlockDraftPallet(customerId, operatorId(user), request); return ApiResponse.success(null); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 8056cd113..3cdad5daf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -19,12 +19,21 @@ public final class DispatchRequests { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } - public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, + public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus, @Size(max = 64) String draftToken, - @Size(max = 64) String relabelNo) { + @Size(max = 64) String relabelNo, + @Size(max = 500) List<@NotNull Long> returnReceiptIds) { + public PreArrange { + planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds); + returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds); + } + public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, + String vehicleAppointmentStatus, String draftToken, String relabelNo) { + this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, relabelNo, List.of()); + } public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus) { this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); @@ -35,8 +44,15 @@ public final class DispatchRequests { } } - public record DraftPallet(@NotNull Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId) { + public record DraftPallet(Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId, + Long returnReceiptId, String sourceType) { + public DraftPallet(Long planPalletId, String draftToken, Long draftContainerId) { this(planPalletId, draftToken, draftContainerId, null, "INBOUND_PLAN"); } public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); } + @jakarta.validation.constraints.AssertTrue(message = "请选择唯一且匹配类型的实物来源") + public boolean isValidSource() { + return returnReceiptId != null ? planPalletId == null && "RETURN_RECEIPT".equals(sourceType) + : planPalletId != null && (sourceType == null || "INBOUND_PLAN".equals(sourceType)); + } } public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java index df89323c0..cb8d25f94 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -18,6 +18,7 @@ public class DispatchItem { private String customerId; private Long planId; private Long planPalletId; + private Long returnReceiptId; private Long warehousePalletId; private Long orderId; private String overseasWarehouseId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java index 57239e4ed..eb6728f0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -22,6 +22,7 @@ public class DispatchPalletLock { private String activeKey; private Long planId; private Long planPalletId; + private Long returnReceiptId; private Long warehousePalletId; private Long dispatchOrderId; @TableField(updateStrategy = FieldStrategy.ALWAYS) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java new file mode 100644 index 000000000..74bb166d7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface ReturnDispatchCandidateMapper { + String SELECT = "SELECT r.id AS returnReceiptId,r.receipt_no AS returnReceiptNo,r.order_id AS orderId," + + "r.customer_id AS customerId,o.customer_name AS customerName,o.order_type AS orderType,o.order_no AS orderNo," + + "o.order_no AS orderRefs,b.inbound_code AS inboundCode,p.pallet_id AS warehousePalletId,p.pallet_no AS palletNo," + + "p.overseas_warehouse_id AS overseasWarehouseId,b.overseas_warehouse_name AS overseasWarehouseName," + + "b.overseas_warehouse_code AS overseasWarehouseCode,d.destination_type AS destinationType," + + "d.destination_warehouse_code AS destinationWarehouseCode,d.destination_warehouse_code AS fbaWarehouseCode," + + "d.destination_warehouse_name AS destinationWarehouseName,d.delivery_address AS deliveryAddress,d.postal_code AS postalCode," + + "d.contact_name AS contactName,d.contact_phone AS contactPhone,'RECEIVED' AS sourceStage," + + "LEAST(p.available_packages,r.received_packages-(SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " + + "WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id)) AS packageCount " + + "FROM ll_return_stock_receipt r JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " + + "JOIN ll_inventory_position p ON p.customer_id=r.customer_id AND p.id=r.inventory_position_id " + + "JOIN ll_inventory_balance b ON b.customer_id=r.customer_id AND b.id=r.inventory_balance_id " + + "JOIN ll_exception_order e ON e.customer_id=r.customer_id AND e.id=r.exception_order_id " + + "JOIN ll_delivery_order d ON d.customer_id=r.customer_id AND d.id=e.delivery_order_id "; + String ELIGIBLE = " AND o.detained=0 AND p.available_packages>0 AND r.received_packages>(SELECT COALESCE(SUM(a.packages),0) " + + "FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) "; + @Select("") + List available(@Param("customerId") String customerId, @Param("orderNo") String orderNo, + @Param("fba") String fba, @Param("draftToken") String draftToken); + + @Select("") + List lockSelected(@Param("customerId") String customerId, @Param("ids") List ids); + + @Select("") + List customers(@Param("ids") List ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java index 1fb5f6e9c..9b57005ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -3,42 +3,87 @@ package com.baiyue.lastleg.dispatch.model; import java.math.BigDecimal; import java.time.LocalDateTime; -public record DispatchPalletCandidate( - Long planId, - Long planPalletId, - Long warehousePalletId, - Long orderId, - String customerId, - String customerName, - String planNo, - String orderType, - BigDecimal volumeWeightKg, - BigDecimal grossWeightKg, - String containerNo, - LocalDateTime appointmentAt, - LocalDateTime expectedLoadingAt, - String overseasWarehouseId, - String overseasWarehouseCode, - String overseasWarehouseName, - String orderNo, - String orderRefs, - String inboundCode, - String palletNo, - String destinationWarehouseId, - String destinationWarehouseCode, - String destinationWarehouseName, - String fbaWarehouseCode, - Integer packageCount, - BigDecimal volumeCbm, - String sourceStage, - String cartonMarks, - String relabelCartonMarks, - String destinationType, - String deliveryAddress, - String postalCode, - String contactName, - String contactPhone -) { +@lombok.Data +@lombok.NoArgsConstructor +@lombok.experimental.Accessors(fluent = true) +@com.fasterxml.jackson.annotation.JsonAutoDetect(fieldVisibility = com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY) +public class DispatchPalletCandidate { + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long orderId; + private String customerId; + private String customerName; + private String planNo; + private String orderType; + private BigDecimal volumeWeightKg; + private BigDecimal grossWeightKg; + private String containerNo; + private LocalDateTime appointmentAt; + private LocalDateTime expectedLoadingAt; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String orderNo; + private String orderRefs; + private String inboundCode; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String fbaWarehouseCode; + private Integer packageCount; + private BigDecimal volumeCbm; + private String sourceStage; + private String cartonMarks; + private String relabelCartonMarks; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; + private Long returnReceiptId; + private String returnReceiptNo; + @com.fasterxml.jackson.annotation.JsonProperty("sourceType") + public String sourceType() { return returnReceiptId == null ? "INBOUND_PLAN" : "RETURN_RECEIPT"; } + + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String customerId, String customerName, String planNo, String orderType, BigDecimal volumeWeightKg, BigDecimal grossWeightKg, String containerNo, LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm, String sourceStage, String cartonMarks, String relabelCartonMarks, String destinationType, String deliveryAddress, String postalCode, String contactName, String contactPhone) { + this.planId = planId; + this.planPalletId = planPalletId; + this.warehousePalletId = warehousePalletId; + this.orderId = orderId; + this.customerId = customerId; + this.customerName = customerName; + this.planNo = planNo; + this.orderType = orderType; + this.volumeWeightKg = volumeWeightKg; + this.grossWeightKg = grossWeightKg; + this.containerNo = containerNo; + this.appointmentAt = appointmentAt; + this.expectedLoadingAt = expectedLoadingAt; + this.overseasWarehouseId = overseasWarehouseId; + this.overseasWarehouseCode = overseasWarehouseCode; + this.overseasWarehouseName = overseasWarehouseName; + this.orderNo = orderNo; + this.orderRefs = orderRefs; + this.inboundCode = inboundCode; + this.palletNo = palletNo; + this.destinationWarehouseId = destinationWarehouseId; + this.destinationWarehouseCode = destinationWarehouseCode; + this.destinationWarehouseName = destinationWarehouseName; + this.fbaWarehouseCode = fbaWarehouseCode; + this.packageCount = packageCount; + this.volumeCbm = volumeCbm; + this.sourceStage = sourceStage; + this.cartonMarks = cartonMarks; + this.relabelCartonMarks = relabelCartonMarks; + this.destinationType = destinationType; + this.deliveryAddress = deliveryAddress; + this.postalCode = postalCode; + this.contactName = contactName; + this.contactPhone = contactPhone; + } + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String overseasWarehouseId, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java index 4f87b9f53..c59eb46d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -15,6 +15,7 @@ public class DispatchPdaPickOrder { private String customerId; private Long pickId; private Long receiptId; + private Long returnReceiptId; private Long orderId; private Long packages; private Long productQuantity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 284d35a54..1c64a6bda 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -81,7 +81,7 @@ public class DispatchPdaWorkflowService { return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); } - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, String operatorId, String operatorName, DispatchPdaRequests.Pick request) { @@ -147,7 +147,7 @@ public class DispatchPdaWorkflowService { pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); pickAttribution.attribute(customerId, pick, - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); + "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId()); long total = picked + request.packages(); order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index d1207b9cb..3c1bcc9e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -15,7 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; -import java.util.LinkedHashMap; +import java.util.TreeMap; import java.util.List; import java.util.Map; @@ -24,12 +24,14 @@ public class DispatchPickOrderAttributionService { @Resource private DispatchPdaPickOrderMapper attributions; @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper history; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; /** Acquire source order locks before any inventory lock, matching order hold/release. */ @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void preparePicking(String customerId, long positionId, Long fallbackOrderId) { var sourceIds = new java.util.TreeSet(); attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); + returnReceipts.available(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId); if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order"); for (Long orderId : sourceIds) { @@ -42,6 +44,10 @@ public class DispatchPickOrderAttributionService { .anyMatch(receipt -> !sourceIds.contains(receipt.getOrderId()))) { throw conflict("PDA pick source orders changed; reload the task"); } + for (var receipt : returnReceipts.available(customerId, positionId)) { + if (!sourceIds.contains(receipt.getOrderId())) throw conflict("Returned source orders changed; reload the task"); + if (returnReceipts.lock(customerId, receipt.getId()) == null) throw conflict("Returned stock receipt disappeared"); + } } /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ @@ -55,14 +61,18 @@ public class DispatchPickOrderAttributionService { * authoritative tail-leg order, so use that order only for relabel picks. */ public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) { + return attribute(customerId, pick, fallbackOrderId, null); + } + public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId, Long selectedReturnReceiptId) { long remainingPackages = value(pick.getPackages()); long totalPackages = remainingPackages; long totalQuantity = value(pick.getProductQuantity()); long allocatedPackages = 0; long allocatedQuantity = 0; - Map pickedByOrder = new LinkedHashMap<>(); - for (InboundStockReceipt receipt : attributions.availableReceipts(customerId, - pick.getInventoryPositionId())) { + Map pickedByOrder = new TreeMap<>(); + List pendingAttributions = new java.util.ArrayList<>(); + for (InboundStockReceipt receipt : selectedReturnReceiptId == null ? attributions.availableReceipts(customerId, + pick.getInventoryPositionId()) : List.of()) { if (remainingPackages == 0) break; long receiptPackages = value(receipt.getReceivedPackages()) - attributions.attributedPackages(customerId, receipt.getId()); @@ -80,17 +90,36 @@ public class DispatchPickOrderAttributionService { row.setPackages(packages); row.setProductQuantity(quantity); row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); - attributions.insert(row); + pendingAttributions.add(row); pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum); remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; } - if (remainingPackages != 0 && fallbackOrderId != null && fallbackOrderId > 0) { + for (var receipt : returnReceipts.available(customerId, pick.getInventoryPositionId())) { + if (remainingPackages == 0) break; + if (selectedReturnReceiptId != null && !selectedReturnReceiptId.equals(receipt.getId())) continue; + var locked = returnReceipts.lock(customerId, receipt.getId()); + if (locked == null) throw conflict("Returned stock receipt disappeared"); + long available = locked.getReceivedPackages() - returnReceipts.picked(customerId, locked.getId()); + if (available <= 0) continue; + long packages = Math.min(remainingPackages, available); + long nextPackages = allocatedPackages + packages; + long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setId(IdWorker.getId()); row.setCustomerId(customerId); row.setPickId(pick.getId()); + row.setReturnReceiptId(locked.getId()); row.setOrderId(locked.getOrderId()); row.setPackages(packages); + row.setProductQuantity(nextQuantity - allocatedQuantity); + row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); + pendingAttributions.add(row); pickedByOrder.merge(locked.getOrderId(), packages, Long::sum); + remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; + } + if (remainingPackages != 0 && selectedReturnReceiptId == null && fallbackOrderId != null && fallbackOrderId > 0) { pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum); remainingPackages = 0; } if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); + pendingAttributions.forEach(attributions::insert); for (Map.Entry entry : pickedByOrder.entrySet()) { if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) { throw conflict("Picked packages exceed tail-leg order remaining packages"); @@ -102,7 +131,7 @@ public class DispatchPickOrderAttributionService { /** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */ public List depart(String customerId, String outboundNo, String dispatchNo, List picks, LocalDateTime departedAt) { - Map shippedByOrder = new LinkedHashMap<>(); + Map shippedByOrder = new TreeMap<>(); for (DispatchPdaPick pick : picks) { for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) { shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum); @@ -117,7 +146,7 @@ public class DispatchPickOrderAttributionService { throw conflict("Tail-leg outbound sub-order status could not be synchronized"); } long shipped = value(before.getShippedPackageCount()) + entry.getValue(); - String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; + String to = shipped == value(before.getPackageCount()) + orders.returnedPackages(customerId, entry.getKey()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; LastlegOrderStatusHistory row = new LastlegOrderStatusHistory(); row.setCustomerId(customerId); row.setOrderId(entry.getKey()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index a5ad1de69..80eec715b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -62,6 +62,14 @@ public class DispatchService { @Resource private DispatchDraftMapper drafts; @Resource private DispatchDraftContainerMapper draftContainers; @Resource private DispatchCandidateMapper candidates; + @Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; + + public String resolveCustomerScopeForSources(List planPalletIds, List returnReceiptIds) { + var scopes = new java.util.ArrayList(); + if (planPalletIds != null && !planPalletIds.isEmpty()) scopes.addAll(candidates.findCustomerScopesByPlanPalletIds(planPalletIds)); + if (returnReceiptIds != null && !returnReceiptIds.isEmpty()) scopes.addAll(returnCandidates.customers(returnReceiptIds)); + return requireSingleCustomerScope(scopes.stream().distinct().toList(), "Selected dispatch sources"); + } @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderMapper tailOrders; @@ -120,8 +128,11 @@ public class DispatchService { .toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList(); } } + if (planIdsForReturns(sourceStage)) result = java.util.stream.Stream.concat(result.stream(), + returnCandidates.available(null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(draftToken)).stream()).toList(); return result; } + private boolean planIdsForReturns(String sourceStage) { return sourceStage == null || sourceStage.isBlank() || "RECEIVED".equals(sourceStage); } public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) { return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); } @@ -134,7 +145,7 @@ public class DispatchService { private List containers(String token) { return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), locks.findActiveByDraftContainer(token, c.getId()).stream() - .map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); + .map(x -> x.getReturnReceiptId() == null ? String.valueOf(x.getPlanPalletId()) : "RETURN:" + x.getReturnReceiptId()).toList())).toList(); } @Transactional @@ -177,7 +188,7 @@ public class DispatchService { boundContainer = new DispatchDraftContainer(); boundContainer.setId(IdWorker.getId()); boundContainer.setDraftId(draft.getId()); boundContainer.setDraftToken(request.draftToken()); boundContainer.setSequenceNo(1); boundContainer.setCapacity(500); boundContainer.setCreatedAt(LocalDateTime.now()); boundContainer.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(boundContainer); } if (boundContainer == null || !request.draftToken().equals(boundContainer.getDraftToken()) || !Objects.equals(boundContainer.getDraftId(), draft.getId())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不存在"); - DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId()); + DispatchPalletCandidate candidate = requireDraftSource(customerId, request); LocalDateTime now = LocalDateTime.now(); DispatchPalletLock active = activeLock(customerId, candidate); if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) { @@ -199,6 +210,7 @@ public class DispatchService { lock.setActiveKey(candidate.sourcePalletKey()); lock.setPlanId(candidate.planId()); lock.setPlanPalletId(candidate.planPalletId()); + lock.setReturnReceiptId(candidate.returnReceiptId()); lock.setWarehousePalletId(candidate.warehousePalletId()); lock.setDraftToken(request.draftToken()); lock.setDraftContainerId(request.draftContainerId()); @@ -218,7 +230,7 @@ public class DispatchService { @Transactional public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId()); + DispatchPalletCandidate candidate = requireDraftSource(customerId, request); DispatchPalletLock active = activeLock(customerId, candidate); if (active == null) return; requireDraftOwner(active, operatorId, request.draftToken()); @@ -226,7 +238,9 @@ public class DispatchService { DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId()); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); requirePalletEditingAllowed(dispatch); - DispatchItem item = items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()); + DispatchItem item = request.returnReceiptId() == null ? items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()) + : items.selectOne(new LambdaQueryWrapper().eq(DispatchItem::getCustomerId, customerId) + .eq(DispatchItem::getDispatchOrderId, dispatch.getId()).eq(DispatchItem::getReturnReceiptId, request.returnReceiptId()).last("FOR UPDATE")); if (item != null) removeLockedItem(dispatch, item, List.of(active)); else releaseLock(active, LocalDateTime.now()); return; @@ -261,6 +275,7 @@ public class DispatchService { @Transactional public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { if (StringUtils.hasText(request.relabelNo())) { + if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用"); return preArrangeRelabel(customerId, operatorId, request); } if (!StringUtils.hasText(customerId)) { @@ -268,13 +283,20 @@ public class DispatchService { "No customer business data is available for dispatch"); } List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); - if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) { + List receiptIds = request.returnReceiptIds().stream().distinct().sorted().toList(); + if (palletIds.size() + receiptIds.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); + if ((palletIds.isEmpty() && receiptIds.isEmpty()) || palletIds.size() != request.planPalletIds().size() + || receiptIds.size() != request.returnReceiptIds().size()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); } - List selected = candidates.lockSelected(customerId, palletIds); - if (selected.size() != palletIds.size()) { + List selected = new java.util.ArrayList<>(); + if (!palletIds.isEmpty()) selected.addAll(candidates.lockSelected(customerId, palletIds)); + if (!receiptIds.isEmpty()) selected.addAll(returnCandidates.lockSelected(customerId, receiptIds)); + if (selected.size() != palletIds.size() + receiptIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + if (selected.stream().map(DispatchPalletCandidate::sourcePalletKey).distinct().count() != selected.size()) + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复选择,请先拆托核对来源"); if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) { boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType())); throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba @@ -338,24 +360,22 @@ public class DispatchService { if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch); List previousItems = items.findByDispatchOrder(customerId, dispatch.getId()); - Set selectedPalletIds = selected.stream().map(DispatchPalletCandidate::planPalletId) + Set selectedPalletIds = selected.stream().map(DispatchService::selectionKey) .collect(Collectors.toSet()); if (refreshing) { List previousLocks = locks.selectList(new LambdaQueryWrapper() .eq(DispatchPalletLock::getCustomerId, customerId) .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) .eq(DispatchPalletLock::getStatus, "ACTIVE")); - previousItems.stream().filter(item -> item.getPlanPalletId() != null - && !selectedPalletIds.contains(item.getPlanPalletId())) + previousItems.stream().filter(item -> !selectedPalletIds.contains(selectionKey(item))) .forEach(item -> { - previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId())) + previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())) .forEach(lock -> releaseLock(lock, now)); items.deleteById(item.getId()); }); } - Set existingPalletIds = previousItems.stream() - .filter(item -> item.getPlanPalletId() == null || selectedPalletIds.contains(item.getPlanPalletId())) - .map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).collect(Collectors.toSet()); + Set existingPalletIds = previousItems.stream() + .map(DispatchService::selectionKey).filter(selectedPalletIds::contains).collect(Collectors.toSet()); for (DispatchPalletCandidate candidate : selected) { DispatchPalletLock lock = activeLock(customerId, candidate); @@ -373,6 +393,7 @@ public class DispatchService { lock.setActiveKey(candidate.sourcePalletKey()); lock.setPlanId(candidate.planId()); lock.setPlanPalletId(candidate.planPalletId()); + lock.setReturnReceiptId(candidate.returnReceiptId()); lock.setWarehousePalletId(candidate.warehousePalletId()); lock.setDispatchOrderId(dispatch.getId()); lock.setOperatorId(trimToNull(operatorId)); @@ -387,13 +408,14 @@ public class DispatchService { "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); } } - if (existingPalletIds.contains(candidate.planPalletId())) continue; + if (existingPalletIds.contains(selectionKey(candidate))) continue; DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setPlanId(candidate.planId()); item.setPlanPalletId(candidate.planPalletId()); + item.setReturnReceiptId(candidate.returnReceiptId()); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); @@ -564,7 +586,7 @@ public class DispatchService { .eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId()) .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) .eq(DispatchPalletLock::getStatus, "ACTIVE")); - List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) + List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId()) || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId()))) .toList(); return removeLockedItem(dispatch, item, matchingLocks); @@ -705,6 +727,15 @@ public class DispatchService { } return found.get(0); } + private static String selectionKey(DispatchPalletCandidate value) { return value.returnReceiptId() == null ? "PLAN:" + value.planPalletId() : "RETURN:" + value.returnReceiptId(); } + private static String selectionKey(DispatchItem value) { return value.getReturnReceiptId() == null ? "PLAN:" + value.getPlanPalletId() : "RETURN:" + value.getReturnReceiptId(); } + private DispatchPalletCandidate requireDraftSource(String customerId, DispatchRequests.DraftPallet request) { + if (!request.isValidSource()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择唯一实际来源"); + if (request.returnReceiptId() == null) return requireDraftCandidate(customerId, request.planPalletId()); + var found = returnCandidates.lockSelected(customerId, List.of(request.returnReceiptId())); + if (found.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "退回实收来源当前不可预排"); + return found.get(0); + } private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) { DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 3bac8a9b2..da23f8550 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -41,6 +41,7 @@ public class InventoryCommandService { @Resource private InventoryLedgerMapper ledgers; @Resource private InventoryOperationMapper operations; @Resource private InventoryCommandMapper commands; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; @Resource private WarehouseResourceGateway warehouseGateway; @Resource private ObjectMapper objectMapper; @@ -335,6 +336,17 @@ public class InventoryCommandService { InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); if (existing != null) return replayReturned(existing, requestHash); + Map returnOwners = new LinkedHashMap<>(); + for (var source : sources.values()) { + var owner = returnReceipts.owner(customerId, exceptionNo, outboundNo, source.outboundPalletItemId(), source.inventoryPositionId()); + if (owner == null || owner.orderId() == null || !Objects.equals(owner.inboundOrderId(), request.inboundOrderId())) + throw conflict("Returned stock receipt is missing the original order attribution"); + returnOwners.put(source.outboundPalletItemId(), owner); + } + for (Long orderId : returnOwners.values().stream().map(com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnOwner::orderId).distinct().sorted().toList()) { + if (returnReceipts.lockOrder(customerId, orderId) == null) throw conflict("Returned stock source order is missing or held"); + } + InventoryOperation operation = new InventoryOperation(); operation.setId(IdWorker.getId()); operation.setCustomerId(customerId); @@ -430,6 +442,16 @@ public class InventoryCommandService { normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(), "Exception return received with evidence " + request.evidenceFileIds().size()); ledgerIds.add(ledger.getId()); + var owner = returnOwners.get(source.outboundPalletItemId()); + var stockReceipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); + stockReceipt.setId(IdWorker.getId()); stockReceipt.setCustomerId(customerId); + stockReceipt.setReceiptNo("RSR" + stockReceipt.getId()); stockReceipt.setInventoryLedgerId(ledger.getId()); + stockReceipt.setExceptionOrderId(owner.exceptionOrderId()); stockReceipt.setOrderId(owner.orderId()); + stockReceipt.setInboundOrderId(owner.inboundOrderId()); stockReceipt.setSourceOutboundNo(outboundNo); + stockReceipt.setSourceOutboundPalletItemId(source.outboundPalletItemId()); stockReceipt.setSourceInventoryPositionId(source.inventoryPositionId()); + stockReceipt.setInventoryBalanceId(balance.getId()); stockReceipt.setInventoryPositionId(target.getId()); + stockReceipt.setReceivedPackages(receipt.receivedPackages()); stockReceipt.setReceivedAt(ledger.getOccurredAt()); + stockReceipt.setCreatedAt(ledger.getOccurredAt()); returnReceipts.insert(stockReceipt); restoredPackages += receipt.receivedPackages(); } long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java new file mode 100644 index 000000000..12f0f87f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +/** An immutable receipt from a real return ledger; it is not another customer order or initial inbound receipt. */ +@Data +@TableName("ll_return_stock_receipt") +public class ReturnStockReceipt { + @TableId private Long id; + private String customerId; + private String receiptNo; + private Long inventoryLedgerId; + private Long exceptionOrderId; + private Long orderId; + private Long inboundOrderId; + private String sourceOutboundNo; + private Long sourceOutboundPalletItemId; + private Long sourceInventoryPositionId; + private Long inventoryBalanceId; + private Long inventoryPositionId; + private Long receivedPackages; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java new file mode 100644 index 000000000..7183b318f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.ReturnStockReceipt; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface ReturnStockReceiptMapper extends BaseMapper { + @Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + Long lockOrder(@Param("customerId") String customerId, @Param("id") Long id); + record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { } + @Select("SELECT e.id AS exception_order_id,e.last_mile_order_id AS order_id,e.inbound_order_id " + + "FROM ll_exception_order e JOIN ll_order o ON o.customer_id=e.customer_id AND o.id=e.last_mile_order_id " + + "JOIN ll_exception_return_item i ON i.customer_id=e.customer_id AND i.exception_order_id=e.id " + + "WHERE e.customer_id=#{customerId} AND e.exception_no=#{exceptionNo} AND e.outbound_no=#{outboundNo} " + + "AND e.exception_type='RETURN' AND e.return_direction='OVERSEAS_WAREHOUSE' AND e.status='PROCESSING' " + + "AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}") + ReturnOwner owner(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo, + @Param("outboundNo") String outboundNo, @Param("itemId") Long itemId, @Param("positionId") Long positionId); + + @Select("SELECT r.* FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=#{positionId} " + + "AND p.customer_id=r.customer_id AND p.balance_id=r.inventory_balance_id " + + "WHERE r.customer_id=#{customerId} AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) " + + "FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) " + + "ORDER BY r.received_at,r.id") + List available(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + @Select("SELECT * FROM ll_return_stock_receipt WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + ReturnStockReceipt lock(@Param("customerId") String customerId, @Param("id") Long id); + + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND return_receipt_id=#{id}") + long picked(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 26dd83ad7..2119553a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -220,18 +220,24 @@ public interface LastlegOrderMapper extends BaseMapper { @Param("expectedVersion") int expectedVersion, @Param("updatedAt") java.time.LocalDateTime updatedAt); + String RETURNED_PACKAGES = "(SELECT COALESCE(SUM(r.received_packages),0) FROM ll_return_stock_receipt r " + + "WHERE r.customer_id=ll_order.customer_id AND r.order_id=ll_order.id)"; + + @Select("SELECT COALESCE(SUM(received_packages),0) FROM ll_return_stock_receipt WHERE customer_id=#{customerId} AND order_id=#{id}") + long returnedPackages(@Param("customerId") String customerId, @Param("id") Long id); + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " + - "AND package_count-shipped_package_count-picked_package_count >= #{packages}") + "AND #{packages}>0 AND package_count+" + RETURNED_PACKAGES + "-shipped_package_count-picked_package_count >= #{packages}") int increasePicked(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages); - @Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count " + + @Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count+" + RETURNED_PACKAGES + " " + "THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," + "picked_package_count=picked_package_count-#{packages}," + "shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 AND picked_package_count >= #{packages} " + - "AND shipped_package_count+#{packages} <= package_count") + "AND #{packages}>0 AND shipped_package_count+#{packages} <= package_count+" + RETURNED_PACKAGES) int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages, @Param("outboundNo") String outboundNo); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 441e97d42..75f0a3803 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1763,6 +1763,8 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( source_business_type VARCHAR(32) NULL COMMENT '来源业务类型', source_business_no VARCHAR(128) NULL COMMENT '来源业务单号', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), KEY idx_ll_dispatch_item_order (customer_id,order_id), @@ -1794,6 +1796,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab released_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 '更新时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), @@ -1862,10 +1866,39 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_operation_key (customer_id,idempotency_key) ) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; +CREATE TABLE ll_return_stock_receipt ( + id BIGINT NOT NULL COMMENT '退回入仓实收凭据ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据归属', + receipt_no VARCHAR(64) NOT NULL COMMENT '退回实收业务编号', + inventory_ledger_id BIGINT NOT NULL COMMENT '唯一实际退回入账流水', + exception_order_id BIGINT NOT NULL COMMENT '退回异常订单', + order_id BIGINT NOT NULL COMMENT '原尾程订单', + inbound_order_id BIGINT NOT NULL COMMENT '原入仓单', + source_outbound_no VARCHAR(64) NOT NULL COMMENT '原发运出库业务编号', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + source_inventory_position_id BIGINT NOT NULL COMMENT '原拣货库存位置', + inventory_balance_id BIGINT NOT NULL COMMENT '实际退回库存余额', + inventory_position_id BIGINT NOT NULL COMMENT '实际退回库存位置', + received_packages BIGINT NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + received_at DATETIME(3) NOT NULL COMMENT '实际退回入账时间', + created_at DATETIME(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_stock_receipt_no(customer_id,receipt_no), + UNIQUE KEY uk_return_stock_receipt_ledger(customer_id,inventory_ledger_id), + KEY idx_return_stock_receipt_order(customer_id,order_id), + KEY idx_return_stock_receipt_position(customer_id,inventory_balance_id,inventory_position_id), + CONSTRAINT chk_return_stock_receipt_packages CHECK(received_packages>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; + CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', - receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', + receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)), + + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) ) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 7a0c3751c..63b1de0d5 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -2401,21 +2401,28 @@ components: packageCount: { type: integer } volumeCbm: { type: number } sourceStage: { type: string, enum: [PLAN, RECEIVED] } + sourceType: { type: string, enum: [INBOUND_PLAN, RETURN_RECEIPT], description: 正式实物来源类型 } + returnReceiptId: { type: string, nullable: true, description: 退回实收来源ID,不作为计划托盘ID } + returnReceiptNo: { type: string, nullable: true, description: 退回实收业务编号 } cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } SmartDispatchPreArrange: type: object - required: [planPalletIds] + description: planPalletIds与returnReceiptIds是两类正式来源,合计至少一项且最多500项。退回来源仅接受实际入账生成的实收记录,不得用退回实收ID冒充计划托盘ID;同一实物托盘不可重复选择。 properties: - planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } + planPalletIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string } } + returnReceiptIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string }, description: 原订单实际退回入账来源ID } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } draftToken: { type: string, nullable: true, maxLength: 64, description: 拖入托盘时使用的智能排车草稿标识 } SmartDispatchDraftPallet: type: object - required: [planPalletId, draftToken] + required: [draftToken] + description: planPalletId和returnReceiptId恰有一个;退回来源必须声明sourceType=RETURN_RECEIPT。并发占用始终按客户及实物托盘锁定。 properties: planPalletId: { type: string } + returnReceiptId: { type: string } + sourceType: { type: string, enum: [INBOUND_PLAN, RETURN_RECEIPT] } draftToken: { type: string, minLength: 1, maxLength: 64 } draftContainerId: { type: string, nullable: true, description: 托盘所在草稿车厢ID } SmartDispatchDraftRelease: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java new file mode 100644 index 000000000..8001a026f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +/** Executes the production annotation SQL against real H2 tables, not mocked mapper results. */ +class DeliverySourceEvidenceMapperTest { + @Test void historySurvivesCurrentDeliveryRebindingAndKeepsAllCoShippedSources() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,delivery_order_no VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); + db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S-A',4,'NEW-DELIVERY'),(102,'C','FIRSTLEG','S-B',6,'OTHER-DELIVERY')"); + db.update("INSERT INTO ll_dispatch_order VALUES(10,'C','2026-10-03 01:00:00')"); + db.update("INSERT INTO ll_delivery_order VALUES(20,'C',10,'CODE:A',10,8,'2026-10-03 03:00:00','OUT-1')"); + db.update("INSERT INTO ll_dispatch_pda_task VALUES(1,'C',10)"); + db.update("INSERT INTO ll_dispatch_item VALUES(7,'C',10,'FBA',NULL,NULL,'a',NULL,NULL)"); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(2,'C',1,7)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(3,'C',2,101,4,30,NULL),(4,'C',2,102,6,31,NULL)"); + db.update("INSERT INTO ll_exception_order VALUES(30,'C',20,'EX-1',101)"); + db.update("INSERT INTO ll_inventory_ledger VALUES(40,'C','EX-1','OUT-1','RETURN_STOCK_IN',2,'2026-10-03 02:00:00','RETURN_SOURCE:7:8'),(41,'C','EX-1','OUT-1','RETURN_STOCK_IN',1,'2026-10-03 04:00:00','RETURN_SOURCE:7:8')"); + var args = Map.of("customerId","C","sourceInboundId","S-A","orderId",101L,"dispatchId",10L); + assertThat(named.queryForList(sql("source"),args)).singleElement().satisfies(r -> assertThat(((Number)r.get("packages")).longValue()).isEqualTo(4)); + assertThat(named.queryForList(sql("dispatchIds"),args)).hasSize(1); + assertThat(named.queryForList(sql("deliveries"),args)).singleElement().satisfies(r -> assertThat(r.get("departed_at")).isNotNull()); + var picks = named.queryForList(sql("picks"),args); + assertThat(picks).hasSize(2); assertThat(picks.stream().mapToLong(r -> ((Number)r.get("packages")).longValue()).sum()).isEqualTo(10); + assertThat(picks).allSatisfy(r -> assertThat(r.get("group_key")).isEqualTo("CODE:A")); + assertThat(named.queryForList(sql("returns"),args)).hasSize(2); + db.update("INSERT INTO ll_return_stock_receipt VALUES(90,'C',101,40,2,30,'OUT-1',7,8)"); + db.update("UPDATE ll_dispatch_pda_pick_order SET receipt_id=NULL,return_receipt_id=90,packages=2 WHERE id=3"); + var linked=named.queryForList(sql("picks"),args).get(0); + assertThat(((Number)linked.get("return_ledger_id")).longValue()).isEqualTo(40); + assertThat(((Number)linked.get("return_packages")).longValue()).isEqualTo(2); + db.update("UPDATE ll_return_stock_receipt SET order_id=102 WHERE id=90"); + assertThat(named.queryForList(sql("picks"),args).get(0).get("return_ledger_id")).isNull(); + for (var method : List.of("source","dispatchIds","deliveries","picks","returns")) { + var other = new HashMap<>(args); other.put("customerId","OTHER"); + assertThat(named.queryForList(sql(method),other)).isEmpty(); + } + } + private String sql(String name) { + var method = Arrays.stream(DeliverySourceEvidenceMapper.class.getMethods()).filter(m -> m.getName().equals(name)).findFirst().orElseThrow(); + return String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}", ":$1"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java new file mode 100644 index 000000000..f237273fc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java @@ -0,0 +1,228 @@ +package com.baiyue.lastleg.delivery; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.service.*; +import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.entity.*; +import com.baiyue.lastleg.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.*; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.lang.reflect.Proxy; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Production document/query/policy/outbox flow, with actual SQL state and transactions. + * Document BaseMapper plumbing is implemented with JDBC answers; source annotation SQL is executed verbatim. + */ +class DeliverySourcePublicationPersistenceTest { + static class Fixture { + final DriverManagerDataSource ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); + final JdbcTemplate db=new JdbcTemplate(ds); + final NamedParameterJdbcTemplate named=new NamedParameterJdbcTemplate(ds); + final DataSourceTransactionManager manager=new DataSourceTransactionManager(ds); + DeliveryDocumentService service=new DeliveryDocumentService(); + volatile boolean failOutbox, failRevision; + DeliverySourceEvidenceMapper mapper; + volatile Runnable beforeFirstSourceRead = () -> {}; + final Map> sourceLocks=new ConcurrentHashMap<>(); + Fixture() { + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); + db.execute("ALTER TABLE ll_order ADD sub_order_status VARCHAR(128)"); + db.execute("ALTER TABLE ll_delivery_order ADD version BIGINT DEFAULT 0"); + db.execute("ALTER TABLE ll_delivery_order ADD status VARCHAR(32) DEFAULT 'DELIVERING'"); + db.execute("ALTER TABLE ll_delivery_order ADD documents_json CLOB"); + db.execute("CREATE TABLE proof_revision(delivery_id BIGINT,command_key VARCHAR(128),request_hash VARCHAR(128),snapshot CLOB, UNIQUE(delivery_id,command_key))"); + db.execute("CREATE TABLE proof_outbox(event_key VARCHAR(255) PRIMARY KEY,aggregate_id VARCHAR(128),customer_id VARCHAR(128),payload CLOB)"); + db.execute("CREATE TABLE proof_operation(delivery_id BIGINT,node VARCHAR(80),result CLOB)"); + mapper = (DeliverySourceEvidenceMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{DeliverySourceEvidenceMapper.class},(proxy,method,args) -> { + if(method.getName().equals("source")) beforeFirstSourceRead.run(); + if(method.getName().equals("lockSource")) sourceLocks.computeIfAbsent(Thread.currentThread().getName(),k->new CopyOnWriteArrayList<>()).add((Long)args[1]); + String sql=String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}",":$1"); + String name=method.getName(); String parameter=name.equals("source")?"sourceInboundId":name.equals("dispatchIds")||name.equals("lockSource")?"orderId":"dispatchId"; + var rows=named.query(sql,Map.of("customerId",args[0],parameter,args[1]),(r,n)-> switch(name) { + case "source","lockSource" -> new Source(r.getString("source_inbound_id"),r.getLong("order_id"),r.getLong("packages")); + case "dispatchIds" -> r.getLong(1); + case "deliveries" -> new Delivery(r.getLong("delivery_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("packages"),r.getObject("signed_packages",Long.class),r.getObject("signed_at",LocalDateTime.class),r.getObject("departed_at",LocalDateTime.class)); + case "picks" -> new Pick(r.getLong("attribution_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("order_id"),r.getLong("packages"),r.getObject("receipt_id",Long.class),r.getObject("return_receipt_id",Long.class),r.getObject("return_ledger_id",Long.class),r.getObject("return_packages",Long.class)); + case "returns" -> new Returned(r.getLong("ledger_id"),r.getLong("delivery_id"),r.getLong("order_id"),r.getLong("packages"),r.getObject("received_at",LocalDateTime.class)); + default -> throw new IllegalStateException(name); + }); + return name.equals("source")||name.equals("lockSource") ? rows.isEmpty()?null:rows.get(0) : rows; + }); + var orders=mock(DeliveryOrderMapper.class); + when(orders.lockByNo(anyString(),anyString())).thenAnswer(a -> delivery(Long.parseLong(a.getArgument(1)),true)); + when(orders.updateById(any(DeliveryOrder.class))).thenAnswer(a -> { + DeliveryOrder d=a.getArgument(0); + return db.update("UPDATE ll_delivery_order SET signed_packages=?,signed_at=?,version=?,status=?,documents_json=? WHERE id=?",d.getSignedPackages(),d.getSignedAt(),d.getVersion(),d.getStatus(),d.getDocumentsJson(),d.getId()); + }); + var tails=mock(LastlegOrderMapper.class); + when(tails.selectList(any())).thenReturn(List.of()); // Current delivery relation deliberately unavailable. + when(tails.selectBatchIds(anyCollection())).thenAnswer(a -> { + Collection ids=a.getArgument(0); var result=new ArrayList(); + for(Object id:ids) result.add(tail(((Number)id).longValue())); return result; + }); + when(tails.selectById(anyLong())).thenAnswer(a -> tail(a.getArgument(0))); + when(tails.updateById(any(LastlegOrder.class))).thenAnswer(a -> { + LastlegOrder t=a.getArgument(0); return db.update("UPDATE ll_order SET sub_order_status=? WHERE id=?",t.getSubOrderStatus(),t.getId()); + }); + var revisions=mock(DeliveryDocumentRevisionMapper.class); + when(revisions.findCommand(anyString(),anyLong(),anyString())).thenAnswer(a -> { + var rows=db.query("SELECT * FROM proof_revision WHERE delivery_id=? AND command_key=?",(r,n)-> { + var v=new DeliveryDocumentRevision();v.setRequestHash(r.getString("request_hash"));v.setSnapshotJson(r.getString("snapshot"));return v; + },a.getArgument(1),a.getArgument(2)); return rows.isEmpty()?null:rows.get(0); + }); + when(revisions.insert(any(DeliveryDocumentRevision.class))).thenAnswer(a->{ + DeliveryDocumentRevision v=a.getArgument(0);if(failRevision)throw new IllegalStateException("Injected revision failure");return db.update("INSERT INTO proof_revision VALUES(?,?,?,?)",v.getDeliveryOrderId(),v.getIdempotencyKey(),v.getRequestHash(),v.getSnapshotJson()); + }); + var operations=mock(DeliveryOperationMapper.class); + when(operations.returnReceipts(anyString(),anyLong())).thenAnswer(a -> db.query( + "SELECT l.id,l.delta_packages,l.occurred_at FROM ll_inventory_ledger l JOIN ll_exception_order e ON e.exception_no=l.business_order_no WHERE e.delivery_order_id=?", + (r,n)->new DeliveryReturnReceiptFact(r.getLong(1),1L,1L,r.getLong(2),r.getObject(3,LocalDateTime.class)),new Object[]{a.getArgument(1)})); + when(operations.insert(any(DeliveryOperation.class))).thenAnswer(a->{DeliveryOperation o=a.getArgument(0);return db.update("INSERT INTO proof_operation VALUES(?,?,?)",o.getDeliveryOrderId(),o.getOperationNode(),o.getResult());}); + var outbox=mock(LastlegEventOutboxMapper.class); + when(outbox.insert(any(LastlegEventOutbox.class))).thenAnswer(a->{LastlegEventOutbox o=a.getArgument(0);if(failOutbox)throw new IllegalStateException("Injected outbox failure");return db.update("INSERT INTO proof_outbox VALUES(?,?,?,?)",o.getEventKey(),o.getAggregateId(),o.getCustomerId(),o.getPayloadJson());}); + when(outbox.findByEventKey(anyString())).thenAnswer(a->{var rows=db.query("SELECT * FROM proof_outbox WHERE event_key=?",(r,n)->{ + var o=new LastlegEventOutbox();o.setAggregateId(r.getString("aggregate_id"));o.setCustomerId(r.getString("customer_id"));return o;},new Object[]{a.getArgument(0)});return rows.isEmpty()?null:rows.get(0);}); + var publisher=new DeliveryCompletedOutboxService(); var json=new ObjectMapper().findAndRegisterModules(); + ReflectionTestUtils.setField(publisher,"outbox",outbox);ReflectionTestUtils.setField(publisher,"objectMapper",json); + var outbound=mock(OutboundOrderMapper.class);var shipped=new OutboundOrder();shipped.setStatus("OUTBOUNDED"); + when(outbound.findByNo(anyString(),anyString())).thenReturn(shipped); + var files=mock(DeliveryDocumentFileGateway.class); + when(files.metadata(anyString(),anyLong(),any(),anyLong())).thenReturn(new com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File(1L,"receipt.png","image/png",1,LocalDateTime.of(2026,1,1,0,0))); + Map fields=Map.ofEntries(Map.entry("orders",orders),Map.entry("outboundOrders",outbound),Map.entry("revisions",revisions),Map.entry("operations",operations),Map.entry("files",files),Map.entry("objectMapper",json),Map.entry("lastlegOrders",tails),Map.entry("deliveryCompletedOutbox",publisher),Map.entry("customerVisits",mock(CustomerVisitService.class)),Map.entry("sourceEvidence",mapper),Map.entry("sourceCompletion",new SourceDeliveryCompletionQuery(mapper))); + fields.forEach((name,value)->ReflectionTestUtils.setField(service,name,value)); + var proxyFactory=new org.springframework.aop.framework.ProxyFactory(service);proxyFactory.setProxyTargetClass(true); + proxyFactory.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource())); + service=(DeliveryDocumentService)proxyFactory.getProxy(); + } + DeliveryOrder delivery(long id,boolean lock) { + return db.queryForObject("SELECT * FROM ll_delivery_order WHERE id=?"+(lock?" FOR UPDATE":""),(r,n)->{ + var d=new DeliveryOrder();d.setId(id);d.setCustomerId("C");d.setDeliveryNo(String.valueOf(id));d.setOutboundNo(r.getString("outbound_no"));d.setDispatchOrderId(r.getLong("dispatch_order_id"));d.setDeliveryGroupKey(r.getString("delivery_group_key"));d.setPackageCount(r.getInt("package_count"));d.setSignedPackages(r.getObject("signed_packages",Integer.class));d.setSignedAt(r.getObject("signed_at",LocalDateTime.class));d.setVersion(r.getLong("version"));d.setStatus(r.getString("status"));d.setDocumentsJson(r.getString("documents_json"));return d; + },id); + } + LastlegOrder tail(long id) { + return db.queryForObject("SELECT * FROM ll_order WHERE id=?",(r,n)->{var o=new LastlegOrder();o.setId(id);o.setCustomerId("C");o.setSourceRegion("FIRSTLEG");o.setSourceInboundId(r.getString("source_inbound_id"));o.setPackageCount(r.getInt("package_count"));o.setSubOrderStatus(r.getString("sub_order_status"));o.setOrderNo("LM-"+id);o.setInboundCode("IN-"+id);o.setSourceOrderId("SOURCE-"+id);o.setSourceOrderNo("FL-"+id);o.setRootOrderId("ROOT-"+id);return o;},id); + } + TransactionTemplate tx() { var tx=new TransactionTemplate(manager);tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);return tx; } + Save request(String key) { return new Save(0L,key,null,List.of(new Binding(DeliveryDocumentCategory.RECEIPT,List.of(1L)))); } + void sign(long id,String key) { service.save("C",String.valueOf(id),"TEST",request(key)); } + void source(long id,int packages) { db.update("INSERT INTO ll_order(id,customer_id,source_region,source_inbound_id,package_count) VALUES(?,'C','FIRSTLEG',?,?)",id,"S-"+id,packages); } + void shipment(long id,int packages) { + db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-01-01 01:00:00')",id); + db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(?,'C',?,'CODE:A',?,?)",id,id,packages,"OUT-"+id); + db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",id,id); + db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",id,id); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",id,id,id); + } + void pick(long id,long dispatch,long order,int packages,Long receipt) { + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,?,?, ?,?)",id,dispatch,order,packages,receipt==null?id:null,receipt); + } + void returned(long id,long delivery,long order,int packages,LocalDateTime at) { + db.update("INSERT INTO ll_exception_order VALUES(?,'C',?,?,?)",id,delivery,"EX-"+id,order); + db.update("INSERT INTO ll_inventory_ledger VALUES(?,'C',?,?,'RETURN_STOCK_IN',?,?,?)",id,"EX-"+id,"OUT-"+delivery,packages,at,"RETURN_SOURCE:7:8"); + db.update("INSERT INTO ll_return_stock_receipt VALUES(?,'C',?,?,?,?,?,7,8)",id,order,id,packages,id,"OUT-"+delivery); + } + List published() { return db.queryForList("SELECT aggregate_id FROM proof_outbox ORDER BY aggregate_id",String.class); } + } + @Test void mixedDeliveryPublishesOnlyFullySignedSourceAndReplayDoesNotRepublish() { + var f=new Fixture();f.source(101,4);f.source(102,6);f.shipment(1,10);f.pick(1,1,101,4,null);f.pick(2,1,102,6,null); + f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"same-key"); + assertThat(f.published()).containsExactly("S-102");assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY"); + assertThat(f.tail(102).getSubOrderStatus()).contains("DELIVERY:DELIVERED"); + f.sign(1,"same-key");assertThat(f.published()).containsExactly("S-102"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isEqualTo(1); + // Re-dispatch only the two previously unsigned boxes. Original current delivery pointers are absent. + f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1)); + f.pick(3,2,101,2,30L); + // Keep deterministic chronology without sleeping or depending on wall clock granularity. + f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2"); + f.sign(2,"redelivery");assertThat(f.published()).containsExactly("S-101","S-102"); + } + @Test void postSignatureRedeliveryDoesNotFillNeverShippedFourBoxes() { + var f=new Fixture();f.source(101,10);f.shipment(1,6);f.pick(1,1,101,6,null);f.sign(1,"first"); + f.db.update("UPDATE ll_delivery_order SET signed_at='2026-01-02 00:00:00' WHERE id=1"); + f.returned(30,1,101,4,LocalDateTime.of(2026,1,3,0,0)); + f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-04 00:00:00' WHERE id=2"); + f.pick(2,2,101,4,30L);f.sign(2,"repeat-four"); + assertThat(f.published()).isEmpty();assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY"); + } + @Test void concurrentSignaturesPublishOnlyAfterLastSourceLockOwnerSeesBoth() throws Exception { + var f=new Fixture();f.source(101,10);f.source(102,10);f.shipment(1,10);f.shipment(2,10); + f.pick(1,1,102,5,null);f.pick(2,1,101,5,null); // B,A input + f.pick(3,2,101,5,null);f.pick(4,2,102,5,null); // A,B input + var firstLocked=new CountDownLatch(1);var secondUpdated=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(2); + f.beforeFirstSourceRead=()->{if(Thread.currentThread().getName().equals("second-signer"))secondUpdated.countDown();}; + try { + var first=pool.submit(()->f.tx().execute(s->{ + Thread.currentThread().setName("first-signer"); + f.delivery(1,true);f.mapper.lockSource("C",101);firstLocked.countDown();await(secondUpdated); + var result=f.service.save("C","1","TEST",f.request("one"));assertThat(f.published()).isEmpty();return result; + })); + var second=pool.submit(()->{Thread.currentThread().setName("second-signer");await(firstLocked);return f.tx().execute(s->{ + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE signed_at IS NOT NULL",Integer.class)).isZero(); + return f.service.save("C","2","TEST",f.request("two")); + });}); + first.get(10,TimeUnit.SECONDS);second.get(10,TimeUnit.SECONDS); + assertThat(f.published()).containsExactly("S-101","S-102");f.sign(2,"two");assertThat(f.published()).containsExactly("S-101","S-102"); + assertThat(f.sourceLocks.get("first-signer")).containsExactly(101L,101L,102L); + assertThat(f.sourceLocks.get("second-signer")).containsExactly(101L,102L); + } finally {pool.shutdownNow();} + } + @Test void springProxyAppliesReadCommittedAndFailureRollsBackSignatureSourceAndOutbox() { + for(boolean revisionFailure:List.of(false,true)) { + var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null); + assertThat(org.springframework.aop.support.AopUtils.isAopProxy(f.service)).isTrue(); + f.beforeFirstSourceRead=()->assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.getCurrentTransactionIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED); + f.failRevision=revisionFailure;f.failOutbox=!revisionFailure; + assertThatThrownBy(()->f.sign(1,"failure")).isInstanceOf(IllegalStateException.class).hasMessageContaining("Injected"); + assertThat(f.delivery(1,false).getSignedAt()).isNull();assertThat(f.delivery(1,false).getSignedPackages()).isNull(); + assertThat(f.delivery(1,false).getVersion()).isZero();assertThat(f.tail(101).getSubOrderStatus()).isNull(); + assertThat(f.published()).isEmpty(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_operation",Integer.class)).isZero(); + f.failRevision=false;f.failOutbox=false;f.sign(1,"failure");assertThat(f.published()).containsExactly("S-101"); + } + } + @Test void multiHopPreSignatureRedeliveryPublishesWhenLastUnsignedBoxIsSigned() { + var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null); + f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"first");assertThat(f.published()).isEmpty(); + f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L); + f.returned(40,2,101,1,LocalDateTime.of(2026,1,4,0,0));f.sign(2,"second");assertThat(f.published()).isEmpty(); + f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L); + f.sign(3,"third");assertThat(f.published()).containsExactly("S-101"); + } + @Test void unrelatedUnattributedDestinationDoesNotBlockSignedSource() { + var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null); + f.db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(2,'C',1,'CODE:B',6,'OUT-1')"); + f.sign(1,"source-a");assertThat(f.published()).containsExactly("S-101"); + assertThat(f.delivery(2,false).getSignedAt()).isNull(); + } + private static void await(CountDownLatch latch) { + try {if(!latch.await(6,TimeUnit.SECONDS))throw new IllegalStateException("Timed out");} + catch(InterruptedException e){Thread.currentThread().interrupt();throw new IllegalStateException(e);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java new file mode 100644 index 000000000..37ff4394f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import com.baiyue.lastleg.delivery.service.SourceDeliveryCompletionQuery; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import java.lang.reflect.Proxy; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** Two real database transactions execute the production mapper SQL, with bounded waits. */ +class SourceDeliveryCompletionConcurrencyTest { + @Test void lastSignerSeesFirstCommitEvenAfterAnEarlyReadWithoutLockingOtherDelivery() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000","sa",""); + var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); + db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S',10)"); + for (int i=1;i<=2;i++) { + db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-10-03 01:00:00')",i); + db.update("INSERT INTO ll_delivery_order VALUES(?,'C',?,'CODE:A',5,NULL,NULL,?)",i,i,"OUT-"+i); + db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",i,i); + db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",i,i); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",i,i,i); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,101,5,?,NULL)",i,i,i); + } + var mapper = (DeliverySourceEvidenceMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{DeliverySourceEvidenceMapper.class},(proxy,method,args) -> { + String sql=String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}",":$1"); + String name=method.getName(); String parameter=name.equals("source")?"sourceInboundId":name.equals("dispatchIds")||name.equals("lockSource")?"orderId":"dispatchId"; + var rows=named.query(sql,Map.of("customerId",args[0],parameter,args[1]),(r,n)-> switch(name) { + case "source","lockSource" -> new Source(r.getString("source_inbound_id"),r.getLong("order_id"),r.getLong("packages")); + case "dispatchIds" -> r.getLong(1); + case "deliveries" -> new Delivery(r.getLong("delivery_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("packages"),r.getObject("signed_packages",Long.class),r.getObject("signed_at",LocalDateTime.class),r.getObject("departed_at",LocalDateTime.class)); + case "picks" -> new Pick(r.getLong("attribution_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("order_id"),r.getLong("packages"),r.getObject("receipt_id",Long.class),r.getObject("return_receipt_id",Long.class),r.getObject("return_ledger_id",Long.class),r.getObject("return_packages",Long.class)); + case "returns" -> new Returned(r.getLong("ledger_id"),r.getLong("delivery_id"),r.getLong("order_id"),r.getLong("packages"),r.getObject("received_at",LocalDateTime.class)); + default -> throw new IllegalStateException(name); + }); + return name.equals("source")||name.equals("lockSource") ? rows.isEmpty()?null:rows.get(0) : rows; + }); + var query=new SourceDeliveryCompletionQuery(mapper); + var manager=new DataSourceTransactionManager(ds); + var sourceLocked=new CountDownLatch(1); var secondDeliveryLocked=new CountDownLatch(1); + var pool=Executors.newFixedThreadPool(2); + try { + var first=pool.submit(()->transaction(manager).execute(status->{ + db.update("UPDATE ll_delivery_order SET signed_packages=5,signed_at='2026-10-03 03:00:00' WHERE id=1"); + mapper.lockSource("C",101); sourceLocked.countDown(); await(secondDeliveryLocked); + return query.prove("C","S"); + })); + var second=pool.submit(()->{ await(sourceLocked); return transaction(manager).execute(status->{ + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE signed_at IS NOT NULL",Integer.class)).isZero(); + db.update("UPDATE ll_delivery_order SET signed_packages=5,signed_at='2026-10-03 03:01:00' WHERE id=2"); + secondDeliveryLocked.countDown(); return query.prove("C","S"); + }); }); + assertThat(first.get(8,TimeUnit.SECONDS).fullySigned()).isFalse(); + assertThat(second.get(8,TimeUnit.SECONDS).fullySigned()).isTrue(); + } finally { pool.shutdownNow(); } + } + private static TransactionTemplate transaction(DataSourceTransactionManager manager) { + var tx=new TransactionTemplate(manager); tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); return tx; + } + private static void await(CountDownLatch latch) { + try { if(!latch.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("Concurrency checkpoint timed out"); } + catch(InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java new file mode 100644 index 000000000..7dc39fbef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class SourceDeliveryCompletionPolicyTest { + private final LocalDateTime departure = LocalDateTime.of(2026, 10, 3, 1, 0); + private final LocalDateTime signature = departure.plusHours(2); + private final List picks = List.of(new Pick(1,10,"CODE:A",101,4,1L,null,null,null), new Pick(2,10,"CODE:A",102,6,2L,null,null,null)); + private Delivery delivery(long signed) { return new Delivery(20,10,"CODE:A",10,signed,signature,departure); } + private Result evaluate(long order, long original, List deliveries, List facts, List returns) { + return SourceDeliveryCompletionPolicy.evaluate(new Source("SOURCE-" + order, order, original), deliveries, facts, returns, true); + } + @Test void onlyReturnedSourceWaitsWhileOtherSourceIsProvenFullySigned() { + var returns = List.of(new Returned(30,20,101,2,departure.plusHours(1))); + assertThat(evaluate(101,4,List.of(delivery(8)),picks,returns)).satisfies(r -> { + assertThat(r.provenSignedPackages()).isEqualTo(2); assertThat(r.fullySigned()).isFalse(); assertThat(r.unresolvedReasons()).isEmpty(); }); + assertThat(evaluate(102,6,List.of(delivery(8)),picks,returns).fullySigned()).isTrue(); + } + @Test void postSignatureReturnDoesNotEraseHistoricSignature() { + assertThat(evaluate(101,4,List.of(delivery(10)),picks,List.of(new Returned(30,20,101,2,signature.plusHours(1)))).fullySigned()).isTrue(); + } + @Test void laterRedeliveryOfPreviouslyUnsignedBoxesCompletesSource() { + var deliveries = List.of(delivery(8), new Delivery(21,11,"CODE:A",2,2L,signature.plusDays(1),signature.plusHours(3))); + var allPicks = List.of(picks.get(0),picks.get(1),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L)); + assertThat(evaluate(101,4,deliveries,allPicks,List.of(new Returned(30,20,101,2,departure.plusHours(1)))).fullySigned()).isTrue(); + } + @Test void totalSignatureCannotBeProportionallyAssignedWhenReturnSourceIsMissing() { + var r = evaluate(102,6,List.of(delivery(8)),picks,List.of()); + assertThat(r.fullySigned()).isFalse(); assertThat(r.unresolvedReasons()).contains("PRE_SIGNATURE_RETURN_UNRESOLVED:20"); + } + @Test void equalTimestampRequiresReconciliation() { + assertThat(evaluate(102,6,List.of(delivery(8)),picks,List.of(new Returned(30,20,101,2,signature))).unresolvedReasons()).isNotEmpty(); + } + @Test void duplicateLedgerAndIncompleteHistoryNeverProveFullSignature() { + var returned = new Returned(30,20,101,1,departure.plusHours(1)); + assertThat(evaluate(102,6,List.of(delivery(8)),picks,List.of(returned,returned)).fullySigned()).isFalse(); + assertThat(SourceDeliveryCompletionPolicy.evaluate(new Source("S",102,6),List.of(delivery(10)),picks,List.of(),false).fullySigned()).isFalse(); + } + @Test void ambiguousDestinationOrMissingPickCoverageBlocksCompletion() { + assertThat(evaluate(102,6,List.of(delivery(10),new Delivery(21,10,"CODE:A",10,10L,signature,departure)),picks,List.of()).fullySigned()).isFalse(); + assertThat(evaluate(102,6,List.of(delivery(10)),List.of(picks.get(1)),List.of()).fullySigned()).isFalse(); + } + @Test void postSignatureRedeliveryCannotDoubleCountOriginalBoxes() { + assertThat(evaluate(101,4,List.of(delivery(10),new Delivery(21,11,"CODE:A",2,2L,signature.plusDays(1),signature.plusHours(3))), + List.of(picks.get(0),picks.get(1),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L)),List.of()).unresolvedReasons()).contains("RETURN_PICK_ORIGIN_UNRESOLVED:3"); + } + @Test void postSignatureReturnRedeliveryCannotFillTheNeverShippedOriginalRemainder() { + var deliveries=List.of(new Delivery(20,10,"CODE:A",6,6L,signature,departure), + new Delivery(21,11,"CODE:A",4,4L,signature.plusDays(1),signature.plusHours(3))); + var facts=List.of(new Pick(1,10,"CODE:A",101,6,1L,null,null,null), + new Pick(2,11,"CODE:A",101,4,null,300L,30L,4L)); + var result=evaluate(101,10,deliveries,facts,List.of(new Returned(30,20,101,4,signature.plusHours(1)))); + assertThat(result.provenSignedPackages()).isEqualTo(6); + assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).isEmpty(); + } + @Test void missingReceiptOriginCannotSilentlyBecomeFreshCargo() { + var facts=List.of(new Pick(1,10,"CODE:A",101,4,null,null,null,null),picks.get(1)); + assertThat(evaluate(101,4,List.of(delivery(10)),facts,List.of()).unresolvedReasons()).contains("PICK_RECEIPT_ORIGIN_UNRESOLVED"); + } + @Test void unrelatedDestinationMissingEvidenceDoesNotBlockProvenSource() { + var result=evaluate(101,4,List.of(new Delivery(20,10,"CODE:A",4,4L,signature,departure), + new Delivery(21,10,"CODE:B",6,null,null,departure)),List.of(picks.get(0)),List.of()); + assertThat(result.fullySigned()).isTrue();assertThat(result.unresolvedReasons()).isEmpty(); + } + @Test void completeTwoHopPreSignatureReturnsContributeOnlyNewlySignedBoxes() { + var ds=List.of(new Delivery(20,10,"CODE:A",4,2L,departure.plusHours(2),departure), + new Delivery(21,11,"CODE:A",2,1L,departure.plusHours(5),departure.plusHours(3)), + new Delivery(22,12,"CODE:A",1,1L,departure.plusHours(8),departure.plusHours(6))); + var ps=List.of(picks.get(0),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L), + new Pick(4,12,"CODE:A",101,1,null,400L,40L,1L)); + var rs=List.of(new Returned(30,20,101,2,departure.plusHours(1)),new Returned(40,21,101,1,departure.plusHours(4))); + var result=evaluate(101,4,ds,ps,rs); + assertThat(result.fullySigned()).isTrue();assertThat(result.provenSignedPackages()).isEqualTo(4); + } + @Test void repeatedReturnOfAlreadySignedCohortRemainsNonContributing() { + var ds=List.of(new Delivery(20,10,"CODE:A",6,6L,departure.plusHours(2),departure), + new Delivery(21,11,"CODE:A",4,2L,departure.plusHours(6),departure.plusHours(4)), + new Delivery(22,12,"CODE:A",2,2L,departure.plusHours(9),departure.plusHours(7))); + var ps=List.of(new Pick(1,10,"CODE:A",101,6,1L,null,null,null),new Pick(2,11,"CODE:A",101,4,null,300L,30L,4L), + new Pick(3,12,"CODE:A",101,2,null,400L,40L,2L)); + var rs=List.of(new Returned(30,20,101,4,departure.plusHours(3)),new Returned(40,21,101,2,departure.plusHours(5))); + var result=evaluate(101,10,ds,ps,rs); + assertThat(result.fullySigned()).isFalse();assertThat(result.provenSignedPackages()).isEqualTo(6); + assertThat(result.unresolvedReasons()).isEmpty(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java new file mode 100644 index 000000000..53559c431 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import com.baiyue.lastleg.delivery.service.SourceDeliveryCompletionQuery; +import org.apache.ibatis.exceptions.TooManyResultsException; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SourceDeliveryCompletionQueryTest { + private DeliverySourceEvidenceMapper mapper; + private SourceDeliveryCompletionQuery query; + private TransactionTemplate tx; + private final LocalDateTime departure = LocalDateTime.of(2026,10,3,1,0); + @BeforeEach void setup() { + mapper = mock(DeliverySourceEvidenceMapper.class); + var manager = new DataSourceTransactionManager(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID(),"sa","")); + tx = new TransactionTemplate(manager); tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED); + var factory = new ProxyFactory(new SourceDeliveryCompletionQuery(mapper)); factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource())); + query = (SourceDeliveryCompletionQuery)factory.getProxy(); + when(mapper.source("C","S")).thenReturn(new Source("S",101,4)); + when(mapper.lockSource("C",101)).thenReturn(new Source("S",101,4)); + when(mapper.dispatchIds("C",101)).thenReturn(List.of(11L,10L)); + when(mapper.deliveries("C",10)).thenReturn(List.of(new Delivery(20,10,"CODE:A",10,8L,departure.plusHours(2),departure))); + when(mapper.picks("C",10)).thenReturn(List.of(new Pick(1,10,"CODE:A",101,4,1L,null,null,null),new Pick(2,10,"CODE:A",102,6,2L,null,null,null))); + when(mapper.returns("C",10)).thenReturn(List.of(new Returned(30,20,101,2,departure.plusHours(1)))); + when(mapper.deliveries("C",11)).thenReturn(List.of(new Delivery(21,11,"CODE:A",2,2L,departure.plusDays(1),departure.plusHours(3)))); + when(mapper.picks("C",11)).thenReturn(List.of(new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L))); + when(mapper.returns("C",11)).thenReturn(List.of()); + } + @Test void requiresExistingWriteTransaction() { + assertThatThrownBy(() -> query.prove("C","S")).isInstanceOf(IllegalTransactionStateException.class); + tx.setReadOnly(true); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(mapper); + } + @Test void collectsEveryDispatchAndAllCoShippedSourcesBeforeProof() { + var result = tx.execute(s -> query.prove("C","S")); + assertThat(result.fullySigned()).isTrue(); assertThat(result.provenSignedPackages()).isEqualTo(4); + var order = inOrder(mapper); + order.verify(mapper).source("C","S"); order.verify(mapper).lockSource("C",101); order.verify(mapper).dispatchIds("C",101); + for (long id : List.of(10L,11L)) { + order.verify(mapper).deliveries("C",id); order.verify(mapper).picks("C",id); order.verify(mapper).returns("C",id); + } + order.verifyNoMoreInteractions(); + } + @Test void missingSourceNeverMarksHistoryComplete() { + when(mapper.source("C","S")).thenReturn(null); + var result = tx.execute(s -> query.prove("C","S")); + assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).contains("HISTORY_NOT_COMPLETE"); + verify(mapper,never()).dispatchIds(anyString(),anyLong()); + } + @Test void laterDispatchFailurePropagatesWithoutReturningEarlierPartialProof() { + when(mapper.returns("C",11)).thenThrow(new org.springframework.dao.DataAccessResourceFailureException("offline")); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(org.springframework.dao.DataAccessResourceFailureException.class); + } + @Test void missingDispatchEvidenceIsUnresolved() { + when(mapper.picks("C",11)).thenReturn(List.of()); + var result = tx.execute(s -> query.prove("C","S")); + assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).contains("HISTORY_NOT_COMPLETE"); + } + @Test void brokenSourceUniquenessNeverSelectsOneOrSumsRows() { + when(mapper.source("C","S")).thenThrow(new TooManyResultsException("duplicate source")); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(TooManyResultsException.class); + verify(mapper,never()).dispatchIds(anyString(),anyLong()); + } + @Test void wrongDispatchReturnIsRejected() { + when(mapper.returns("C",11)).thenReturn(List.of(new Returned(50,20,101,1,departure.plusHours(4)))); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class); + } + @Test void repeatableReadAndUnspecifiedIsolationAreRejectedBeforeReadingEvidence() { + for(int isolation : List.of(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ,org.springframework.transaction.TransactionDefinition.ISOLATION_DEFAULT)) { + tx.setIsolationLevel(isolation); + assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class).hasMessageContaining("READ_COMMITTED"); + } + verifyNoInteractions(mapper); + } + @Test void multipleSourcesLockNumericOrderIdsBeforeAnyHistoryRead() { + when(mapper.source("C","Z")).thenReturn(new Source("Z",50,2)); + when(mapper.lockSource("C",50)).thenReturn(new Source("Z",50,2)); + when(mapper.dispatchIds("C",50)).thenReturn(List.of()); + tx.execute(s -> query.proveAll("C",List.of("S","Z"))); + var order=inOrder(mapper); + order.verify(mapper).source("C","S"); order.verify(mapper).source("C","Z"); + order.verify(mapper).lockSource("C",50); order.verify(mapper).lockSource("C",101); + order.verify(mapper).dispatchIds("C",50); order.verify(mapper).dispatchIds("C",101); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 8b5778182..7eae1933b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -34,7 +34,7 @@ class DeliveryCompletedOutboxServiceTest { delivery.setPackageCount(10); delivery.setSignedPackages(10); service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"), - order(12, "102", "202", "IN-2", "ACY2")), Instant.parse("2026-09-15T08:00:00Z")); + order(12, "102", "202", "IN-2", "ACY2")), proofs(), Instant.parse("2026-09-15T08:00:00Z")); ArgumentCaptor rows = ArgumentCaptor.forClass(LastlegEventOutbox.class); verify(mapper, times(2)).insert(rows.capture()); @@ -62,7 +62,7 @@ class DeliveryCompletedOutboxServiceTest { relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION"); relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1"); - service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z")); + service.publish(delivery, List.of(relabel), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z")); verifyNoInteractions(mapper); } @@ -79,7 +79,7 @@ class DeliveryCompletedOutboxServiceTest { transfer.setId(22L); transfer.setOrderType("WAREHOUSE_TRANSFER"); transfer.setCustomerId("88"); transfer.setSourceInboundId("WAREHOUSE_TRANSFER:WT-1"); - service.publish(delivery, List.of(transfer), Instant.parse("2026-09-15T08:00:00Z")); + service.publish(delivery, List.of(transfer), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z")); verifyNoInteractions(mapper); } @@ -96,7 +96,7 @@ class DeliveryCompletedOutboxServiceTest { local.setId(23L); local.setOrderType("RETURN_RELABEL"); local.setSourceRegion("CUSTOMER"); local.setCustomerId("88"); local.setSourceInboundId("RETURN-1"); - service.publish(delivery, List.of(local), Instant.parse("2026-09-15T08:00:00Z")); + service.publish(delivery, List.of(local), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z")); verifyNoInteractions(mapper); } @@ -108,16 +108,20 @@ class DeliveryCompletedOutboxServiceTest { var sources = List.of(order(11, "101", "201", "IN-1", "ONT8")); for (Integer invalid : java.util.Arrays.asList(null, 0, -1, 11)) { delivery.setSignedPackages(invalid); - assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION); + assertThat(service.publish(delivery, sources, java.util.Map.of(), Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION); } delivery.setSignedPackages(6); - assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION); + assertThat(service.publish(delivery, sources, java.util.Map.of(), Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION); verifyNoInteractions(mapper); } + private java.util.Map proofs() { + return java.util.Map.of("101",new com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.Result("101",5,5,true,List.of()), + "102",new com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.Result("102",5,5,true,List.of())); + } private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { LastlegOrder value = new LastlegOrder(); value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); - value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode); + value.setSourceRegion("FIRSTLEG"); value.setPackageCount(5); value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode); value.setSourceOrderId(orderId); value.setSourceOrderNo("FL-" + orderId); value.setRootOrderId(orderId); value.setFbaWarehouseCode(fba); return value; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index d0057df50..2fbfbf6b8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -33,6 +33,8 @@ class DeliveryDocumentServiceTest { @Mock LastlegOrderMapper lastlegOrders; @Mock DeliveryCompletedOutboxService deliveryCompletedOutbox; @Mock CustomerVisitService customerVisits; + @Mock SourceDeliveryCompletionQuery sourceCompletion; + @Mock DeliverySourceEvidenceMapper sourceEvidence; DeliveryDocumentService service; DeliveryOrder order; @BeforeEach void setUp() { @@ -46,6 +48,8 @@ class DeliveryDocumentServiceTest { ReflectionTestUtils.setField(service,"lastlegOrders",lastlegOrders); ReflectionTestUtils.setField(service,"deliveryCompletedOutbox",deliveryCompletedOutbox); ReflectionTestUtils.setField(service,"customerVisits",customerVisits); + ReflectionTestUtils.setField(service,"sourceCompletion",sourceCompletion); + ReflectionTestUtils.setField(service,"sourceEvidence",sourceEvidence); order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); order.setOutboundNo("OUT-1"); order.setPackageCount(10); @@ -142,6 +146,7 @@ class DeliveryDocumentServiceTest { when(orders.lockByNo("100","DL-1")).thenReturn(order); when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); when(lastlegOrders.selectList(any())).thenReturn(List.of(related)); + when(lastlegOrders.selectById(20L)).thenReturn(related); service.save("100","DL-1","9",request(0,"receipt",List.of(new Binding(RECEIPT,List.of(13L))))); @@ -149,7 +154,7 @@ class DeliveryDocumentServiceTest { assertThat(order.getSignedAt()).isNotNull(); assertThat(related.getSubOrderStatus()).isEqualTo("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED"); verify(lastlegOrders).updateById(related); - verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), any()); + verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), anyMap(), any()); verify(customerVisits).ensureForCompletedDelivery(eq(order), any()); ArgumentCaptor operation = ArgumentCaptor.forClass(DeliveryOperation.class); verify(operations, times(2)).insert(operation.capture()); @@ -215,6 +220,20 @@ class DeliveryDocumentServiceTest { verify(files,never()).download(anyString(),anyLong(),any(),anyLong()); verifyNoInteractions(revisions,operations); } + @Test void pendingSourceAuditUsesBusinessLabelAndChineseReasonWithoutInternalIdentifiers() { + order.setStatus("DELIVERING");order.setDispatchOrderId(50L);order.setDeliveryGroupKey("CODE:A"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + var outbound=new OutboundOrder();outbound.setStatus("OUTBOUNDED");when(outboundOrders.findByNo("100","OUT-1")).thenReturn(outbound); + var tail=new LastlegOrder();tail.setId(20L);tail.setCustomerId("100");tail.setSourceRegion("FIRSTLEG");tail.setSourceInboundId("S"); + when(sourceEvidence.picks("100",50L)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(123456,50,"CODE:A",20,10,1L,null,null,null))); + when(lastlegOrders.selectBatchIds(anyCollection())).thenReturn(List.of(tail));when(lastlegOrders.selectById(20L)).thenReturn(tail); + when(sourceCompletion.proveAll(eq("100"),anyCollection())).thenReturn(java.util.Map.of("S",new SourceDeliveryCompletionPolicy.Result("S",10,6,false,List.of("DELIVERY_SOURCE_COVERAGE_UNRESOLVED:987654")))); + service.save("100","DL-1","9",request(0,"audit",List.of(new Binding(RECEIPT,List.of(13L))))); + var operationsSaved=ArgumentCaptor.forClass(DeliveryOperation.class);verify(operations,times(3)).insert(operationsSaved.capture()); + var text=operationsSaved.getAllValues().stream().filter(o->"FIRSTLEG_COMPLETION_PENDING".equals(o.getOperationNode())).findFirst().orElseThrow().getResult(); + assertThat(text).contains("—","6/10","来源证据尚未核齐").doesNotContain("987654","123456","UNRESOLVED","null"); + } @Test void remainingSignatureFreezesActualQuantityAndPersistsDomesticWritebackBlocker() { order.setStatus("RETURN_PENDING"); when(orders.lockByNo("100", "DL-1")).thenReturn(order); @@ -222,7 +241,8 @@ class DeliveryDocumentServiceTest { var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED"); when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound); when(operations.returnReceipts("100", 10L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 2L, 3L, 4, LocalDateTime.now().minusMinutes(1)))); - when(deliveryCompletedOutbox.publish(eq(order), anyList(), any())).thenReturn(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION); + var tail=new LastlegOrder(); tail.setId(20L); tail.setCustomerId("100"); tail.setSourceRegion("FIRSTLEG"); tail.setSourceInboundId("S"); tail.setInboundCode("IN-S"); + when(lastlegOrders.selectList(any())).thenReturn(List.of(tail)); service.save("100", "DL-1", "9", request(0, "residual-signature", List.of(new Binding(RECEIPT, List.of(13L))))); assertThat(order.getPackageCount()).isEqualTo(10); assertThat(order.getSignedPackages()).isEqualTo(6); assertThat(order.getSignedAt()).isNotNull(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index 4fb109ff9..141d1c508 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -54,7 +54,7 @@ class SmartDispatchControllerTest { void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( List.of(10L, 11L), "APT-1", "SUCCESS", "PENDING"); - when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); + when(service.resolveCustomerScopeForSources(List.of(10L, 11L), List.of())).thenReturn("100"); controller.preArrange(platform(), request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java index 3a7bc627f..fbcf9a294 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -32,6 +32,7 @@ class DispatchPickOrderAttributionServiceTest { @Mock private DispatchPdaPickOrderMapper attributions; @Mock private LastlegOrderMapper orders; @Mock private LastlegOrderStatusHistoryMapper history; + @Mock private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; @Test void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException { @@ -108,11 +109,58 @@ class DispatchPickOrderAttributionServiceTest { verify(orders,never()).increasePicked(anyString(),anyLong(),anyLong()); } + @Test void returnedTwoPackagesHaveTheirOwnReceiptAttributionAndRejectExcess() { + var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); + receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L); + when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt)); + when(returnReceipts.lock("t1", 201L)).thenReturn(receipt); + when(orders.increasePicked("t1", 101L, 2L)).thenReturn(1); + var service = service(); + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 2L)), + service.attribute("t1", pick(81L, 91L, 2L, 0L), null, 201L)); + var capture = ArgumentCaptor.forClass(DispatchPdaPickOrder.class); + verify(attributions).insert(capture.capture()); + assertEquals(201L, capture.getValue().getReturnReceiptId()); assertEquals(null, capture.getValue().getReceiptId()); + when(returnReceipts.picked("t1", 201L)).thenReturn(2L); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.attribute("t1", pick(82L, 91L, 1L, 0L), null, 201L)); + } + + @Test void selectedReturnCannotUseFallbackOrLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() { + var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); + receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L); + when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt)); + when(returnReceipts.lock("t1", 201L)).thenReturn(receipt); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 101L, 201L)); + verify(attributions, never()).insert(any(DispatchPdaPickOrder.class)); + verify(orders, never()).increasePicked(anyString(), anyLong(), anyLong()); + verify(attributions, never()).availableReceipts(anyString(), anyLong()); + } + + @Test void departureLocksSourceOrdersInNumericOrderInsteadOfPickOrder() { + var high = new DispatchPdaPickOrder(); high.setOrderId(10L); high.setPackages(1L); + var low = new DispatchPdaPickOrder(); low.setOrderId(2L); low.setPackages(1L); + when(attributions.listPick("t1", 81L)).thenReturn(List.of(high, low)); + for (long id : List.of(2L, 10L)) { + var order = new LastlegOrder(); order.setId(id); order.setPackageCount(1); + order.setPickedPackageCount(1); order.setShippedPackageCount(0); + when(orders.lockById("t1", id)).thenReturn(order); + when(orders.transferPickedToShipped("t1", id, 1L, "OUT1")).thenReturn(1); + when(orders.markDispatchOutbound("t1", id)).thenReturn(1); + } + service().depart("t1", "OUT1", "DP1", List.of(pick(81L, 91L, 2L, 0L)), LocalDateTime.now()); + var sequence = inOrder(orders); + sequence.verify(orders).lockById("t1", 2L); + sequence.verify(orders).lockById("t1", 10L); + } + private DispatchPickOrderAttributionService service() { DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService(); ReflectionTestUtils.setField(service, "attributions", attributions); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts); return service; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java new file mode 100644 index 000000000..e38d09d47 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +class ReturnedDispatchAllowancePersistenceTest { + @Test void realSqlAllowsOnlyTwoReturnedPackagesToDepartAgainWithoutRewritingOriginalTotals() throws Exception { + var db = database(); var named = new NamedParameterJdbcTemplate(db.getDataSource()); + String pick = sql("increasePicked", String.class, Long.class, long.class); + String depart = sql("transferPickedToShipped", String.class, Long.class, long.class, String.class); + var args = Map.of("customerId", "A", "id", 1L, "packages", 2L, "outboundNo", "OUT-NEW"); + assertThat(named.update(pick, args)).isZero(); + db.update("INSERT INTO ll_return_stock_receipt VALUES(10,'A',1,100,2)"); + assertThat(named.update(pick, args)).isEqualTo(1); + assertThat(named.update(pick, args)).isZero(); + assertThat(named.update(depart, args)).isEqualTo(1); + assertThat(named.update(depart, args)).isZero(); + assertThat(db.queryForObject("SELECT package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT shipped_package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(4); + assertThat(db.queryForObject("SELECT picked_package_count FROM ll_order WHERE id=1", Integer.class)).isZero(); + assertThatThrownBy(() -> db.update("INSERT INTO ll_return_stock_receipt VALUES(11,'A',1,100,2)")) + .isInstanceOf(org.springframework.dao.DuplicateKeyException.class); + assertThat(named.update(pick, Map.of("customerId", "OTHER", "id", 1L, "packages", 1L))).isZero(); + } + + @Test void concurrentRedispatchPicksCannotSpendTheSameReturnTwice() throws Exception { + var db = database(); db.update("INSERT INTO ll_return_stock_receipt VALUES(10,'A',1,100,2)"); + var tx = new TransactionTemplate(new DataSourceTransactionManager(db.getDataSource())); + var named = new NamedParameterJdbcTemplate(db.getDataSource()); + String pick = sql("increasePicked", String.class, Long.class, long.class); + var start = new CountDownLatch(1); var pool = Executors.newFixedThreadPool(2); + Callable attempt = () -> { + start.await(); + return tx.execute(status -> { + db.queryForObject("SELECT id FROM ll_order WHERE customer_id='A' AND id=1 FOR UPDATE", Long.class); + db.queryForObject("SELECT id FROM ll_return_stock_receipt WHERE customer_id='A' AND id=10 FOR UPDATE", Long.class); + return named.update(pick, Map.of("customerId", "A", "id", 1L, "packages", 2L)); + }); + }; + try { + var a = pool.submit(attempt); var b = pool.submit(attempt); start.countDown(); + assertThat(a.get(5, TimeUnit.SECONDS) + b.get(5, TimeUnit.SECONDS)).isEqualTo(1); + assertThat(db.queryForObject("SELECT picked_package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(2); + } finally { pool.shutdownNow(); } + } + private JdbcTemplate database() { + var db = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", "")); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY, customer_id VARCHAR(20), package_count INT,shipped_package_count INT,picked_package_count INT,detained INT,current_status VARCHAR(40),outbound_order_no VARCHAR(50),updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,UNIQUE(customer_id,inventory_ledger_id))"); + db.update("INSERT INTO ll_order(id,customer_id,package_count,shipped_package_count,picked_package_count,detained,current_status) VALUES(1,'A',2,2,0,0,'COMPLETED')"); + return db; + } + private String sql(String name, Class... args) throws Exception { + return String.join(" ", LastlegOrderMapper.class.getMethod(name,args).getAnnotation(Update.class).value()) + .replace("#{customerId}", ":customerId").replace("#{id}", ":id").replace("#{packages}", ":packages").replace("#{outboundNo}", ":outboundNo"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 63e740ef4..be1d5e874 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -37,6 +37,7 @@ import java.util.List; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -55,6 +56,7 @@ class DispatchServiceTest { @Mock DispatchBolMapper bolLines; @Mock DispatchPalletLockMapper locks; @Mock DispatchCandidateMapper candidates; + @Mock com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; @Mock DispatchDraftMapper drafts; @Mock DispatchDraftContainerMapper draftContainers; @Mock DispatchPdaTaskMapper pdaTasks; @@ -72,6 +74,7 @@ class DispatchServiceTest { ReflectionTestUtils.setField(service, "bolLines", bolLines); ReflectionTestUtils.setField(service, "locks", locks); ReflectionTestUtils.setField(service, "candidates", candidates); + ReflectionTestUtils.setField(service, "returnCandidates", returnCandidates); ReflectionTestUtils.setField(service, "drafts", drafts); ReflectionTestUtils.setField(service, "draftContainers", draftContainers); ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); @@ -497,6 +500,57 @@ class DispatchServiceTest { assertEquals("PLAN:11", plannedOnly.sourcePalletKey()); } + @Test void returnedReceiptPreArrangementUsesItsOwnReferenceAndTheRealTargetPalletLock() throws Exception { + var candidate = candidate(10L, 900L, "ONT8", 2); + candidate.planId(null); candidate.planPalletId(null); candidate.returnReceiptId(701L); candidate.returnReceiptNo("RSR-701"); + when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(candidate)); + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(), null, null, null, null, null, List.of(701L))); + var item = ArgumentCaptor.forClass(DispatchItem.class); verify(items).insert(item.capture()); + assertEquals(null, item.getValue().getPlanPalletId()); assertEquals(701L, item.getValue().getReturnReceiptId()); + assertEquals(2, item.getValue().getPackageCount()); + var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); verify(locks).insert(lock.capture()); + assertEquals("RECEIVED:900", lock.getValue().getActiveKey()); assertEquals(701L, lock.getValue().getReturnReceiptId()); + verify(candidates, never()).lockSelected(any(), any()); + var json = new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(candidate); + assertEquals("RETURN_RECEIPT", json.path("sourceType").asText()); + assertEquals("RSR-701", json.path("returnReceiptNo").asText()); assertTrue(json.path("planPalletId").isNull()); + } + + @Test void plannedAndReturnedSourcesCreateOneDispatchWithBothReferencesAndTheirExactQuantities() { + var planned = candidate(10L, 900L, "ONT8", 3); + var returned = candidate(11L, 901L, "ONT8", 2); + returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701"); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned)); + when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned)); + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L))); + var dispatch = ArgumentCaptor.forClass(DispatchOrder.class); verify(orders).insert(dispatch.capture()); + assertEquals(5, dispatch.getValue().getPackageCount()); assertEquals(2, dispatch.getValue().getPalletCount()); + var inserted = ArgumentCaptor.forClass(DispatchItem.class); + verify(items, org.mockito.Mockito.times(2)).insert(inserted.capture()); + var normalItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() == null).findFirst().orElseThrow(); + var returnItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() != null).findFirst().orElseThrow(); + assertEquals(10L, normalItem.getPlanPalletId()); assertEquals(3, normalItem.getPackageCount()); + assertEquals(null, returnItem.getPlanPalletId()); assertEquals(701L, returnItem.getReturnReceiptId()); assertEquals(2, returnItem.getPackageCount()); + assertEquals(normalItem.getDispatchOrderId(), returnItem.getDispatchOrderId()); + var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); + verify(locks, org.mockito.Mockito.times(2)).insert(lock.capture()); + assertEquals(java.util.Set.of("RECEIVED:900", "RECEIVED:901"), lock.getAllValues().stream() + .map(DispatchPalletLock::getActiveKey).collect(java.util.stream.Collectors.toSet())); + } + + @Test void mixedSourcesOnTheSamePhysicalPalletAreRejectedBeforeAnyDispatchOrLockWrite() { + var planned = candidate(10L, 900L, "ONT8", 3); + var returned = candidate(11L, 900L, "ONT8", 2); + returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701"); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned)); + when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned)); + var failure = assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L)))); + assertEquals(409, failure.getStatusCode().value()); assertTrue(failure.getReason().contains("同一实物托盘")); + verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class)); + verify(locks, never()).insert(any(DispatchPalletLock.class)); verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); + } + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { return candidate(planPalletId, warehousePalletId, destination, 12); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java new file mode 100644 index 000000000..fbfd62f09 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class ReturnDispatchCandidatePersistenceTest { + @Test void actualReturnOnANewPalletCanBeSelectedWithoutAnInboundPlanPallet() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(data); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,receipt_no VARCHAR(50),customer_id VARCHAR(20),order_id BIGINT,inventory_position_id BIGINT,inventory_balance_id BIGINT,exception_order_id BIGINT,received_packages BIGINT,received_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),customer_name VARCHAR(50),order_type VARCHAR(50),order_no VARCHAR(50),detained INT)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),pallet_id BIGINT,pallet_no VARCHAR(50),overseas_warehouse_id VARCHAR(50),available_packages BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_code VARCHAR(50),overseas_warehouse_name VARCHAR(50),overseas_warehouse_code VARCHAR(50))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT)"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),destination_type VARCHAR(20),destination_warehouse_code VARCHAR(50),destination_warehouse_name VARCHAR(50),delivery_address VARCHAR(100),postal_code VARCHAR(50),contact_name VARCHAR(50),contact_phone VARCHAR(50))"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),return_receipt_id BIGINT,packages BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pallet_lock(customer_id VARCHAR(20),source_pallet_key VARCHAR(50),status VARCHAR(20),expires_at TIMESTAMP,draft_token VARCHAR(50))"); + db.update("INSERT INTO ll_order VALUES(1,'A','客户A','FIRST_LEG','LM-1',0)"); + db.update("INSERT INTO ll_inventory_position VALUES(2,'A',900,'RETURN-PALLET','92',2)"); + db.update("INSERT INTO ll_inventory_balance VALUES(3,'A','IN-1','海外仓','WH')"); + db.update("INSERT INTO ll_exception_order VALUES(4,'A',5)"); + db.update("INSERT INTO ll_delivery_order VALUES(5,'A','FBA','ONT8','ONT8',NULL,NULL,NULL,NULL)"); + db.update("INSERT INTO ll_return_stock_receipt VALUES(6,'RSR-6','A',1,2,3,4,2,CURRENT_TIMESTAMP)"); + var config = new MybatisConfiguration(); config.addMapper(ReturnDispatchCandidateMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ReturnDispatchCandidateMapper.class); + var rows = mapper.available("A", "RSR-6", "ONT8", null); + assertThat(rows).hasSize(1); var row = rows.get(0); + assertThat(row.packageCount()).isEqualTo(2); assertThat(row.planPalletId()).isNull(); + assertThat(row.sourceType()).isEqualTo("RETURN_RECEIPT"); assertThat(row.returnReceiptNo()).isEqualTo("RSR-6"); + assertThat(row.warehousePalletId()).isEqualTo(900); assertThat(row.grossWeightKg()).isNull(); + assertThat(mapper.lockSelected("A", List.of(6L))).hasSize(1); + assertThat(mapper.lockSelected("B", List.of(6L))).isEmpty(); + db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RECEIVED:900','ACTIVE',NULL,'another-draft')"); + assertThat(mapper.available("A", null, null, "this-draft")).isEmpty(); + db.update("DELETE FROM ll_dispatch_pallet_lock"); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)"); + // Even if the position also has unrelated available stock, this receipt contributes only its unspent one box. + db.update("UPDATE ll_inventory_position SET available_packages=8 WHERE id=2"); + var partial = mapper.available("A", null, null, null); + assertThat(partial).hasSize(1); assertThat(partial.get(0).packageCount()).isEqualTo(1); + assertThat(mapper.lockSelected("A", List.of(6L)).get(0).packageCount()).isEqualTo(1); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)"); + assertThat(mapper.available("A", null, null, null)).isEmpty(); + assertThat(mapper.lockSelected("A", List.of(6L))).isEmpty(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 7a7110092..89733caf6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -51,6 +51,7 @@ class InventoryCommandServiceTest { @Mock InventoryLedgerMapper ledgers; @Mock InventoryOperationMapper operations; @Mock InventoryCommandMapper commands; + @Mock com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; @Mock WarehouseResourceGateway warehouseGateway; private InventoryCommandService service; @@ -63,6 +64,10 @@ class InventoryCommandServiceTest { ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts); + lenient().when(returnReceipts.owner(any(), any(), any(), any(), any())).thenReturn( + new com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnOwner(1L, 51L, 101L)); + lenient().when(returnReceipts.lockOrder(any(), any())).thenAnswer(call -> call.getArgument(1)); ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); lenient().when(operations.findIdempotency(any(), any())).thenReturn(null); @@ -385,6 +390,12 @@ class InventoryCommandServiceTest { assertEquals("RETURN_STOCK_IN", ledger.getValue().getBusinessType()); assertEquals("RETURN_SOURCE:301:201", ledger.getValue().getSourceEventId()); assertEquals("OUT-1", ledger.getValue().getOutboundOrderNo()); + ArgumentCaptor returned = ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.entity.ReturnStockReceipt.class); + verify(returnReceipts).insert(returned.capture()); + assertEquals(ledger.getValue().getId(), returned.getValue().getInventoryLedgerId()); + assertEquals(51L, returned.getValue().getOrderId()); + assertEquals(3L, returned.getValue().getReceivedPackages()); + assertEquals(target.getValue().getId(), returned.getValue().getInventoryPositionId()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java index 4da37378b..27e4c0ba8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java @@ -58,7 +58,8 @@ class InventoryHoldConcurrencyPersistenceTest { jdbc = new JdbcTemplate(ds); for (Class entity : List.of(InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, InventoryOperation.class, LastlegOrder.class, LastlegOrderCommandRecord.class, - LastlegOrderHoldHistory.class, LastlegOrderOperationLog.class, InboundStockReceipt.class, DispatchPdaPickOrder.class)) { + LastlegOrderHoldHistory.class, LastlegOrderOperationLog.class, InboundStockReceipt.class, + ReturnStockReceipt.class, DispatchPdaPickOrder.class)) { String columns = Arrays.stream(entity.getDeclaredFields()) .filter(f -> !Modifier.isStatic(f.getModifiers())) .filter(f -> f.getAnnotation(TableField.class) == null || f.getAnnotation(TableField.class).exist()) @@ -74,7 +75,8 @@ class InventoryHoldConcurrencyPersistenceTest { for (Class mapper : List.of(InventoryBalanceMapper.class, InventoryPositionMapper.class, InventoryLedgerMapper.class, InventoryOperationMapper.class, InventoryCommandMapper.class, LastlegOrderMapper.class, LastlegOrderCommandRecordMapper.class, LastlegOrderHoldHistoryMapper.class, - LastlegOrderOperationLogMapper.class, DispatchPdaPickOrderMapper.class)) config.addMapper(mapper); + LastlegOrderOperationLogMapper.class, DispatchPdaPickOrderMapper.class, + ReturnStockReceiptMapper.class)) config.addMapper(mapper); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); var session = new SqlSessionTemplate(factory.getObject()); var manager = new DataSourceTransactionManager(ds); tx = new TransactionTemplate(manager); @@ -91,6 +93,7 @@ class InventoryHoldConcurrencyPersistenceTest { var sourceLocks = new DispatchPickOrderAttributionService(); ReflectionTestUtils.setField(sourceLocks,"orders",orderMapper); ReflectionTestUtils.setField(sourceLocks,"attributions",session.getMapper(DispatchPdaPickOrderMapper.class)); + ReflectionTestUtils.setField(sourceLocks,"returnReceipts",session.getMapper(ReturnStockReceiptMapper.class)); attribution = proxy(sourceLocks,manager); var target = new LastlegOrderCommandService(); ReflectionTestUtils.setField(target,"orders",orderMapper); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 2bd6e285a..63ac026b0 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 152]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 153]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|152) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|153) AS initialized_tables;$/) } }) @@ -44,6 +44,19 @@ test('车次直接成本按车次幂等并保留独立币种与不可变更正 assert.match(history, /snapshot_json JSON NOT NULL/i) }) +test('退回实收按真实流水唯一保存,重派拣货必须明确唯一来源', () => { + const receipt = table(overseas, 'baiyue_lastleg', 'll_return_stock_receipt') + assert.match(receipt, /UNIQUE KEY uk_return_stock_receipt_ledger\(customer_id,inventory_ledger_id\)/i) + assert.match(receipt, /UNIQUE KEY uk_return_stock_receipt_no\(customer_id,receipt_no\)/i) + assert.match(receipt, /CHECK\(received_packages>0\)/i) + for (const name of ['ll_dispatch_item', 'll_dispatch_pallet_lock', 'll_dispatch_pda_pick_order']) { + assert.match(table(overseas, 'baiyue_lastleg', name), /return_receipt_id BIGINT NULL/i) + } + const pick = table(overseas, 'baiyue_lastleg', 'll_dispatch_pda_pick_order') + assert.match(pick, /\breceipt_id BIGINT NULL/i) + assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL AND return_receipt_id IS NULL\) OR \(receipt_id IS NULL AND return_receipt_id IS NOT NULL\)\)/i) +}) + test('车辆报价与实测记录保留业务去重和不可变版本', () => { const booking = table(overseas, 'baiyue_lastleg', 'll_fleet_external_booking') assert.match(booking, /quote_source_json JSON NULL/i)