From 1b27d7e6c22403e6cddefef34196a89010538942 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 29 Oct 2026 13:36:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 3 + ...order-budget-and-procurement-rejection.sql | 29 ++++ deploy/sql/海外区域统一增量-MySQL8.sql | 25 +++- docs/business-index.md | 0 lastleg-service/docs/procurement-workflow.md | 11 +- .../mapper/CargoDiscardRelabelMapper.java | 80 ++++++++++ .../pda/service/PdaCargoDiscardService.java | 9 +- .../CargoDiscardRelabelRepository.java | 34 +++++ .../CargoDiscardRelabelStockService.java | 123 +++++++++++++++ .../service/CargoDiscardStockService.java | 51 +++++-- .../mapper/DispatchCandidateMapper.java | 2 + .../transport/TransportTaskModels.java | 5 +- .../transport/TransportTaskService.java | 6 +- .../lastleg/fleet/driver/DriverModels.java | 8 +- .../lastleg/fleet/driver/DriverService.java | 6 + .../fleet/port/DriverAvailability.java | 9 ++ .../trip/DriverAvailabilityRepository.java | 15 ++ .../fleet/trip/FleetResourceMapper.java | 7 + ...MerchantOverseasProcurementController.java | 4 +- ...MerchantProcurementWorkflowController.java | 6 + .../ProcurementWorkflowController.java | 6 + .../dto/ProcurementWorkflowDtos.java | 6 +- .../entity/OverseasProcurementOrder.java | 2 + .../mapper/ProcurementWorkflowMapper.java | 3 + .../ProcurementWorkflowRepository.java | 2 + .../service/ProcurementWorkflowService.java | 30 +++- .../mapper/RelabelDispatchSourceMapper.java | 4 + .../relabel/mapper/RelabelItemMapper.java | 4 + .../RelabelDispatchSourceRepository.java | 7 +- .../relabel/service/RelabelBindingGuard.java | 3 +- .../MerchantUsTruckOrderController.java | 7 +- .../trucking/dto/UsTruckOrderRequests.java | 8 +- .../trucking/service/UsTruckOrderService.java | 8 +- .../budget/MerchantOrderBudgetController.java | 40 +++++ .../lastleg/wallet/budget/OrderBudget.java | 26 ++++ .../wallet/budget/OrderBudgetMapper.java | 29 ++++ .../wallet/budget/OrderBudgetRepository.java | 23 +++ .../wallet/budget/OrderBudgetService.java | 140 ++++++++++++++++++ .../src/main/resources/db/lastleg.sql | 25 +++- .../openapi/cargo-discard-photos-v1.yaml | 12 +- .../openapi/dispatch-transport-tasks-v1.yaml | 2 +- .../resources/openapi/fleet-drivers-v1.yaml | 1 + .../resources/openapi/order-budgets-v1.yaml | 56 +++++++ .../openapi/overseas-business-v1.yaml | 12 +- .../openapi/procurement-workflow-v1.yaml | 46 +++++- .../resources/openapi/storage-rent-v1.yaml | 2 +- .../CargoDiscardRelabelMapperTest.java | 57 +++++++ .../service/PdaCargoDiscardServiceTest.java | 12 +- .../CargoDiscardRelabelStockServiceTest.java | 47 ++++++ .../service/CargoDiscardStockServiceTest.java | 1 + .../DispatchMergedRelabelPersistenceTest.java | 2 +- .../fleet/driver/DriverPersistenceTest.java | 16 ++ .../ProcurementWorkflowAuthorizationTest.java | 1 + .../ProcurementWorkflowPersistenceTest.java | 21 +++ .../RelabelDispatchSourcePersistenceTest.java | 1 + .../trucking/UsTruckOrderRequestsTest.java | 6 +- .../service/UsTruckOrderServiceTest.java | 6 +- .../budget/OrderBudgetAuthorizationTest.java | 46 ++++++ .../budget/OrderBudgetPersistenceTest.java | 55 +++++++ .../wallet/budget/OrderBudgetServiceTest.java | 60 ++++++++ 60 files changed, 1208 insertions(+), 60 deletions(-) create mode 100644 deploy/sql/overseas-order-budget-and-procurement-rejection.sql create mode 100644 docs/business-index.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardRelabelMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardRelabelRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardRelabelStockService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/port/DriverAvailability.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/DriverAvailabilityRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudget.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java create mode 100644 lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardRelabelMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardRelabelStockServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 4ff0f6c5b..11860de19 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -224,3 +224,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test 本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。 + + +2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。 diff --git a/deploy/sql/overseas-order-budget-and-procurement-rejection.sql b/deploy/sql/overseas-order-budget-and-procurement-rejection.sql new file mode 100644 index 000000000..c078d8d00 --- /dev/null +++ b/deploy/sql/overseas-order-budget-and-procurement-rejection.sql @@ -0,0 +1,29 @@ +-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因''')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照''')); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; +CREATE TABLE IF NOT EXISTS ll_order_budget_reservation ( + id BIGINT NOT NULL COMMENT '预算冻结记录主键', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识', + kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型', + request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键', + order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号', + original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额', + currency VARCHAR(3) NOT NULL COMMENT '预估金额币种', + amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额', + status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态', + error_message VARCHAR(1000) NULL COMMENT '资金失败原因', + command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识', + command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本', + release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占', + created_at DATETIME(3) NOT NULL COMMENT '首次预占时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key), + KEY idx_order_budget_release(status,release_requested,created_at), + KEY idx_order_budget_order(customer_id,kind,order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影'; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 74ef68f36..e183b1cbb 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2105,6 +2105,8 @@ CREATE TABLE ll_overseas_procurement_order ( created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', + customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因', + rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照', review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', @@ -4897,4 +4899,25 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command ( PRIMARY KEY (order_id,command_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables; +CREATE TABLE ll_order_budget_reservation ( + id BIGINT NOT NULL COMMENT '预算冻结记录主键', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识', + kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型', + request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键', + order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号', + original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额', + currency VARCHAR(3) NOT NULL COMMENT '预估金额币种', + amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额', + status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态', + error_message VARCHAR(1000) NULL COMMENT '资金失败原因', + command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识', + command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本', + release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占', + created_at DATETIME(3) NOT NULL COMMENT '首次预占时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key), + KEY idx_order_budget_release(status,release_requested,created_at), + KEY idx_order_budget_order(customer_id,kind,order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 223 AS initialized_tables; diff --git a/docs/business-index.md b/docs/business-index.md new file mode 100644 index 000000000..e69de29bb diff --git a/lastleg-service/docs/procurement-workflow.md b/lastleg-service/docs/procurement-workflow.md index b94e378cb..65d3e7e66 100644 --- a/lastleg-service/docs/procurement-workflow.md +++ b/lastleg-service/docs/procurement-workflow.md @@ -14,7 +14,7 @@ 拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。 -收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 +收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。 @@ -46,3 +46,12 @@ - Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。 - 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。 - 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。 + + +## 2026-10-29 客户下单预算与页面调整 + +客户采购下单前,以相同幂等键调用 `order-budgets/procurement/{key}`,金额为预算单价乘数量,按有效汇率折人民币;仅Finance冻结成功且usable=true时允许建单。余额不足使用可用授信,合计不足则提示Finance原因,不创建订单。免费样品金额为0时不发资金指令。未绑定预占15分钟后释放,晚到回执仍释放;已绑定订单终态释放,失败定时重试。预算预占独立于现有人工费用冻结/实扣,不计入已扣款汇总。 + +本土卡车同样校验预算,托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。 + +结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardRelabelMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardRelabelMapper.java new file mode 100644 index 000000000..a1eec71df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardRelabelMapper.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 已确认待发换标箱的废弃归属账本;取消及扣减均保留原换标事实。 */ +@Mapper +public interface CargoDiscardRelabelMapper { + /** 与排车使用相同原申请行锁,锁顺序由调用方统一排列。 */ + @Select("SELECT relabel_no FROM ll_relabel_application WHERE relabel_no=#{no} FOR UPDATE") + String lock(@Param("no") String no); + String ELIGIBLE = "a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + + "AND a.customer_confirmed_at IS NOT NULL AND COALESCE(a.outbound_order_no,'')='' " + + "AND i.return_carton_id IS NULL AND i.notified_package_count=1 " + + "AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id " + + "AND dc.carton_id=i.id AND dc.source_type IN ('RELABEL','RELABEL_DISCARDED')) "; + + /** 已确认箱必须仍在实际冻结仓位;当前轮次绑定由业务端口继续复核。 */ + @Select("SELECT i.* FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " + + "WHERE i.customer_id=#{customerId} AND i.source_inventory_position_id=#{positionId} AND " + ELIGIBLE + " ORDER BY i.id") + List cartons(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + /** 通过原入仓单及客户/系统箱唛定位唯一转运实收箱,废弃扫码始终使用系统箱唛。 */ + @Select("SELECT DISTINCT c.carton_mark FROM ll_relabel_item i " + + "JOIN ll_warehouse_transfer_order t ON t.customer_id=i.customer_id AND t.inbound_order_id=i.source_inbound_order_id " + + "JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id " + + "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) " + + "WHERE i.customer_id=#{customerId} AND i.id=#{id} ORDER BY c.carton_mark") + List systemCartonMarks(@Param("customerId") String customerId, @Param("id") Long id); + + /** 箱级交接使用锁定读复核最新归属,不能复用提交前的一致性读快照。 */ + @Select("SELECT i.id FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " + + "WHERE i.customer_id=#{customerId} AND i.id=#{id} AND " + ELIGIBLE + " FOR UPDATE") + Long lockEligible(@Param("customerId") String customerId, @Param("id") Long id); + + /** 增补仍有已确认换标冻结实箱的候选仓位,不开放其他业务冻结库存。 */ + @Select("") + List positions(@Param("customerId") String customerId, @Param("keyword") String keyword); + + /** 排车与废弃统一先锁原换标申请,再锁库存仓位。 */ + @Select("") + List applications(@Param("customerId") String customerId, @Param("positions") List positions); + + /** 原申请业务号用于交接及取消恢复冻结。 */ + @Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " + + "WHERE i.customer_id=#{customerId} AND i.id=#{id}") + String relabelNo(@Param("customerId") String customerId, @Param("id") Long id); + + /** 识别已转交换标的旧转运箱,避免同一箱在原托盘再次出现。 */ + @Select("SELECT DISTINCT c.id FROM ll_warehouse_transfer_carton c JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id " + + "JOIN ll_relabel_item i ON i.customer_id=c.customer_id AND i.source_inbound_order_id=t.inbound_order_id " + + "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) " + + "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " + + "WHERE c.customer_id=#{customerId} AND a.status NOT IN ('CANCELLED','REJECTED')") + List transferredCartons(@Param("customerId") String customerId); + + /** 使用原状态条件将本次箱级占用改为取消或已实际废弃。 */ + @Update("UPDATE ll_cargo_discard_carton SET source_type=#{target} WHERE customer_id=#{customerId} AND carton_id=#{id} AND source_type='RELABEL'") + int finish(@Param("customerId") String customerId, @Param("id") Long id, @Param("target") String target); + + /** 有待废弃箱时暂停排车,结算后剩余箱恢复;全部实际废弃后终止履约。 */ + @Update("UPDATE ll_relabel_application SET fulfillment_status=CASE " + + "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.carton_id=i.id AND dc.customer_id=i.customer_id " + + "AND dc.source_type='RELABEL' WHERE i.application_id=ll_relabel_application.id) THEN 'DISCARD_PENDING' " + + "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i WHERE i.application_id=ll_relabel_application.id AND NOT EXISTS " + + "(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED')) " + + "THEN 'WAIT_PRE_DISPATCH' ELSE 'DISCARDED' END,updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND status='READY_FOR_OUTBOUND' " + + "AND fulfillment_status IN ('WAIT_PRE_DISPATCH','DISCARD_PENDING','DISCARDED') AND COALESCE(outbound_order_no,'')=''") + int refresh(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index d87d4b978..4c904d469 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -69,10 +69,11 @@ public class PdaCargoDiscardService { if (!line.actualQuantityEditable() && actual != line.plannedPackages()) throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); } - if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) { + // 有真实箱号必须逐箱核对,兼容字段不能绕过扫码校验。 + if ("CARTON".equals(task.scanMode())) { var scanned = request.scannedCartonMarks() == null ? List.of() : request.scannedCartonMarks(); if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks()))) - throw conflict("必须扫描全部箱唛,或选择一键全部废弃"); + throw conflict("必须扫描本次废弃的全部箱号"); } List palletItems = new ArrayList<>(); for (PdaCargoDiscardDtos.Sku line : task.skus()) { @@ -179,8 +180,8 @@ public class PdaCargoDiscardService { isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList(); String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList(); - // 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 - boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream() + // 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。 + boolean cartonScan = !marks.isEmpty() || entry.getValue().stream() .anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired())); if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛"); return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardRelabelRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardRelabelRepository.java new file mode 100644 index 000000000..0d40e6fcc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardRelabelRepository.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 换标废弃事实仅经本库 Mapper 读取和原状态条件更新。 */ +@Repository +public class CargoDiscardRelabelRepository { + private final CargoDiscardRelabelMapper mapper; + public CargoDiscardRelabelRepository(CargoDiscardRelabelMapper mapper) { this.mapper = mapper; } + /** 按原申请业务号取得正式行锁。 */ + public String lock(String no) { return mapper.lock(no); } + /** 复验最新箱级状态,拒绝并发废弃或已经排车的旧页面。 */ + public boolean lockEligible(String customer, Long id) { return mapper.lockEligible(customer, id) != null; } + /** 已确认待发实箱。 */ + public List cartons(String customer, Long position) { return mapper.cartons(customer, position); } + /** 读取换标箱对应的原系统箱唛,避免以 FBA 箱唛替代仓内扫码身份。 */ + public List systemCartonMarks(String customer, Long item) { return mapper.systemCartonMarks(customer, item); } + /** 已确认冻结仓位。 */ + public List positions(String customer, String keyword) { return mapper.positions(customer, keyword); } + /** 原申请锁集合。 */ + public List applications(String customer, List positions) { return positions.isEmpty() ? List.of() : mapper.applications(customer, positions); } + /** 换标业务归属。 */ + public String relabelNo(String customer, Long item) { return mapper.relabelNo(customer, item); } + /** 原托盘旧箱记录。 */ + public List transferredCartons(String customer) { return mapper.transferredCartons(customer); } + /** 结束箱级独占。 */ + public int finish(String customer, Long item, String target) { return mapper.finish(customer, item, target); } + /** 刷新整单剩余履约资格。 */ + public int refresh(String customer, String no) { return mapper.refresh(customer, no); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardRelabelStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardRelabelStockService.java new file mode 100644 index 000000000..f12b1ee40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardRelabelStockService.java @@ -0,0 +1,123 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.entity.*; +import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow; +import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.util.*; + +/** 已换标实箱在原换标冻结与废弃冻结之间原子交接,保留原箱历史。 */ +@Service +public class CargoDiscardRelabelStockService { + @Resource private CargoDiscardRelabelRepository repository; + @Resource private RelabelDispatchSourcePort sources; + @Resource private InventoryCommandService commands; + @Resource private ObjectMapper json; + + /** 仅增补有待发换标库存的位置。 */ + public List positions(String customer, String keyword) { return repository.positions(customer, keyword); } + + /** 从当前轮次实托绑定核对箱级贡献,换标完成但未绑定的箱不能猜测位置。 */ + public List cartons(String customer, InventoryPosition position) { + var items = repository.cartons(customer, position.getId()); + if (items.isEmpty() || position.getPalletId() == null || position.getPalletId() <= 0) return List.of(); + var snapshot = sources.snapshot(position.getOverseasWarehouseId(), List.of(position.getPalletId())); + if (!snapshot.complete()) return List.of(); + var bound = snapshot.sources().stream().filter(s -> customer.equals(s.customerId()) + && Objects.equals(position.getId(), s.inventoryPositionId()) && s.boundPackageCount() == 1) + .map(RelabelDispatchSourcePort.BoundSource::itemId).collect(java.util.stream.Collectors.toSet()); + var result = new ArrayList(); + for (var item : items) { + if (!bound.contains(item.getId())) continue; + var box = new CargoDiscardStockCartonRow(); box.setId(item.getId()); box.setSourceType("RELABEL"); + // 系统箱唛是仓内实箱身份;换标仅更新 FBA 箱唛和商品标签,不替换废弃扫码身份。 + var systemMarks = repository.systemCartonMarks(customer, item.getId()); + if (systemMarks.size() > 1) throw conflict("换标箱对应多个系统箱唛,请先核对原转运箱记录"); + box.setCartonMark(systemMarks.isEmpty() ? item.getCartonMark() : systemMarks.get(0)); + box.setSkuCode(item.getNewSkuCode() == null || item.getNewSkuCode().isBlank() ? item.getSkuCode() : item.getNewSkuCode()); + try { + var values = json.readTree(Objects.toString(item.getSkuRelabelItemsJson(), "[]")); + if (values.isArray() && !values.isEmpty()) { + var contents = json.createArrayNode(); + for (var value : values) { + var content = contents.addObject(); + String code = value.path("newSkuCode").asText(); + content.put("skuCode", code.isBlank() ? value.path("skuCode").asText() : code); + content.put("quantity", value.path("quantity").asLong()); + } + box.setSkuItemsJson(json.writeValueAsString(contents)); + } + } catch (Exception invalid) { throw conflict("换标箱内 SKU 事实无法读取"); } + result.add(box); + } + return result.size() <= number(position.getHoldPackages()) ? result : List.of(); + } + + /** 排除已转交换标处理的旧转运箱,不能同时宣称原托盘和新托盘都存有该箱。 */ + public Set transferred(String customer) { return new HashSet<>(repository.transferredCartons(customer)); } + + /** 保存废弃选箱账本前以最新锁定读认领实箱。 */ + public int claim(String customer, Long item) { return repository.lockEligible(customer, item) ? 1 : 0; } + + /** 与排车共用按业务号排序的原申请锁,之后再取得库存正式锁。 */ + public void lock(String customer, List positions) { + var applications = repository.applications(customer, positions); + applications.stream().distinct().sorted().forEach(no -> { + if (repository.lock(no) == null) throw conflict("换标原申请不存在"); + }); + } + + /** 先归还原换标冻结,紧接着由调用方以废弃周期重新冻结,同事务不暴露可用窗口。 */ + public void handoff(String customer, String operator, CargoDiscardApplication app, CargoDiscardItem line, + List selected) { + for (var box : selected) if ("RELABEL".equals(box.getSourceType())) { + String no = requireNo(customer, box.getId()); + commands.release(customer, operator, move(line, app, box.getId(), no, "HANDOFF")); + } + } + + /** 箱级占用已持久化后暂停原换标出库;同一混标箱多SKU行只需刷新一次。 */ + public void synchronize(List links, String customer) { + var ids = links.stream().filter(link -> "RELABEL".equals(link.getSourceType())) + .map(CargoDiscardCarton::getCartonId).distinct().toList(); + var no = new TreeSet(); ids.forEach(id -> no.add(requireNo(customer, id))); + for (String relabelNo : no) if (repository.refresh(customer, relabelNo) != 1) + throw conflict("换标单已进入其他出库流程"); + } + + /** 实际废弃或撤销记录保留箱级结果;整单仅依据剩余箱恢复资格。 */ + public int finish(String customer, Long item, boolean discarded) { + String no = requireNo(customer, item); + int changed = repository.finish(customer, item, discarded ? "RELABEL_DISCARDED" : "RELABEL_RELEASED"); + if (changed == 1 && repository.refresh(customer, no) != 1) throw conflict("换标废弃归属发生变化"); + return changed; + } + + /** 撤销后恢复原换标 HOLD,不能把已换标待发箱释放为普通可用箱。 */ + public void restore(String customer, CargoDiscardApplication app, CargoDiscardItem line, List links) { + for (var link : links) if (Objects.equals(line.getId(), link.getItemId()) && "RELABEL".equals(link.getSourceType())) + commands.hold(customer, app.getCreatedBy(), move(line, app, link.getCartonId(), requireNo(customer, link.getCartonId()), "RESTORE")); + } + + /** 每次废弃申请独立幂等,允许取消后重新申请而不复用旧释放结果。 */ + private InventoryCommandRequests.Move move(CargoDiscardItem line, CargoDiscardApplication app, Long item, String no, String action) { + return new InventoryCommandRequests.Move(line.getInventoryPositionId(), 1, no, null, "RELABEL:" + no, + "DISCARD_RELABEL:" + app.getId() + ":" + item + ":" + action, "换标库存废弃交接 " + action); + } + /** 原申请必须可追溯。 */ + private String requireNo(String customer, Long item) { + String no = repository.relabelNo(customer, item); + if (no == null) throw conflict("换标箱原申请不存在"); + return no; + } + private long number(Long value) { return value == null ? 0 : value; } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java index 4ef53aa2f..ec2ecec86 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java @@ -20,14 +20,18 @@ public class CargoDiscardStockService { @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository; @Resource private InventoryCommandService commands; @Resource private ObjectMapper objectMapper; + @Resource private CargoDiscardRelabelStockService relabelStock; /** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */ public List candidates(String customer, String keyword) { - return repository.candidates(customer, keyword).stream().map(position -> { + var positions = new LinkedHashMap(); + repository.candidates(customer, keyword).forEach(p -> positions.put(p.getId(), p)); + relabelStock.positions(customer, keyword).forEach(p -> positions.put(p.getId(), p)); + return positions.values().stream().map(position -> { var balance = repository.balance(position.getBalanceId()); if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在"); - long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); - var cartons = repository.cartons(customer, position.getId()); + long available = available(customer, position, balance); + var cartons = cartons(customer, position); boolean complete = completeCartons(cartons, available); var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(), contents(c, balance.getProductNameZh()))).toList() : List.of(); @@ -47,16 +51,17 @@ public class CargoDiscardStockService { if (balance == null || !customer.equals(balance.getCustomerId()) || !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId())) throw conflict("库存不属于当前客户或仓库"); - long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); + long available = available(customer, position, balance); if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足"); if (request.cartonIds() != null) { if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size()) throw conflict("废弃箱子数量与所选箱子不一致或重复"); - var cartons = repository.cartons(customer, position.getId()); + var cartons = cartons(customer, position); var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet()); if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选"); if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子"); } else { + if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子"); if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); } @@ -66,14 +71,16 @@ public class CargoDiscardStockService { /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { + relabelStock.lock(customer, List.of(request.inventoryPositionId())); if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); var balance = validate(customer, request); - reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>()); + reserveValidated(customer, operator, application, request, at, balance, cartons(customer, position(customer, request.inventoryPositionId())), new HashSet<>(), new HashSet<>()); } /** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */ public void reserveAll(String customer, String operator, CargoDiscardApplication application, List requests, LocalDateTime at) { var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList(); + relabelStock.lock(customer, stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().toList()); stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> { if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在"); }); @@ -82,7 +89,7 @@ public class CargoDiscardStockService { var selectedSkus = new HashMap>(); for (var request : stocks) { balances.put(request.inventoryPositionId(), validate(customer, request)); - var facts = repository.cartons(customer, request.inventoryPositionId()); + var facts = cartons(customer, position(customer, request.inventoryPositionId())); cartons.put(request.inventoryPositionId(), facts); for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId())) selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim()); @@ -124,9 +131,15 @@ public class CargoDiscardStockService { if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录"); snapshotMarks(line, marks); } - int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count(); - if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD")); + var newlyHeld = selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).toList(); + int physical = selected.isEmpty() ? request.discardPackages() : newlyHeld.size(); + if (physical > 0) { + relabelStock.handoff(customer, operator, application, line, newlyHeld); + commands.hold(customer, operator, move(application, line, physical, "HOLD")); + } repository.saveItem(line); + if (newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType()))) + relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer); } /** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */ @@ -165,14 +178,18 @@ public class CargoDiscardStockService { /** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */ public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) { + var links = repository.selectedCartons(customer, application.getId()); + relabelStock.lock(customer, List.of(line.getInventoryPositionId())); changeSelectedCartons(customer, application, line, "AVAILABLE"); int physical = physicalPackages(customer, application, line); if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE")); + relabelStock.restore(customer, application, line, links); } /** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */ @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) { + relabelStock.lock(customer, List.of(line.getInventoryPositionId())); if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数"); if (!actualQuantityEditable(line) && actual != line.getDiscardPackages()) throw conflict("必须完成本次申请所选全部箱子,不能漏出"); @@ -203,6 +220,7 @@ public class CargoDiscardStockService { /** 按箱来源执行带原状态条件的变更。 */ private int changeBox(String customer, Long cartonId, String source, String from, String to) { + if ("RELABEL".equals(source)) return "DISCARD_PENDING".equals(to) ? relabelStock.claim(customer, cartonId) : relabelStock.finish(customer, cartonId, "DISCARDED".equals(to)); if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to); if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); return repository.changeInboundCarton(customer, cartonId, from, to); @@ -239,6 +257,21 @@ public class CargoDiscardStockService { } /** 无 SKU 来源保留未指定标记,不猜测商品。 */ private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); } + /** 当前可废弃数量包括已完成并确认的换标实箱,不包含其他业务冻结。 */ + private long available(String customer, InventoryPosition position, InventoryBalance balance) { + long normal = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); + long relabel = relabelStock.cartons(customer, position).size(); + return normal + Math.min(relabel, value(balance.getHoldPackages())); + } + /** 原转运旧箱与当前换标实箱互斥,避免换托后重复展示。 */ + private List cartons(String customer, InventoryPosition position) { + var moved = relabelStock.transferred(customer); + var result = new ArrayList<>(repository.cartons(customer, position.getId()).stream() + .filter(c -> !"TRANSFER".equals(c.getSourceType()) || !moved.contains(c.getId())).toList()); + result.addAll(relabelStock.cartons(customer, position)); + return result; + } + /** 空库存桶按零处理。 */ private static long value(Long value) { return value == null ? 0 : value; } /** 返回可恢复的业务冲突。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 9a356e834..f145bd43f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -328,6 +328,8 @@ public interface DispatchCandidateMapper { "LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " + "AND wtp.warehouse_pallet_id=pos.pallet_id " + "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + + // 部分废弃后只统计仍在仓内的换标箱,保留原任务供追溯。 + "AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=ri.customer_id AND dc.carton_id=ri.id AND dc.source_type='RELABEL_DISCARDED') " + "AND a.customer_id=#{customerId} " + "AND a.relabel_no=#{relabelNo} " + "AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java index 8a322331a..2d4c1c167 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java @@ -16,11 +16,14 @@ public final class TransportTaskModels { @NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { } public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { } + public record Cargo(int palletCount,int packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm, + String origin,String originAddress,String originContactName,String originContactPhone, + String destination,String destinationAddress,String destinationContactName,String destinationContactPhone) { } public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution, String customerId,String orderNo,List pickups, List departureEvidence, List completionEvidence, - Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { } + Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources,Cargo cargo) { } public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, @NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason, @Min(0) Long dispatchRecordVersion, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java index 2be0ab5d2..795d794ab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -101,12 +101,16 @@ public class TransportTaskService { var departureIds=commandEvidence.isEmpty()?List.of():evidenceJson(commandEvidence.get(0)); var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList() : "COMPLETED".equals(trip.getStatus()) ? departureIds : List.of(); + var cargo=link==null?null:trucks.findNo(link.customerId(),link.orderNo()); return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(), "PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(), "LOCAL_TRUCK".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) ? metadata(trip.getId(),departureIds) : "LOCAL_TRUCK".equals(row.getTaskType()) && "COMPLETED".equals(trip.getStatus()) && commandEvidence.size()>1 ? metadata(trip.getId(),departureIds) : List.of(), "LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.of(),row.getRecordVersion(), - reviewPhotos.resources(row)); + reviewPhotos.resources(row),cargo==null?null:new TransportTaskModels.Cargo( + cargo.getPalletCount()==null?0:cargo.getPalletCount(),cargo.getPackageCount()==null?0:cargo.getPackageCount(),cargo.getTotalWeightKg(),cargo.getTotalVolumeCbm(), + cargo.getOrigin(),cargo.getOriginAddress(),cargo.getOriginContactName(),cargo.getOriginContactPhone(), + cargo.getDestination(),cargo.getDestinationAddress(),cargo.getDestinationContactName(),cargo.getDestinationContactPhone())); } /** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */ public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java index 60e0f085a..91d928def 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java @@ -15,8 +15,14 @@ public final class DriverModels { public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword, Boolean active, String reviewStatus, String ownership, String region, String licenseState, - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) { + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd, + @Size(max = 64) String dispatchNo) { public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + /** 无排车上下文的档案查询保持原有语义。 */ + public Query(Integer page, Integer size, String keyword, Boolean active, String reviewStatus, + String ownership, String region, String licenseState, Instant expectedEnd) { + this(page, size, keyword, active, reviewStatus, ownership, region, licenseState, expectedEnd, null); + } } public record Draft(@NotBlank @Size(max = 128) String name, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverService.java index f1b5a6b33..deed91524 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverService.java @@ -29,6 +29,7 @@ public class DriverService { private final ObjectMapper json; private final Clock clock; private final FleetWarehouseDirectory warehouses; + @Autowired private com.baiyue.lastleg.fleet.port.DriverAvailability availability; @Autowired public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) { @@ -55,6 +56,7 @@ public class DriverService { page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary); } + /** 先排除其他车次占用,再分页统计,避免空页或错误总数。 */ private LambdaQueryWrapper filter(DriverModels.Query query, Instant now) { String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态"); String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属"); @@ -65,6 +67,10 @@ public class DriverService { .eq(review != null, DriverEntity::getReviewStatus, review) .eq(ownership != null, DriverEntity::getOwnership, ownership) .eq(region != null, DriverEntity::getRegion, region); + if (query.dispatchNo() != null && !query.dispatchNo().isBlank()) { + var unavailable = availability.unavailable(query.dispatchNo()); + if (!unavailable.isEmpty()) filter.notIn(DriverEntity::getId, unavailable); + } if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword) .or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword)); String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/port/DriverAvailability.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/port/DriverAvailability.java new file mode 100644 index 000000000..6c6ba7b1d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/port/DriverAvailability.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.fleet.port; + +import java.util.List; + +/** 司机候选读取权威占用的车队端口。 */ +public interface DriverAvailability { + /** 返回其他车次实际占用的司机,保留当前排车资源。 */ + List unavailable(String dispatchNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/DriverAvailabilityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/DriverAvailabilityRepository.java new file mode 100644 index 000000000..95cebafee --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/DriverAvailabilityRepository.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.port.DriverAvailability; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 司机候选只读取车队权威占用,保留当前排车关联资源。 */ +@Repository +public class DriverAvailabilityRepository implements DriverAvailability { + private final FleetResourceMapper resources; + /** 注入资源占用持久层。 */ + public DriverAvailabilityRepository(FleetResourceMapper resources) { this.resources = resources; } + /** 返回其他车次占用的司机身份。 */ + public List unavailable(String dispatchNo) { return resources.unavailableDrivers(dispatchNo); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java index 081d616b2..52fdbfdec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java @@ -28,5 +28,12 @@ public interface FleetResourceMapper { SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo}) """) List unavailableVehicles(@Param("dispatchNo") String dispatchNo); + /** 当前排车的司机可保留,其他车次实际占用的司机不进入候选。 */ + @Select(""" + SELECT a.resource_id FROM ll_fleet_resource_assignment a + WHERE a.resource_kind='DRIVER' AND NOT EXISTS ( + SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo}) + """) + List unavailableDrivers(@Param("dispatchNo") String dispatchNo); record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java index 2ab349d27..8dbfdc505 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") public class MerchantOverseasProcurementController { @Resource private OverseasProcurementService service; + @Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets; @Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; @PostMapping @@ -31,7 +32,8 @@ public class MerchantOverseasProcurementController { public ApiResponse create( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody OverseasProcurementRequests.Create request) { - var created=service.create(customer(user),operator(user),request); + var created=budgets.submit(customer(user),"procurement",request.idempotencyKey(),request.budgetUnitPrice().multiply(java.math.BigDecimal.valueOf(request.purchaseQuantity())),request.currency(), + ()->service.create(customer(user),operator(user),request),result->result.summary().orderNo()); return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo())); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantProcurementWorkflowController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantProcurementWorkflowController.java index ba39c60b9..5e36e2c81 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantProcurementWorkflowController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantProcurementWorkflowController.java @@ -29,6 +29,12 @@ public class MerchantProcurementWorkflowController { public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Command request) { return ApiResponse.success(workflow.confirm(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request)); } + /** 客户驳回必须说明原因,由当前认证客户确认归属。 */ + @PostMapping("/{orderNo}/reject") + @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_CREATE+"')") + public ApiResponse reject(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Rejection request) { + return ApiResponse.success(workflow.reject(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request)); + } /** 只下载本单收货与出库已关联凭证,其他内部文件不外露。 */ @GetMapping("/{orderNo}/files/{fileId}/content") @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java index f28a3347c..9a8bdbf89 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java @@ -29,6 +29,12 @@ public class ProcurementWorkflowController { public ApiResponse receive(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) { return ApiResponse.success(service.receive(customerId,orderNo,platform(user,customerId),request)); } + /** 客户驳回后的照片替换复用正式收货权限。 */ + @PostMapping("/{orderNo}/receipt-photos") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") + public ApiResponse replacePhotos(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ReceiptPhotos request) { + return ApiResponse.success(service.replacePhotos(customerId,orderNo,platform(user,customerId),request)); + } /** 将本单实收交给客户确认。 */ @PostMapping("/{orderNo}/request-confirmation") @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java index ec8a42e2a..737c5f2af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java @@ -8,6 +8,10 @@ import java.util.List; public final class ProcurementWorkflowDtos { private ProcurementWorkflowDtos() { } public record Command(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey) { } + public record Rejection(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotBlank @Size(max=1000) String reason) { } + public record ReceiptPhotos(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, + @Positive long receiptId,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { } public record Receipt(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, @Positive long quantity,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { } public record Outbound(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, @@ -22,5 +26,5 @@ public final class ProcurementWorkflowDtos { public record FileInfo(Long fileId,String fileName,String contentType) { } public record Workflow(String orderNo,long version,String status,long receivedQuantity,long stockQuantity, long reservedQuantity,long availableQuantity,List receipts, - List outbounds,List files) { } + List outbounds,List files,String rejectionReason,boolean photosNeedUpdate) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index c571d1909..02cdee57f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -33,6 +33,8 @@ public class OverseasProcurementOrder { private Long inboundQuantity; @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; private String status; + private String customerRejectionReason; + private String rejectedPhotoIds; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; private String idempotencyKey; private String requestHash; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java index c62ed6103..61b3dc03d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java @@ -22,6 +22,9 @@ public interface ProcurementWorkflowMapper { /** 保存带照片的分批收货。 */ @Insert("INSERT INTO ll_procurement_workflow_receipt(id,order_id,quantity,file_ids,received_at,created_by) VALUES(#{id},#{orderId},#{quantity},#{files},CURRENT_TIMESTAMP(3),#{operator})") void receipt(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("files") String files,@Param("operator") String operator); + /** 驳回返工只替换本批照片,不重复增加收货数量。 */ + @Update("UPDATE ll_procurement_workflow_receipt SET file_ids=#{files} WHERE id=#{id} AND order_id=#{orderId}") + int replacePhotos(@Param("id") long id,@Param("orderId") long orderId,@Param("files") String files); /** 创建占用可用采购库存的出库单。 */ @Insert("INSERT INTO ll_procurement_workflow_outbound(id,order_id,outbound_no,quantity,tracking_no,status,file_ids,created_at,created_by) VALUES(#{id},#{orderId},#{no},#{quantity},#{tracking},'IN_TRANSIT','[]',CURRENT_TIMESTAMP(3),#{operator})") void outbound(@Param("id") long id,@Param("orderId") long orderId,@Param("no") String no,@Param("quantity") long quantity,@Param("tracking") String tracking,@Param("operator") String operator); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java index 3fc42b2db..3b624e2db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java @@ -27,6 +27,8 @@ public class ProcurementWorkflowRepository { public List> outbounds(long id) { return facts.outbounds(id); } /** 写入收货事实。 */ public void receipt(long id,long orderId,long quantity,String files,String operator) { facts.receipt(id,orderId,quantity,files,operator); } + /** 照片返工保留原始收货数量和时间。 */ + public int replacePhotos(long id,long orderId,String files) { return facts.replacePhotos(id,orderId,files); } /** 写入出库事实。 */ public void outbound(long id,long orderId,String no,long quantity,String tracking,String operator) { facts.outbound(id,orderId,no,quantity,tracking,operator); } /** 更新出库批次。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java index 9445c1027..f218c62ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java @@ -49,6 +49,7 @@ public class ProcurementWorkflowService { if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order); version(order,request.version());state(order,"RECEIVING"); if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量"); + if(snapshot(order).photosNeedUpdate()) throw conflict("客户已驳回,请重新上传各收货批次照片后再提交确认"); order.setStatus("WAIT_CUSTOMER_CONFIRM"); finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request); return snapshot(order); @@ -65,6 +66,33 @@ public class ProcurementWorkflowService { return snapshot(order); } + /** 客户驳回后回到收货中,冻结本轮照片快照以强制重新上传。 */ + @Transactional + public Workflow reject(String customer,String no,String operator,Rejection request) { + var order=order(customer,no,true); + if(replay(order,request.idempotencyKey(),"CUSTOMER_REJECT",request)) return snapshot(order); + version(order,request.version());state(order,"WAIT_CUSTOMER_CONFIRM"); + if(request.reason()==null || request.reason().isBlank() || request.reason().length()>1000) throw bad("请填写驳回原因,最多1000字"); + order.setCustomerRejectionReason(request.reason().trim()); + order.setRejectedPhotoIds(serialize(repository.files(order).stream().map(f->f.getFileId()).distinct().toList())); + order.setStatus("RECEIVING"); + finish(order,operator,request.idempotencyKey(),"CUSTOMER_REJECT",request);return snapshot(order); + } + + /** 平台逐批重新上传图片,禁止复用本轮被驳回照片或追加虚假收货量。 */ + @Transactional + public Workflow replacePhotos(String customer,String no,String operator,ReceiptPhotos request) { + var order=order(customer,no,true); + if(replay(order,request.idempotencyKey(),"REPLACE_PHOTOS",request)) return snapshot(order); + version(order,request.version());state(order,"RECEIVING"); + if(order.getCustomerRejectionReason()==null) throw conflict("只有客户驳回后可以重新上传收货照片"); + files(order,request.fileIds(),true); + var rejected=ids(order.getRejectedPhotoIds()); + if(request.fileIds().stream().anyMatch(rejected::contains)) throw bad("请重新上传照片,不能复用被驳回的图片"); + if(repository.replacePhotos(request.receiptId(),order.getId(),serialize(request.fileIds()))!=1) throw bad("收货批次不属于本采购单"); + finish(order,operator,request.idempotencyKey(),"REPLACE_PHOTOS",request);return snapshot(order); + } + /** 创建出库先占用库存,避免其他批次重复使用相同数量;完成才实际扣除。 */ @Transactional public Workflow outbound(String customer,String no,String operator,Outbound request) { @@ -152,7 +180,7 @@ public class ProcurementWorkflowService { long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum(); Set visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds())); var files=visible.isEmpty()?List.of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList(); - return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files); + return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files,order.getCustomerRejectionReason(),order.getRejectedPhotoIds()!=null && receipts.stream().flatMap(r->r.fileIds().stream()).anyMatch(ids(order.getRejectedPhotoIds())::contains)); } /** 文件数量、客户归属、用途和照片类型均由服务端验证。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java index 068903272..137112ee1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java @@ -16,6 +16,10 @@ import java.util.List; /** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */ @Mapper public interface RelabelDispatchSourceMapper { + /** 已实际废弃箱不再贡献排车库存,原换标明细及作业事实继续保留追溯。 */ + @Select("SELECT DISTINCT i.id FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.customer_id=i.customer_id " + + "AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED' WHERE i.application_id=#{id}") + List discardedItems(@Param("id") Long id); String PALLETS = "#{id}"; /** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java index 6248b1cda..77c4c0179 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface RelabelItemMapper extends BaseMapper { + /** 废弃完成后原明细仅用于追溯,不再参与剩余箱的绑定门禁。 */ + @org.apache.ibatis.annotations.Select("SELECT DISTINCT i.id FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc " + + "ON dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED' WHERE i.application_id=#{id}") + java.util.List discardedItems(@org.apache.ibatis.annotations.Param("id") Long id); /** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */ @org.apache.ibatis.annotations.Select("