From 423e40e6aac25d732d10118d115b941f029ade4c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 27 Oct 2026 17:41:33 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B9=E7=94=A8=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 | 2 + .../sql/lastleg-dispatch-transport-tasks.sql | 10 + deploy/sql/overseas-procurement-workflow.sql | 36 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 38 +- lastleg-service/docs/business-index.md | 4 +- lastleg-service/docs/procurement-direction.md | 2 + lastleg-service/docs/procurement-workflow.md | 46 +++ .../service/DeliveryTransferService.java | 3 +- .../service/DispatchAggregateService.java | 34 +- .../transport/TransportTaskMapper.java | 4 +- .../transport/TransportTaskModels.java | 1 + .../transport/TransportTaskProjection.java | 16 +- .../transport/TransportTaskService.java | 53 ++- .../mapper/OverseasFeeBusinessMapper.java | 4 + .../OverseasFeeBusinessRepository.java | 2 + .../service/OverseasFeeSourceService.java | 10 +- .../service/OverseasManualFeeService.java | 22 +- .../mapper/OwnedBusinessQueryMapper.java | 15 +- .../model/OwnedBusinessModels.java | 2 +- .../model/OwnedBusinessRows.java | 3 +- .../service/OwnedBusinessProjection.java | 27 +- .../service/OwnedBusinessQueryService.java | 10 + .../service/OwnedBusinessRecordService.java | 5 + .../fleet/reporting/TripOperationsCharts.java | 2 +- .../fleet/reporting/TripOperationsModels.java | 11 +- .../reporting/TripOperationsQueryMapper.java | 37 ++ .../reporting/TripOperationsRepository.java | 36 +- .../reporting/TripOperationsService.java | 22 +- ...MerchantProcurementWorkflowController.java | 47 +++ .../controller/ProcurementFileController.java | 4 +- .../ProcurementWorkflowController.java | 56 +++ .../dto/ProcurementWorkflowDtos.java | 24 ++ .../mapper/ProcurementWorkflowMapper.java | 31 ++ .../ProcurementWorkflowRepository.java | 38 ++ .../service/OverseasProcurementService.java | 7 +- .../service/ProcurementLifecycleService.java | 2 + .../service/ProcurementQueryService.java | 20 +- .../service/ProcurementWorkflowService.java | 189 ++++++++++ .../PlatformUsTruckOrderController.java | 10 + .../trucking/dto/UsTruckOrderRequests.java | 13 + .../trucking/service/UsTruckOrderService.java | 39 ++- .../src/main/resources/db/lastleg.sql | 35 ++ .../openapi/fleet-operations-v1.yaml | 6 +- .../openapi/fleet-owned-business-v1.yaml | 4 + .../openapi/overseas-business-v1.yaml | 2 +- .../openapi/overseas-finance-fees-v1.yaml | 4 +- .../openapi/procurement-workflow-v1.yaml | 331 ++++++++++++++++++ .../service/DeliveryTransferServiceTest.java | 6 + .../DispatchFeedbackRegressionTest.java | 6 +- ...DispatchReviewWorkflowPersistenceTest.java | 21 ++ .../transport/TransportTaskServiceTest.java | 21 ++ .../OverseasFeeRoleAuthorizationTest.java | 70 ++++ .../OverseasManualFeePersistenceTest.java | 14 + .../service/OverseasManualFeeServiceTest.java | 43 +++ .../ProcurementSettledFeeSourceTest.java | 21 ++ .../OwnedBusinessPersistenceTest.java | 4 +- .../OwnedBusinessProjectionTest.java | 11 + .../ownedbusiness/OwnedBusinessQueryTest.java | 14 + .../OwnedBusinessScopePersistenceTest.java | 29 +- .../reporting/TripOperationsServiceTest.java | 12 + ...verseasProcurementOpenApiContractTest.java | 37 ++ .../ProcurementWorkflowAuthorizationTest.java | 64 ++++ .../OverseasProcurementServiceTest.java | 6 +- .../ProcurementPurchasePersistenceTest.java | 11 +- .../service/ProcurementQueryServiceTest.java | 29 ++ .../ProcurementWorkflowPersistenceTest.java | 164 +++++++++ .../service/UsTruckOrderServiceTest.java | 92 ++--- ...-admin-dispatch-depart-repair-20261027.sql | 28 ++ 68 files changed, 1856 insertions(+), 166 deletions(-) create mode 100644 deploy/sql/overseas-procurement-workflow.sql create mode 100644 lastleg-service/docs/procurement-workflow.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsQueryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantProcurementWorkflowController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java create mode 100644 lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeRoleAuthorizationTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowAuthorizationTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java create mode 100644 scripts/db/local-platform-admin-dispatch-depart-repair-20261027.sql diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 0908be9c7..b36b94d0b 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -2,6 +2,8 @@ `finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 +`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。 + `overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。 车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。 diff --git a/deploy/sql/lastleg-dispatch-transport-tasks.sql b/deploy/sql/lastleg-dispatch-transport-tasks.sql index a36d454bc..84c08b315 100644 --- a/deploy/sql/lastleg-dispatch-transport-tasks.sql +++ b/deploy/sql/lastleg-dispatch-transport-tasks.sql @@ -3,6 +3,16 @@ -- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。 USE baiyue_lastleg; +-- 排车保存会同步异常快照;旧库必须先补齐该正式字段。 +SET @dispatch_exception_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'exceptions_json'), + 'SELECT 1', + 'ALTER TABLE ll_dispatch_order ADD COLUMN exceptions_json JSON NULL COMMENT ''派送异常登记记录及图片引用,不改变派送及库存状态'' AFTER review_basis_hash' +); +PREPARE dispatch_exception_stmt FROM @dispatch_exception_ddl; +EXECUTE dispatch_exception_stmt; +DEALLOCATE PREPARE dispatch_exception_stmt; + SET @dispatch_transport_column_ddl = IF( EXISTS ( SELECT 1 FROM information_schema.columns diff --git a/deploy/sql/overseas-procurement-workflow.sql b/deploy/sql/overseas-procurement-workflow.sql new file mode 100644 index 000000000..a6e908273 --- /dev/null +++ b/deploy/sql/overseas-procurement-workflow.sql @@ -0,0 +1,36 @@ +-- 简化采购独立批次表;只增加结构,不回填旧采购记录,执行需另行授权。 +USE baiyue_lastleg; +-- 简化采购:独立采购库存,收货、占用和签收扣减均由订单锁串行控制。 +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt ( + id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键', + order_id BIGINT NOT NULL COMMENT '采购单主键', + quantity BIGINT NOT NULL COMMENT '本批次数量', + file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表', + received_at DATETIME(3) NOT NULL COMMENT '本批次实收时间', + created_by VARCHAR(128) NOT NULL COMMENT '作业人标识', + KEY idx_procurement_workflow_receipt (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批收货照片与实收数量'; + +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound ( + id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键', + order_id BIGINT NOT NULL COMMENT '采购单主键', + outbound_no VARCHAR(64) NOT NULL COMMENT '采购出库单号', + quantity BIGINT NOT NULL COMMENT '本批次数量', + tracking_no VARCHAR(255) NOT NULL COMMENT '物流跟踪号', + status VARCHAR(32) NOT NULL COMMENT '批次运输派送及签收状态', + file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表', + created_at DATETIME(3) NOT NULL COMMENT '记录创建时间', + completed_at DATETIME(3) NULL COMMENT '本批次签收完成时间', + created_by VARCHAR(128) NOT NULL COMMENT '作业人标识', + UNIQUE KEY uk_procurement_workflow_outbound_no (outbound_no), + KEY idx_procurement_workflow_outbound (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批出库物流与签收凭证'; + +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command ( + order_id BIGINT NOT NULL COMMENT '采购单主键', + command_key VARCHAR(128) NOT NULL COMMENT '本单操作幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '动作及请求参数哈希', + operator_id VARCHAR(128) NOT NULL COMMENT '作业人标识', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + PRIMARY KEY (order_id,command_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 35d33d62d..b01093437 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -5120,4 +5120,40 @@ CREATE TABLE ll_fleet_resource_photo ( UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 240 AS initialized_tables; +USE baiyue_lastleg; +-- 简化采购:独立采购库存,收货、占用和签收扣减均由订单锁串行控制。 +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt ( + id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键', + order_id BIGINT NOT NULL COMMENT '采购单主键', + quantity BIGINT NOT NULL COMMENT '本批次数量', + file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表', + received_at DATETIME(3) NOT NULL COMMENT '本批次实收时间', + created_by VARCHAR(128) NOT NULL COMMENT '作业人标识', + KEY idx_procurement_workflow_receipt (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批收货照片与实收数量'; + +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound ( + id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键', + order_id BIGINT NOT NULL COMMENT '采购单主键', + outbound_no VARCHAR(64) NOT NULL COMMENT '采购出库单号', + quantity BIGINT NOT NULL COMMENT '本批次数量', + tracking_no VARCHAR(255) NOT NULL COMMENT '物流跟踪号', + status VARCHAR(32) NOT NULL COMMENT '批次运输派送及签收状态', + file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表', + created_at DATETIME(3) NOT NULL COMMENT '记录创建时间', + completed_at DATETIME(3) NULL COMMENT '本批次签收完成时间', + created_by VARCHAR(128) NOT NULL COMMENT '作业人标识', + UNIQUE KEY uk_procurement_workflow_outbound_no (outbound_no), + KEY idx_procurement_workflow_outbound (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批出库物流与签收凭证'; + +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command ( + order_id BIGINT NOT NULL COMMENT '采购单主键', + command_key VARCHAR(128) NOT NULL COMMENT '本单操作幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '动作及请求参数哈希', + operator_id VARCHAR(128) NOT NULL COMMENT '作业人标识', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + PRIMARY KEY (order_id,command_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 243 AS initialized_tables; diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index a2fcc3006..dd250af30 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -136,7 +136,7 @@ - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 -- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 +- 海外采购按 `procurement-workflow.md` 的七状态流程执行:创建待审核,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 @@ -169,5 +169,5 @@ ### 海外人工收费首版切换 - 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。 -- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购保留独立收费流程,禁止进入人工通用收费。 +- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。 - 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。 diff --git a/lastleg-service/docs/procurement-direction.md b/lastleg-service/docs/procurement-direction.md index 1066c3d71..18dabdcae 100644 --- a/lastleg-service/docs/procurement-direction.md +++ b/lastleg-service/docs/procurement-direction.md @@ -1,5 +1,7 @@ # 采购双方向履约 +> 2026-10-27 起,日常操作与验收以 [采购简化流程](procurement-workflow.md) 为准。下文保留方向字段与原两段样品链的设计记录,旧装箱、面单、两段核费不再作为新流程门禁。 + 2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。 - `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;平台审核仅展示客户下单方向,不允许审核改向;采购仓按该方向筛选。审核底部直接取消、拒绝或通过,拒绝必须填写具体原因。 diff --git a/lastleg-service/docs/procurement-workflow.md b/lastleg-service/docs/procurement-workflow.md new file mode 100644 index 000000000..86a6b551f --- /dev/null +++ b/lastleg-service/docs/procurement-workflow.md @@ -0,0 +1,46 @@ +# 海外采购简化流程 + +2026-10-27 按客户确认的需求收敛采购主流程,适用于海外采购回国内、国内采购发往海外两个方向。 + +| 主状态 | 编码 | 操作 | +|---|---|---| +| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 | +| 收货中 | `RECEIVING` | 平台分批登记数量和照片 | +| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 | +| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 | +| 运输中 | `IN_TRANSIT` | 平台从采购单创建出库批次,填写数量和物流跟踪号 | +| 派送中 | `DELIVERING` | 平台更新批次派送状态 | +| 签收完成 | `COMPLETED` | 全部采购库存已出完,且所有出库批次已签收 | + +拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。 + +收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 + +出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。 + +主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。 + +费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。每次创建出库批次前,本采购主单必须至少有一项未作废应收费用,且全部未作废应收费用均须有效并审核通过;缺少费用、待审核或驳回时提示并阻止出库,前后端共同校验。应付与已作废费用不能替代应收费用明细;原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。 + +客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。 + +## 验收范围 + +- 创建采购单后为待审核,审核后直接收货中。 +- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。 +- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。 +- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。 +- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。 +- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。 +- 无费用、仅作废费用、仅应付费用或任一应收尚未审核通过时,不能创建出库;全部有效应收审核通过后允许出库。 + +自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。 + +## 2026-10-27 本地验证记录 + +- 平台端定向检查104项通过,客户端定向检查28项通过;两端 TypeScript 检查通过。 +- 后端七状态、审核、权限、OpenAPI、结构中文备注40项去重定向检查通过;共用费用服务44项、采购费用来源4项、客户详情投影7项、费用真实Mapper持久化11项通过。 +- 流程测试使用隔离内存数据库和真实MyBatis Mapper,覆盖两次收货、客户确认、两批出库、派送及签收扣减,以及超量、并发、重试、版本与文件归属。 +- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。 +- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。 +- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index c9084a847..c6ec00f59 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -1431,7 +1431,8 @@ public class DeliveryTransferService { } /** 派送动作由状态和实际出库共同决定,列表与详情复用同一资格。 */ private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus(), deliveryExecution.outboundCompleted(d)), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } - private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); } + /** 派送费用说明沿用收费事实,不以审核意见覆盖业务说明。 */ + private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getFeeRemark(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private String deliveryGroupKey(DispatchItem item) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index de28eb959..6759387d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -373,6 +373,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA public CommandResult cancel(Actor actor,String number,DispatchCommands.ReasonCommand request) { var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request); var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); + if("LOCAL_TRUCK".equals(row.getTaskType()) && !"BEFORE_LOADING".equals(row.getStage()))throw conflict("装车中及后续阶段不能取消排车"); if(trim(request.reason())==null)throw bad("取消原因不能为空"); if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消"); // 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。 @@ -469,7 +470,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA requireEditing(row,request.recordVersion()); if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); verifyReviewResources(row); - if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核"); + // 本土卡车货物来自原卡车订单,不建立 dispatch_item;按排车摘要件数即可进入两级审核。 + if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && !"LOCAL_TRUCK".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核"); long round=row.getReviewRound()+1;String basis=basis(row); row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null))); @@ -507,7 +509,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); if(request.decision()==Decision.APPROVED) { row.setStage(Stage.LOADING.name()); - if(!"PICKUP".equals(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class)); + if(!Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class)); } } else { row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); @@ -700,10 +702,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */ private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); } - /** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */ + /** 取货及本土卡车锁原车次,仓库派送锁整车资源;统一先父后主单。 */ private void lockReviewRoot(Actor actor,String number) { var row=required(actor,number,false); - if("PICKUP".equals(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number); + if(Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number); } /** 严格请求身份不接收前端摘要或操作者。 */ private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));} @@ -722,7 +724,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA private String basis(DispatchAggregate row) { var facts=new TreeMap();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt()); - if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson()); + if(Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson()); else { var batches=json.valueToTree(repository.batches(row.getId())); // 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。 @@ -790,6 +792,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ void verifyReviewResources(DispatchAggregate row) { if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;} + if("LOCAL_TRUCK".equals(row.getTaskType())) {transportTasks.verifyLocalTruckReview(row.getDispatchNo());return;} requireReviewAppointment(row); var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话"); @@ -923,12 +926,25 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), d.status(),d.packageCount(),d.palletCount())).toList(); var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); + // 本土卡车路线以运输资源中的真实首末站为准,兼容早期只保存仓库编码的排车主单。 + String route=row.getRoute(); + if("LOCAL_TRUCK".equals(row.getTaskType())) route=localTruckRoute(number, route); return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver(),resources.maintenance()), - new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)), + new DispatchViews.DispatchInfo(route,instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)), documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand())); } + /** 本土卡车通过运输任务关联读取真实首末站,不能走仓库派送的整车绑定查询。 */ + private String localTruckRoute(String dispatchNo, String fallback) { + try { + var detail=transportTasks.detail(dispatchNo); + var stops=detail.execution().trip().stops(); + if(stops!=null && stops.size()>=2) return stops.get(0).name()+" → "+stops.get(stops.size()-1).name(); + } catch (RuntimeException ignored) { } + return fallback; + } + /** 恢复动作结果前重新核验当前单据范围和原动作权限。 */ @Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED) public CommandResult command(Actor actor,String number,String key) { @@ -1036,9 +1052,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { var blocked=new EnumMap(Action.class); boolean pickup="PICKUP".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus()); + boolean localTruck="LOCAL_TRUCK".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus()); for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) { - boolean state=pickup && switch(action) { - case EDIT,CANCEL -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()); + boolean state=(pickup || localTruck) && switch(action) { + case EDIT -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()); + case CANCEL -> localTruck ? "BEFORE_LOADING".equals(row.getStage()) : Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()); case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState()); case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState()); case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java index b3fa3dc26..79c7689b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java @@ -21,8 +21,8 @@ public interface TransportTaskMapper { /** 保存首次本土卡车安排内容。 */ @Insert("INSERT INTO ll_dispatch_transport_create(trip_no,request_hash) VALUES(#{trip},#{hash})") int createHashSave(@Param("trip") String trip,@Param("hash") String hash); - /** 仅返回已完成原单收货分配且没有承运安排的本土卡车需求。 */ - @Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status='PENDING_DISPATCH' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100") + /** 返回新流程待排车单,并兼容尚未迁移的历史托盘节点。 */ + @Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PALLET_OVERAGE_REVIEW','PARTIALLY_ALLOCATED','ALLOCATED','PENDING_DISPATCH') AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100") java.util.List candidates(); /** 原取货创建请求重放时恢复同一排车号。 */ @Select("SELECT * FROM ll_dispatch_order WHERE trip_id=#{trip}") 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 47a460c8e..8a322331a 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 @@ -18,6 +18,7 @@ public final class TransportTaskModels { @NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { } 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) { } public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java index 854f9aadf..a19c4e88f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator; import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.baiyue.lastleg.fleet.trip.FleetTripModels; import com.baiyue.lastleg.fleet.trip.TransportTripChanged; import com.baiyue.lastleg.trucking.entity.*; import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; @@ -40,11 +41,19 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup. var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null,null); row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review)); resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt()); - row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages); + row.setRoute("LOCAL_TRUCK".equals(trip.getTripPurpose()) ? localTruckRoute(trip, items) : "到仓取货 · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages); row.setPickedPackageCount(0);row.setShippedPackageCount(0);row.setRemark(trip.getRemark());row.setCreatedBy(operator); row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());roots.insert(row); roots.history(IdWorker.getId(),row,"TRANSPORT_CREATE",operator,"接入现有排车",write(row),row.getUpdatedAt());return row; } + /** 本土卡车审核显示真实起运地和目的地,避免只显示仓库编码。 */ + private String localTruckRoute(FleetTripEntity trip, int items) { + try { + var stops=json.readValue(trip.getStopsJson(), new com.fasterxml.jackson.core.type.TypeReference>(){}); + if(stops!=null && stops.size()>=2) return stops.get(0).name()+" → "+stops.get(stops.size()-1).name(); + } catch(Exception ignored) { } + return "本土卡车 · "+items+"单 · "+trip.getWarehouseCode(); + } /** 与资源事实同事务更新主单,兼容仍被集成调用的旧执行接口。 */ @EventListener public void changed(TransportTripChanged event) { @@ -60,8 +69,9 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup. row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt()); String oldResources=row.getResourcesJson();var oldDeparture=row.getPlannedDepartureAt();var oldArrival=row.getEstimatedArrivalAt(); row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip); + if("LOCAL_TRUCK".equals(row.getTaskType())) row.setRoute(localTruckRoute(trip, Objects.requireNonNullElse(row.getItemCount(), 0))); // 资源或计划改变后作废本轮审核,保留审核驳回原因供修改时核对。 - if("PICKUP".equals(row.getTaskType()) && "PLANNED".equals(trip.getStatus()) + if(Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()) && "PLANNED".equals(trip.getStatus()) && (!Objects.equals(oldResources,row.getResourcesJson()) || !Objects.equals(oldDeparture,row.getPlannedDepartureAt()) || !Objects.equals(oldArrival,row.getEstimatedArrivalAt()))) { row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING"); row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound())); @@ -102,7 +112,7 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup. public void transition(String customer,String number,String target,String action,String operator,String reason,String key) { var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在"); String from=order.getStatus();if(target.equals(from))return; - boolean allowed=("PENDING_DISPATCH".equals(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target)); + boolean allowed=(Set.of("WAITING_INBOUND","RECEIVING","WAITING_CONSOLIDATION","PALLET_OVERAGE_REVIEW","PARTIALLY_ALLOCATED","ALLOCATED","PENDING_DISPATCH").contains(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target)); if(!allowed)throw new IllegalStateException("本土卡车原单状态与排车执行不一致"); order.setStatus(target);order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));trucks.updateOrder(order); var history=new UsTruckOrderStatusHistory();history.setId(IdWorker.getId());history.setOrderId(order.getId());history.setCustomerId(customer); 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 c77e1eea2..6aa0bb236 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 @@ -22,6 +22,8 @@ import java.security.MessageDigest; /** 现有排车入口编排运输任务,不调用仓内拣货、装车审核及扣库存命令。 */ @Service public class TransportTaskService { + /** 新流程审核后直接待排车,旧托盘节点作为历史兼容状态一并放行到排车。 */ + private static final Set TRUCK_READY_STATUSES = Set.of("WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PALLET_OVERAGE_REVIEW", "PARTIALLY_ALLOCATED", "ALLOCATED", "PENDING_DISPATCH"); private final DispatchAggregateRepository roots; private final TransportTaskRepository links; private final TransportTaskProjection projection; @@ -77,7 +79,7 @@ public class TransportTaskService { var order=trucks.lockNo(request.customerId(),request.orderNo());if(order==null)throw missing("本土卡车单不存在"); prior=tripRepo.findCreated(operator,request.idempotencyKey());if(prior!=null)return replayCreate(prior,hash); pool.requireStandalone(order.getCustomerId(),"US_TRUCK",order.getId()); - if(!"PENDING_DISPATCH".equals(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待派车且未安排承运的本土卡车单可以接入排车"); + if(!TRUCK_READY_STATUSES.contains(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待取货且未安排承运的本土卡车单可以接入排车"); if(order.getOriginAddress()==null || order.getOriginAddress().isBlank() || order.getDestinationAddress()==null || order.getDestinationAddress().isBlank())throw conflict("请先补齐原单起终点地址"); var created=trips.createLocalTruck(operator,new FleetTripModels.Create(request.warehouseId(),request.plannedDepartureAt(),request.expectedDriverEndAt(), List.of(new FleetTripModels.Stop(order.getOrigin(),order.getOriginAddress(),null),new FleetTripModels.Stop(order.getDestination(),order.getDestinationAddress(),null)),"本土卡车:"+order.getOrderNo(),request.idempotencyKey())); @@ -95,15 +97,25 @@ public class TransportTaskService { @Transactional(readOnly=true) public TransportTaskModels.Detail detail(String number) { var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number); + var commandEvidence=links.evidence(number); + 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(); 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(), - evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList(),row.getRecordVersion(), - "PICKUP".equals(row.getTaskType())?reviewPhotos.resources(row):null); + "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)); } /** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */ 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);} /** 账本引用去重,不把上传但未确认的文件当作运输完成证据。 */ - private List evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->{try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference>(){}).stream();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}}).distinct().toList();} + private List evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->evidenceJson(value).stream()).distinct().toList();} + /** 读取一条执行命令保存的照片 ID 列表。 */ + private List evidenceJson(String value){try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference>(){}).stream().filter(Objects::nonNull).distinct().toList();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}} + /** 按运输资源核对并加载照片元数据。 */ + private List metadata(long tripId,List ids){return ids.stream().map(id->files.metadata(tripId,id)).toList();} /** 所有命令先锁资源父记录,再由既有服务核对公共执行版本。 */ public FleetTripEntity transport(String number,boolean lock) { var root=roots.find(number);if(root==null)throw missing("排车单不存在"); @@ -115,11 +127,11 @@ public class TransportTaskService { public TransportTaskModels.Detail assign(String number,String operator,TransportTaskModels.Assign request) { var trip=transport(number,true); com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence photos=null; - if("PICKUP".equals(trip.getTripPurpose())) { + if(Set.of("PICKUP", "LOCAL_TRUCK").contains(trip.getTripPurpose())) { var row=roots.lock(number); if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null || !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试"); - if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片"); + if(!"PLANNED".equals(trip.getStatus()) || "PICKUP".equals(trip.getTripPurpose()) && pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已开始执行的任务不能更换资源照片"); // 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。 var supplied=request.reviewPhotos(); var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE, @@ -127,7 +139,7 @@ public class TransportTaskService { var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER, request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds()); var license=request.masterLicenseFileId(); - if(license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) { + if("PICKUP".equals(trip.getTripPurpose()) && license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) { var driver=drivers.detail(request.driverId(),null); if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId()); } @@ -150,10 +162,29 @@ public class TransportTaskService { reviewPhotos.verifyReview(roots.find(number),trip); if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息"); } + /** 本土卡车审核校验真实车次、资源、审核照片和原单货量,不依赖仓库派送绑定。 */ + public void verifyLocalTruckReview(String number) { + var trip=transport(number,true); + if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !"PLANNED".equals(trip.getStatus()))throw conflict("仅未发车的本土卡车排车可以审核"); + var row=roots.find(number); + if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null + || !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间"); + var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm(); + requireResources(trip); + reviewPhotos.verifyReview(row,trip); + if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息"); + capacity(trip,truck(number)); + } /** 取消运输并保留原单、排车及资源审计。 */ @Transactional public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) { - var trip=transport(number,true);trips.cancel(trip.getTripNo(),operator,request);return detail(number); + var trip=transport(number,true); + // 本土卡车理货通过即进入装车,后续阶段禁止撤销运输安排。 + if("LOCAL_TRUCK".equals(trip.getTripPurpose())) { + var row=roots.lock(number); + if(row==null || !"BEFORE_LOADING".equals(row.getStage()))throw conflict("装车中及后续阶段不能取消排车"); + } + trips.cancel(trip.getTripNo(),operator,request);return detail(number); } /** 本土卡车发车和完成不制造仓库收发货记录。 */ @Transactional @@ -165,6 +196,12 @@ public class TransportTaskService { var order=truck(number); if("depart".equals(action)) { if(!"PLANNED".equals(trip.getStatus()) || !"DISPATCHED".equals(order.getStatus()))throw conflict("仅已派车的计划任务可发车"); + // 本土卡车沿用排车主单的车辆维护、调度审核和理货审核三道门禁。 + var dispatch = roots.lock(number); + if(dispatch == null || !"LOCAL_TRUCK".equals(dispatch.getTaskType()) || !"LOADING".equals(dispatch.getStage()) + || !"APPROVED".equals(dispatch.getReviewState())) throw conflict("请先完成车辆维护、调度审核和理货员审核"); + if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty())throw conflict("请上传至少一张 B 点取货照片"); + for(long id:request.evidenceFileIds())files.metadata(trip.getId(),id); requireResources(trip);capacity(trip,order);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now()); } else if("complete".equals(action)) { if(!"IN_TRANSIT".equals(trip.getStatus()) || !"IN_TRANSIT".equals(order.getStatus()))throw conflict("请先确认发车"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java index 082206624..a4b50b30d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java @@ -39,12 +39,16 @@ public interface OverseasFeeBusinessMapper { + " UNION ALL SELECT customer_id,NULL FROM ll_warehouse_transfer_order UNION ALL SELECT customer_id,NULL FROM ll_return_order" + " UNION ALL SELECT customer_id,NULL FROM ll_us_truck_order UNION ALL SELECT customer_id,NULL FROM ll_local_looseboard_order" + " UNION ALL SELECT customer_id,NULL FROM ll_relabel_application UNION ALL SELECT customer_id,NULL FROM ll_cargo_discard_application" + + " UNION ALL SELECT customer_id,customer_name FROM ll_overseas_procurement_order" + " UNION ALL SELECT customer_id,NULL FROM ll_inbound_plan UNION ALL SELECT customer_id,NULL FROM ll_delivery_order) c" + " WHERE customer_id IS NOT NULL AND TRIM(customer_id)!='' GROUP BY customer_id ORDER BY customer_id LIMIT 1000") List customers(); /** 头程附加费只认本次服务订单的类型,不能沿货物最初来源推断。 */ @Select("SELECT order_type FROM ll_order WHERE id=#{id} AND customer_id=#{customerId}") String orderType(@Param("id") long id, @Param("customerId") String customerId); + /** 读取头程订单目的地类型;自有海外仓订单默认走客户余额/授信。 */ + @Select("SELECT destination_type FROM ll_order WHERE id=#{id} AND customer_id=#{customerId}") + String destinationType(@Param("id") long id, @Param("customerId") String customerId); /** 换标履约订单登记费用时,定位同客户的真实换标申请。 */ @Select("SELECT a.id business_id,'RELABEL_ORDER' business_type,a.relabel_no business_no,a.customer_id," + "o.customer_name,o.overseas_warehouse_id warehouse_id,o.id order_id,o.order_type" diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeBusinessRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeBusinessRepository.java index b0f75a24f..bace43b5e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeBusinessRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeBusinessRepository.java @@ -29,6 +29,8 @@ public class OverseasFeeBusinessRepository { public List customers() { return mapper.customers(); } /** 读取本次业务订单类型。 */ public String orderType(long id, String customer) { return mapper.orderType(id, customer); } + /** 读取头程订单目的地类型,参与默认结算方式判定。 */ + public String destinationType(long id, String customer) { return mapper.destinationType(id, customer); } /** 从履约主键解析实际换标单,保留客户隔离。 */ public BusinessOption relabelForOrder(long id, String customer) { return mapper.relabelForOrder(id, customer); } /** 空列表不查询,费用展示采用批量业务事实。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java index 57f521c66..77e1de17d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -79,11 +79,14 @@ public class OverseasFeeSourceService { if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送"); } + /** 旧采购费用子单已有独立资金身份,不能从通用入口再次收费;新采购主单复用人工收费。 */ private static void requireNonProcurement(String sourceType,String businessType) { - if("OVERSEAS_PROCUREMENT_FEE".equals(sourceType) || "OVERSEAS_PROCUREMENT".equals(businessType) || "PROCUREMENT_FEE".equals(businessType)) + if("PROCUREMENT_FEE".equals(businessType) + || "OVERSEAS_PROCUREMENT_FEE".equals(sourceType) && !"OVERSEAS_PROCUREMENT".equals(businessType)) throw conflict("采购费用必须通过采购履约或财务节点确认,实扣成功后自动关联来源"); } + /** 创建真实业务关联费用并保留同键同载荷重放。 */ @Transactional public OverseasFeeSource create(String operatorId, String key, SaveRequest request) { requireNonProcurement(request.sourceType(),request.businessType()); @@ -291,7 +294,10 @@ public class OverseasFeeSourceService { value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim()); value.setFeeRemark(text(request.feeRemark())); value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName())); - value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); + value.setSupplierCategory(text(request.supplierCategory())); + // 新采购费用默认从余额结算,显式选择账单时仍遵守统一海外费用规则。 + value.setSettlementMode("OVERSEAS_PROCUREMENT".equals(request.businessType()) && text(request.settlementMode()) == null + ? "WALLET" : text(request.settlementMode())); value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo())); value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); OverseasFeeCurrencyConverter.Conversion conversion = confirmedConversion != null ? confirmedConversion diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java index 5c35a9f28..648808cfa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java @@ -109,7 +109,7 @@ public class OverseasManualFeeService implements WalletResultHandler { /** 候选使用真实服务业务单和当前客户名称,不允许以车辆车次合并客户费用。 */ public List businessOptions(String type, String customerId, String keyword) { - if (!Set.of("LASTLEG_ORDER", "RELABEL_ORDER", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "US_TRUCK", "LOCAL_LOOSEBOARD", "DISCARD_ORDER", "OVERSEAS_INBOUND", "DELIVERY_ORDER").contains(type)) + if (!Set.of("LASTLEG_ORDER", "RELABEL_ORDER", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "US_TRUCK", "LOCAL_LOOSEBOARD", "DISCARD_ORDER", "OVERSEAS_INBOUND", "DELIVERY_ORDER", "OVERSEAS_PROCUREMENT").contains(type)) throw bad("请选择实际服务业务单"); var options = businesses.options(type, text(customerId), text(keyword)); var names = customerNames.resolve(options.stream() @@ -452,20 +452,28 @@ public class OverseasManualFeeService implements WalletResultHandler { private boolean bill(OverseasFeeSource fee) { if ("RECEIVABLE".equals(fee.getDirection()) && "WALLET".equals(fee.getSettlementMode())) return false; if ("RECEIVABLE".equals(fee.getDirection()) && "BILL".equals(fee.getSettlementMode())) return true; - return !"RECEIVABLE".equals(fee.getDirection()) || "LASTLEG_ORDER".equals(fee.getBusinessType()) - && "FIRST_LEG".equals(businesses.orderType(fee.getBusinessId(), fee.getCustomerId())); + if (!"RECEIVABLE".equals(fee.getDirection())) return true; + if (!"LASTLEG_ORDER".equals(fee.getBusinessType())) return false; + if (!"FIRST_LEG".equals(businesses.orderType(fee.getBusinessId(), fee.getCustomerId()))) return false; + // 头程货物进入自有海外仓后,后续人工费用默认从客户余额/授信扣除;显式 BILL 仍优先。 + return !"OVERSEAS".equals(businesses.destinationType(fee.getBusinessId(), fee.getCustomerId())); } - /** 采购已有专用锁款与履约结算,不允许通用费用入口重复收取。 */ + /** 自动仓租与旧采购子费用已有独立资金身份,新采购主单允许使用人工费用链。 */ private void requireManual(OverseasFeeSource fee) { if(StorageRentFeeWorkflow.isRent(fee))throw conflict("自动仓租请通过仓租审核或调整入口处理"); if (procurement(fee)) throw conflict("采购费用请通过采购履约或财务节点处理"); } - /** 识别采购来源及其工单类型,兼容历史费用数据。 */ + /** 仅旧采购子费用禁止重复进入人工链,新采购主单费用正常冻结和审核。 */ private boolean procurement(OverseasFeeSource fee) { - return "OVERSEAS_PROCUREMENT_FEE".equals(fee.getSourceType()) || "OVERSEAS_PROCUREMENT".equals(fee.getBusinessType()) - || "PROCUREMENT_FEE".equals(fee.getBusinessType()); + return "PROCUREMENT_FEE".equals(fee.getBusinessType()) + || "OVERSEAS_PROCUREMENT_FEE".equals(fee.getSourceType()) && !"OVERSEAS_PROCUREMENT".equals(fee.getBusinessType()); + } + + /** 客户采购详情按精确主单与客户读取费用,资金状态取回执后的统一投影。 */ + public List procurementFees(String customerId, long orderId) { + return describe(sources.listForBusiness(customerId, "OVERSEAS_PROCUREMENT", orderId)); } /** 仓租只由实际出库自动生成,人工收费和调价不得重复录入。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java index 941053742..cfbc7bda4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -26,22 +26,27 @@ public interface OwnedBusinessQueryMapper { String SOURCE=""" SELECT """+POOLED_CARGO+" AS pooled_cargo,"+""" + (EXISTS (SELECT 1 FROM ll_fleet_pickup_task picked WHERE CAST(picked.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND picked.status IN ('PICKED','ARRIVED')) AND NOT EXISTS (SELECT 1 FROM ll_fleet_pickup_task p WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status='ASSIGNED')) AS transport_ready, + d.task_type,t.stops_json AS transport_stops_json,t.status AS transport_trip_status, d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id, d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at, d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json, o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode, o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version, o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at - FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id + FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id LEFT JOIN ll_fleet_trip t ON t.id=d.trip_id """; /** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */ String FILTER=""" d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED') - AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL + AND ((d.task_type IN ('LOCAL_TRUCK','PICKUP') AND d.departed_at IS NOT NULL AND t.status IN ('IN_TRANSIT','COMPLETED') + AND d.stage IN ('DELIVERING','DELIVERED') + AND (d.task_type='LOCAL_TRUCK' OR EXISTS (SELECT 1 FROM ll_fleet_pickup_task picked WHERE CAST(picked.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND picked.status IN ('PICKED','ARRIVED')) AND NOT EXISTS (SELECT 1 FROM ll_fleet_pickup_task p WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status='ASSIGNED'))) + OR (d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL AND o.vehicle_ownership IN ('OWNED','DISPATCHED') AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128)) AND o.loading_completed_at=d.loading_completed_at - AND (o.resource_mode='MASTER' OR (o.vehicle_ownership='DISPATCHED' AND o.resource_mode='EXTERNAL')) + AND (o.resource_mode='MASTER' OR (o.vehicle_ownership='DISPATCHED' AND o.resource_mode='EXTERNAL')))) AND NOT """+POOLED_CARGO+""" AND d.planned_departure_at >= #{q.from} @@ -49,7 +54,7 @@ public interface OwnedBusinessQueryMapper { AND d.stage=#{q.stage} AND d.overseas_warehouse_id=#{q.warehouseId} AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%') - OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i + OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR (d.task_type IN ('LOCAL_TRUCK','PICKUP') AND t.stops_json LIKE CONCAT('%',#{q.keyword},'%')) OR EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%') OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%') OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%')))) @@ -58,7 +63,7 @@ public interface OwnedBusinessQueryMapper { @Select("") List page(@Param("q") Search query); /** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */ - @Select("") + @Select("") List summary(@Param("q") Search query); /** 先读原主单和可空归属,以明确区别无快照与不存在。 */ @Select(SOURCE+" WHERE d.dispatch_no=#{number}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java index c42499c11..bfe57c080 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java @@ -53,7 +53,7 @@ public final class OwnedBusinessModels { public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt, String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver, List destinations,Integer actualPalletCount,Operations operations,String unitPrice, - String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List sourceIssues) {} + String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List sourceIssues,String taskType) {} public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {} public record ListResult(List records,int page,int size,int total,Summary summary) {} public record Cargo(String sourceKey,String customerId,String customerName,String orderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java index 7bdd8d7b5..f88fca52b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java @@ -14,7 +14,8 @@ public final class OwnedBusinessRows { private LocalDateTime ownershipCapturedAt,loadingCompletedAt; } @Data public static class Source { - private Boolean pooledCargo; + private Boolean pooledCargo,transportReady; + private String taskType,transportStopsJson,transportTripStatus; private Long id,recordVersion; private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName; private String loadingCompletionHash,loadingConfirmationJson,resourcesJson; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java index d6edec772..79f546354 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -34,7 +34,8 @@ public class OwnedBusinessProjection { if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key; } /** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */ - public int palletCount(Source source) { + public Integer palletCount(Source source) { + if(isTransport(source))return null; // 场外运输没有仓库实托事实,保持未知。 var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class); if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失"); var ids=new HashSet(); @@ -52,7 +53,7 @@ public class OwnedBusinessProjection { public Row row(Source source,Record record,List cargo,List batches,Actor actor,OwnedBusinessScope scope) { var resources=codec.read(source.getResourcesJson(),Resources.class); if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失"); - var ops=operations(record);var photos=photos(record);int pallets=palletCount(source); + var ops=operations(record);var photos=photos(record);Integer pallets=palletCount(source); if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片"); var arrivals=new HashMap();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt()); var destinations=new LinkedHashMap(); @@ -62,17 +63,33 @@ public class OwnedBusinessProjection { item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment( b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k))); } - String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString(); + if(isTransport(source)) { + var stops=codec.list(source.getTransportStopsJson(),com.baiyue.lastleg.fleet.trip.FleetTripModels.Stop.class); + for(int index=1;index"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()), - new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of()); + new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==null?List.of("运输任务无仓库实托记录,单价未计算"):pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of(),Objects.requireNonNullElse(source.getTaskType(),"DELIVERY")); } + /** 运输起点取真实行程首站,仓库任务仍取原起运仓。 */ + private Origin origin(Source source) { + if(isTransport(source)) { + var stops=codec.list(source.getTransportStopsJson(),com.baiyue.lastleg.fleet.trip.FleetTripModels.Stop.class); + if(!stops.isEmpty())return new Origin(null,null,stops.get(0).name()); + } + return new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()); + } + private boolean isTransport(Source source){return Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(source.getTaskType(),""));} /** 承运商仅取原资源快照,列表不逐车查询当前档案。 */ public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();} /** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java index 05e4a0045..fb95f453b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -90,6 +90,16 @@ public class OwnedBusinessQueryService { } /** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */ private void eligible(Source source,OwnedBusinessScope scope) { + // 运输任务以已取货并发车为运营依据,不冒用仓库实托装车快照。 + if(Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(source.getTaskType(),""))) { + if(scope.isPooledCargo())throw scope.missing(); + if("PICKUP".equals(source.getTaskType()) && !Boolean.TRUE.equals(source.getTransportReady()))throw conflict("请先完成本车全部取货"); + if(!Boolean.FALSE.equals(source.getPooledCargo()) || !"ACTIVE".equals(source.getStatus()) + || !Set.of("DELIVERING","DELIVERED").contains(Objects.toString(source.getStage(),"")) + || source.getDepartedAt()==null || !Set.of("IN_TRANSIT","COMPLETED").contains(Objects.toString(source.getTransportTripStatus(),""))) + throw conflict("运输任务尚未完成取货发车"); + return; + } if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务"); if(!Set.of("OWNED","DISPATCHED").contains(source.getOwnedVehicleOwnership()))throw conflict("装车车辆归属不合法"); if(source.getPooledCargo()==null)throw conflict("本单缺少实际拼车分类依据"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java index bc59230fe..85bf5622a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java @@ -99,6 +99,11 @@ public class OwnedBusinessRecordService { } if(ops.arrivals()==null)throw bad("目的地到达登记不完整"); var destinations=new HashSet();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item)); + // 运输任务按真实行程登记到达,不要求不存在的仓库货物行。 + if(Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(source.getTaskType(),""))) { + var stops=codec.list(source.getTransportStopsJson(),com.baiyue.lastleg.fleet.trip.FleetTripModels.Stop.class); + for(int i=1;i();var arrivals=new ArrayList(); for(var arrival:ops.arrivals()) { if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java index 01b0d6991..949b159ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java @@ -12,7 +12,7 @@ final class TripOperationsCharts { var amounts=new TreeMap>();var rateAmounts=new TreeMap>();var quantities=new TreeMap>(); for(var row:rows) { String day=row.departedAt().atOffset(ZoneOffset.UTC).toLocalDate().toString(); - pallets.merge(day,BigDecimal.valueOf(row.physicalPallets()),BigDecimal::add); + if(row.physicalPallets()!=null)pallets.merge(day,BigDecimal.valueOf(row.physicalPallets()),BigDecimal::add); for(var destination:row.destinations()) {destinations.merge(destination.key(),BigDecimal.ONE,BigDecimal::add);labels.put(destination.key(),destination.label());} for(var money:row.externalAgreedAmounts())amounts.computeIfAbsent(money.currency(),ignored->new TreeMap<>()).merge(day,money.amount(),BigDecimal::add); for(var rate:row.externalPalletRates()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java index 7111a9304..f26f98f9f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java @@ -23,9 +23,18 @@ public final class TripOperationsModels { public record Charts(List physicalPallets,List destinationTrips,List externalAgreedAmounts,List externalPalletRates) { } public record Row(String tripNo, String status, String vehiclePlate, String driverName, String warehouseName, Instant departedAt, Instant completedAt, List destinations, - long physicalPallets, long palletSpaces, long ownPackages, long externalPackages, + Long physicalPallets, Long palletSpaces, long ownPackages, long externalPackages, List externalAgreedAmounts, List externalPalletRates, TripOperationsRecordModels.Details measured, String financeStatus, String costStatus, FleetTripCostModels.Summary costs) { + /** 原仓库调用保留精确整数构造;运输无实托时使用可空事实。 */ + public Row(String tripNo,String status,String vehiclePlate,String driverName,String warehouseName, + Instant departedAt,Instant completedAt,List destinations,long physicalPallets,long palletSpaces, + long ownPackages,long externalPackages,List externalAgreedAmounts,List externalPalletRates, + TripOperationsRecordModels.Details measured,String financeStatus,String costStatus,FleetTripCostModels.Summary costs) { + this(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations, + Long.valueOf(physicalPallets),Long.valueOf(palletSpaces),ownPackages,externalPackages,externalAgreedAmounts, + externalPalletRates,measured,financeStatus,costStatus,costs); + } Row withCosts(FleetTripCostModels.Summary value, String state) { return new Row(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations,physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts,externalPalletRates,measured,financeStatus,state,value); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsQueryMapper.java new file mode 100644 index 000000000..519b80818 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsQueryMapper.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */ +@Mapper +public interface TripOperationsQueryMapper { + @Select(""" + + """) + List departures(@Param("q") TripOperationsModels.Query query); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java index 762fc6041..793871cd6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java @@ -1,32 +1,20 @@ package com.baiyue.lastleg.fleet.reporting; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; -import java.time.*; -import java.util.*; +import java.time.Instant; +import java.util.List; +/** 出车只读事实由 Mapper 读取,运输任务与仓库发车分别取各自历史。 */ @Repository public class TripOperationsRepository { - record Departure(String tripNo, String status, Instant completedAt, String snapshot, String measured) { } - private final JdbcTemplate jdbc; - public TripOperationsRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } - List departures(TripOperationsModels.Query query) { - StringBuilder sql = new StringBuilder(""" - SELECT t.trip_no,t.status,t.completed_at, - (SELECT h.snapshot_json FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' - ORDER BY h.record_version LIMIT 1) AS snapshot_json, - (SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no - ORDER BY r.record_version DESC LIMIT 1) AS measured_json - FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ? - """); - var args = new ArrayList(); - args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC)); - if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); } - if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); } - // Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot. - sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001"); - return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"), - r.getTimestamp("completed_at") == null ? null : r.getTimestamp("completed_at").toLocalDateTime().toInstant(ZoneOffset.UTC), - r.getString("snapshot_json"),r.getString("measured_json")), args.toArray()); + public record Departure(String tripNo, String status, Instant completedAt, String snapshot, String measured, String transportFacts) { + @org.apache.ibatis.annotations.AutomapConstructor + public Departure {} + public Departure(String tripNo,String status,Instant completedAt,String snapshot,String measured) { + this(tripNo,status,completedAt,snapshot,measured,null); + } } + private final TripOperationsQueryMapper mapper; + public TripOperationsRepository(TripOperationsQueryMapper mapper) { this.mapper=mapper; } + List departures(TripOperationsModels.Query query) { return mapper.departures(query); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java index 0826034ea..889b10a89 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java @@ -53,6 +53,8 @@ public class TripOperationsService { if (fact.snapshot()==null) throw new IllegalStateException("实际发车缺少不可变历史,请核对车次"+fact.tripNo()); var snapshot=json.readValue(fact.snapshot(),FleetTripModels.AuditSnapshot.class); var trip=snapshot.trip(); var load=snapshot.confirmedLoad(); + if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),""))) + return transport(fact,trip); if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty()) throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo()); var externalByPallet=new HashMap(); var money=new TreeMap(); var bookingNumbers=new HashSet(); @@ -104,11 +106,29 @@ public class TripOperationsService { fact.measured()==null?null:json.readValue(fact.measured(),TripOperationsRecordModels.Details.class),"NOT_QUERIED","NOT_QUERIED",null); } catch(JsonProcessingException e) { throw new IllegalStateException("发车历史无法读取,请核对车次"+fact.tripNo(),e); } } + /** 运输车次没有仓库实托清单,展示真实行程与取货箱数,实托/托位保持未知。 */ + private TripOperationsModels.Row transport(TripOperationsRepository.Departure fact,FleetTripModels.Response trip) throws JsonProcessingException { + if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2) + throw new IllegalStateException("运输发车历史缺少行程或实际时间"); + var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts()); + if(facts==null || !trip.tripPurpose().equals(facts.path("taskType").asText()))throw new IllegalStateException("运输出车缺少来源依据"); + // 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。 + var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue(); + if(quantity<0)throw new IllegalStateException("运输箱数不合法"); + var destinations=new ArrayList(); + for(int i=1;i rows,boolean costVisible) { var money=new TreeMap(); rows.forEach(row -> row.externalAgreedAmounts().forEach(m -> money.merge(m.currency(),m.amount(),BigDecimal::add))); return new TripOperationsModels.Summary(rows.size(),rows.stream().filter(r -> "IN_TRANSIT".equals(r.status())).count(),rows.stream().filter(r -> "COMPLETED".equals(r.status())).count(), - rows.stream().mapToLong(TripOperationsModels.Row::physicalPallets).sum(),rows.stream().mapToLong(TripOperationsModels.Row::palletSpaces).sum(), + rows.stream().map(TripOperationsModels.Row::physicalPallets).filter(Objects::nonNull).mapToLong(Long::longValue).sum(),rows.stream().map(TripOperationsModels.Row::palletSpaces).filter(Objects::nonNull).mapToLong(Long::longValue).sum(), rows.stream().mapToLong(TripOperationsModels.Row::ownPackages).sum(),rows.stream().mapToLong(TripOperationsModels.Row::externalPackages).sum(),amounts(money),costSummary(rows,costVisible)); } private static TripOperationsModels.CostSummary costSummary(List rows,boolean visible) { 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 new file mode 100644 index 000000000..ba39c60b9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantProcurementWorkflowController.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*; +import com.baiyue.lastleg.procurement.service.*; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +/** 客户只读取并确认自己的采购实收,不能代替平台作业。 */ +@RestController @RequiredArgsConstructor +@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") +public class MerchantProcurementWorkflowController { + private final ProcurementWorkflowService workflow; + private final OverseasProcurementService orders; + /** 查询当前客户采购批次。 */ + @GetMapping("/{orderNo}/workflow") + @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')") + public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo) { return ApiResponse.success(workflow.read(customer(user),orderNo)); } + /** 确认收货照片与实收数量。 */ + @PostMapping("/{orderNo}/confirm") + @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_CREATE+"')") + 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)); + } + /** 只下载本单收货与出库已关联凭证,其他内部文件不外露。 */ + @GetMapping("/{orderNo}/files/{fileId}/content") + @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')") + public ResponseEntity file(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@PathVariable long fileId) { + String customer=customer(user);workflow.requireVisibleFile(customer,orderNo,fileId); + var file=orders.downloadFile(customer,orderNo,fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(file.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()).body(file.content()); + } + /** 客户作用域仅由认证身份解析,不接收请求客户ID。 */ + private String customer(AuthenticatedUser user) { + if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(user.tenantId()<=0 || "PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java index bd18fac9d..d4f3383cd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java @@ -31,8 +31,8 @@ public class ProcurementFileController { case BUDGET->Set.of(OverseasPermissions.PURCHASE_REVIEW); case LABEL->Set.of(OverseasPermissions.PURCHASE_LABEL); case EXCEPTION->Set.of(OverseasPermissions.PURCHASE_MANAGE); - case RECEIPT->Set.of(OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS,OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC); - case VOUCHER->Set.of(OverseasPermissions.PURCHASE_CHARGE,OverseasPermissions.PURCHASE_FINANCE_ADJUST); + case RECEIPT->Set.of(OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS,OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC,OverseasPermissions.PURCHASE_SHIP_CUSTOMER); + case VOUCHER->Set.of(OverseasPermissions.PURCHASE_CHARGE,OverseasPermissions.PURCHASE_FINANCE_ADJUST,OverseasPermissions.PURCHASE_SHIP_CUSTOMER); }; if(user.permissions()==null || allowed.stream().noneMatch(user.permissions()::contains))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该采购文件用途的上传权限"); return ApiResponse.success(service.uploadFile(customerId,orderNo,operator,kind.name(),file)); 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 new file mode 100644 index 000000000..2db0e1f53 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*; +import com.baiyue.lastleg.procurement.service.ProcurementWorkflowService; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; + +/** 平台简化采购作业,所有写入沿用正式收货及出库权限。 */ +@RestController @RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class ProcurementWorkflowController { + private final ProcurementWorkflowService service; + /** 查询客户采购批次与库存。 */ + @GetMapping("/{orderNo}/workflow") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") + public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { + platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); + } + /** 录入分批实收照片和数量。 */ + @PostMapping("/{orderNo}/receipts") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") + 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}/request-confirmation") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") + public ApiResponse requestConfirmation(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Command request) { + return ApiResponse.success(service.requestConfirmation(customerId,orderNo,platform(user,customerId),request)); + } + /** 从采购库存创建出库单。 */ + @PostMapping("/{orderNo}/outbounds") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") + public ApiResponse outbound(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Outbound request) { + return ApiResponse.success(service.outbound(customerId,orderNo,platform(user,customerId),request)); + } + /** 推进具体出库单到派送中。 */ + @PostMapping("/{orderNo}/outbounds/{batchId}/dispatch") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") + public ApiResponse dispatch(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) { + return ApiResponse.success(service.dispatch(customerId,orderNo,batchId,platform(user,customerId),request)); + } + /** 上传签收凭证并完成本批出库。 */ + @PostMapping("/{orderNo}/outbounds/{batchId}/complete") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Completion request) { + return ApiResponse.success(service.complete(customerId,orderNo,batchId,platform(user,customerId),request)); + } +} 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 new file mode 100644 index 000000000..0e2d0cc89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementWorkflowDtos.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.procurement.dto; + +import jakarta.validation.constraints.*; +import java.time.LocalDateTime; +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 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, + @Positive long quantity,@NotBlank @Size(max=255) String trackingNo) { } + public record Completion(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotEmpty @Size(max=20) List<@Positive Long> fileIds) { } + public record ReceiptRow(Long id,long quantity,List fileIds,LocalDateTime receivedAt) { } + public record OutboundRow(Long id,String outboundNo,long quantity,String trackingNo,String status, + List fileIds,LocalDateTime createdAt,LocalDateTime completedAt) { } + 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) { } +} 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 new file mode 100644 index 000000000..586704ab6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementWorkflowMapper.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.procurement.mapper; + +import org.apache.ibatis.annotations.*; +import java.util.List; +import java.util.Map; + +/** 简化采购事实只读写本服务表,订单锁负责串行化库存变动。 */ +@Mapper +public interface ProcurementWorkflowMapper { + /** 查询某次操作的幂等内容。 */ + @Select("SELECT request_hash FROM ll_procurement_workflow_command WHERE order_id=#{orderId} AND command_key=#{key}") + String command(@Param("orderId") long orderId,@Param("key") String key); + /** 记录操作及输入哈希。 */ + @Insert("INSERT INTO ll_procurement_workflow_command(order_id,command_key,request_hash,operator_id) VALUES(#{orderId},#{key},#{hash},#{operator})") + void commandInsert(@Param("orderId") long orderId,@Param("key") String key,@Param("hash") String hash,@Param("operator") String operator); + /** 读取已收货批次。 */ + @Select("SELECT * FROM ll_procurement_workflow_receipt WHERE order_id=#{orderId} ORDER BY received_at,id") + List> receipts(long orderId); + /** 读取出库批次。 */ + @Select("SELECT * FROM ll_procurement_workflow_outbound WHERE order_id=#{orderId} ORDER BY created_at,id") + List> outbounds(long orderId); + /** 保存带照片的分批收货。 */ + @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); + /** 创建占用可用采购库存的出库单。 */ + @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); + /** 派送和签收均要求批次属于已锁定订单。 */ + @Update("UPDATE ll_procurement_workflow_outbound SET status=#{status},file_ids=#{files},completed_at=#{completedAt} WHERE id=#{id} AND order_id=#{orderId}") + void updateOutbound(@Param("id") long id,@Param("orderId") long orderId,@Param("status") String status,@Param("files") String files,@Param("completedAt") java.time.LocalDateTime completedAt); +} 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 new file mode 100644 index 000000000..0e4c8842a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementWorkflowRepository.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.procurement.repository; + +import com.baiyue.lastleg.procurement.entity.*; +import com.baiyue.lastleg.procurement.mapper.*; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import java.util.*; + +/** 采购聚合持久化入口,客户隔离在订单读取时固定。 */ +@Repository @RequiredArgsConstructor +public class ProcurementWorkflowRepository { + private final ProcurementWorkflowMapper facts; + private final OverseasProcurementOrderMapper orders; + private final OverseasProcurementFileMapper files; + /** 按客户读取或锁定订单。 */ + public OverseasProcurementOrder order(String customer,String no,boolean lock) { return lock?orders.lockNo(customer,no):orders.findNo(customer,no); } + /** 保存版本及主状态。 */ + public void save(OverseasProcurementOrder order) { orders.updateById(order); } + /** 读取操作幂等哈希。 */ + public String command(long id,String key) { return facts.command(id,key); } + /** 保存操作幂等哈希。 */ + public void command(long id,String key,String hash,String operator) { facts.commandInsert(id,key,hash,operator); } + /** 读取收货事实。 */ + public List> receipts(long id) { return facts.receipts(id); } + /** 读取出库事实。 */ + 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 void outbound(long id,long orderId,String no,long quantity,String tracking,String operator) { facts.outbound(id,orderId,no,quantity,tracking,operator); } + /** 更新出库批次。 */ + public void outbound(long id,long orderId,String status,String files,java.time.LocalDateTime completedAt) { facts.updateOutbound(id,orderId,status,files,completedAt); } + /** 文件仅按当前订单及客户返回,不能引用其他采购单文件。 */ + public List files(OverseasProcurementOrder order) { + return files.selectList(new LambdaQueryWrapper().eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getCustomerId,order.getCustomerId())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 8894c6e1c..e997178bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -49,7 +49,7 @@ public class OverseasProcurementService { public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String WAIT_PURCHASE = "WAIT_PURCHASE"; public static final String REJECTED = "REJECTED"; - private static final Set STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "WAIT_OVERSEAS_RECEIPT", + private static final Set STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "RECEIVING", "WAIT_CUSTOMER_CONFIRM", "CONFIRMED", "IN_TRANSIT", "DELIVERING", "COMPLETED", "WAIT_OVERSEAS_RECEIPT", "OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "WAIT_OVERSEAS_SHIPMENT", "INTERNATIONAL_TRANSIT", "DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "WAIT_CUSTOMER_SHIPMENT", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED"); @@ -213,11 +213,12 @@ public class OverseasProcurementService { value.getWalletStatus(), value.getWalletErrorMessage(), value.getCreatedAt()); } + /** 审核通过后直接进入分批收货,不再要求旧购买及装箱节点。 */ @Transactional public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId, OverseasProcurementRequests.Review request) { String next = switch (request.decision().trim().toUpperCase()) { - case "APPROVE" -> WAIT_PURCHASE; + case "APPROVE" -> "RECEIVING"; case "REJECT" -> REJECTED; default -> throw badRequest("Review decision must be APPROVE or REJECT"); }; @@ -241,7 +242,7 @@ public class OverseasProcurementService { throw badRequest("请填写拒绝原因"); } Map before = reviewSnapshot(order); - if (WAIT_PURCHASE.equals(next)) { + if ("RECEIVING".equals(next)) { if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择与采购方向匹配的收货仓"); var warehouse = ProcurementDirection.EXPORT.equals(direction) ? warehouseDirectory.required(request.purchaseWarehouseId(), direction) : warehouseDirectory.required(request.purchaseWarehouseId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java index 590501678..973283fc9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java @@ -217,7 +217,9 @@ public class ProcurementLifecycleService { if(order==null) throw bad("采购订单不存在或不属于当前客户"); return order; } + /** 新采购禁止通过旧履约命令绕过客户确认及库存占用。 */ boolean replay(OverseasProcurementOrder order,String action,String key,Object request) { + if(ProcurementWorkflowService.STATUSES.contains(order.getStatus())) throw conflict("请使用简化采购收货、客户确认和出库流程"); if(key==null || key.isBlank() || key.length()>128) throw bad("操作幂等键无效"); var previous=jdbc.queryForList("SELECT request_hash FROM ll_procurement_command WHERE order_id=? AND action=? AND idempotency_key=?",String.class,order.getId(),action,key); if(previous.isEmpty()) return false; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java index 3b33d82b4..d1c63b033 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java @@ -16,6 +16,7 @@ import java.util.*; /** Browser detail aggregation; the customer result is a separate allowlist, never a filtered platform map. */ @Service @RequiredArgsConstructor public class ProcurementQueryService { + @jakarta.annotation.Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees; private final ProcurementLifecycleService lifecycle; private final ProcurementPhysicalService physical; private final ProcurementTransportService transport; @@ -42,6 +43,14 @@ public class ProcurementQueryService { return new CustomerFee(fee.getFeeNo(),fee.getPurpose(),fee.getFeeItemName(),fee.getFeeVersion(),Boolean.TRUE.equals(fee.getActive()), fee.getDetermination(),fee.getAmount(),fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(),fee.getExchangeRateMonth(),fee.getBusinessDate(),parentNo,fee.getWalletStatus(),linked); } + /** 客户仅看到本采购单应收结果,不透出内部审核说明、资金凭证或供应商资料。 */ + public static CustomerFee fromManual(com.baiyue.lastleg.financefee.entity.OverseasFeeSource fee) { + return new CustomerFee(String.valueOf(fee.getSourceFeeId()), fee.getFeeItemCode(), fee.getFeeItemName(), + fee.getSourceVersion(), "ACTIVE".equals(fee.getSourceStatus()), fee.getReviewStatus(), fee.getAmount(), + fee.getCurrency(), fee.getOriginalAmount(), fee.getOriginalCurrency(), fee.getExchangeRate(), + fee.getExchangeRateMonth(), fee.getBusinessDate(), null, fee.getWalletStatus(), + "PUBLISHED".equals(fee.getPublicationStatus())); + } } public record CustomerView(Header order,Budget budget,boolean purchased,boolean purchaseConfirmed,boolean purchaseNeedsAttention, List progress,List boxes,List shipments,List fees,List exceptionSummaries) { } @@ -56,6 +65,7 @@ public class ProcurementQueryService { public CustomerView customer(String customer,String orderNo) { var order=lifecycle.requireOrder(customer,orderNo);return customer(order); } + /** 组装客户采购投影;新增费用与旧费用使用同一公开白名单。 */ private CustomerView customer(OverseasProcurementOrder order) { var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map feeNos=new HashMap<>();fees.forEach(f->feeNos.put(f.getId(),f.getFeeNo())); var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),order.getProductSpecification(),order.getProductUrl(), @@ -86,8 +96,14 @@ public class ProcurementQueryService { (rs,n)->new Shipment(rs.getString(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime(),rs.getString(4),rs.getLong(5),rs.getString(6),rs.getString(7)),order.getId()); var summaries=new ArrayList<>(jdbc.queryForList("SELECT client_summary FROM ll_procurement_control_note WHERE order_id=? AND client_summary IS NOT NULL AND TRIM(client_summary)<>'' ORDER BY id",String.class,order.getId())); summaries.addAll(jdbc.queryForList("SELECT client_summary FROM ll_procurement_closure WHERE order_id=?",String.class,order.getId())); + List customerFees = new ArrayList<>(fees.stream() + .map(f->CustomerFee.from(f,feeNos.get(f.getParentFeeId()),finance.sourceLinked(f))).toList()); + customerFees.addAll(manualFees.procurementFees(order.getCustomerId(), order.getId()).stream() + .filter(f -> "RECEIVABLE".equals(f.getDirection()) && "OVERSEAS_PROCUREMENT".equals(f.getBusinessType()) + && order.getCustomerId().equals(f.getCustomerId()) && order.getId().equals(f.getBusinessId())) + .map(CustomerFee::fromManual).toList()); return new CustomerView(header,budget,flow.get("purchased_at")!=null,flow.get("purchase_confirmed_at")!=null,flow.get("purchase_issue")!=null,List.copyOf(progress),boxes,shipments, - fees.stream().map(f->CustomerFee.from(f,feeNos.get(f.getParentFeeId()),finance.sourceLinked(f))).toList(),List.copyOf(summaries)); + List.copyOf(customerFees),List.copyOf(summaries)); } @Transactional(readOnly=true) public PlatformView platform(String customer,String orderNo,Set permissions) { @@ -96,7 +112,9 @@ public class ProcurementQueryService { return new PlatformView(customer(order),flow,physicalView,transportView,finance.all(order.getId()),adjustments.history(customer,orderNo),controls.read(customer,orderNo),base.files(),base.histories(), jdbc.queryForList("SELECT action,operator_id,reason,detail_json,created_at FROM ll_procurement_command WHERE order_id=? ORDER BY id DESC",order.getId()),actions(order,flow,physicalView,transportView,permissions),base.remark()); } + /** 新采购通过批次命令校验资格,旧样品动作不再对七主状态开放。 */ private List actions(OverseasProcurementOrder order,Map flow,ProcurementPhysicalService.Snapshot physicalView,ProcurementTransportService.Snapshot transportView,Set permissions) { + if (ProcurementWorkflowService.STATUSES.contains(order.getStatus())) return List.of(); boolean closed=Set.of("REJECTED","CANCELLED","EXCEPTION_CLOSED","SHIPPED_TO_CUSTOMER").contains(order.getStatus()),purchased=flow.get("purchased_at")!=null,international=flow.get("overseas_shipped_at")!=null; String stopped=closed?"订单已结束":null; String blockers=reason(()->lifecycle.requireNoBlockers(order)); 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 new file mode 100644 index 000000000..1812af3ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java @@ -0,0 +1,189 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository; +import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.security.MessageDigest; +import java.util.*; +import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.bad; +import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.conflict; + +/** 采购仅保留审核、分批收货、客户确认与分批出库;库存始终属于采购单。 */ +@Service @RequiredArgsConstructor +public class ProcurementWorkflowService { + public static final Set STATUSES=Set.of("PENDING_REVIEW","RECEIVING","WAIT_CUSTOMER_CONFIRM","CONFIRMED","IN_TRANSIT","DELIVERING","COMPLETED"); + private final ProcurementWorkflowRepository repository; + private final LastlegDocumentNumberGenerator numbers; + private final ObjectMapper json; + private final OverseasFeeSourceService fees; + + /** 客户和平台共享实际批次事实,按客户及单号读取。 */ + @Transactional(readOnly=true) + public Workflow read(String customer,String no) { return snapshot(order(customer,no,false)); } + + /** 每次实收须上传本单照片,不得超过下单采购数量。 */ + @Transactional + public Workflow receive(String customer,String no,String operator,Receipt request) { + var order=order(customer,no,true); + if(replay(order,request.idempotencyKey(),"RECEIVE",request)) return snapshot(order); + version(order,request.version());state(order,"RECEIVING"); + var current=snapshot(order); + if(request.quantity()<=0 || request.quantity()>order.getPurchaseQuantity()-current.receivedQuantity()) throw bad("收货数量超过未收采购数量"); + files(order,request.fileIds(),true); + repository.receipt(IdWorker.getId(),order.getId(),request.quantity(),serialize(request.fileIds()),operator); + order.setInboundQuantity(current.receivedQuantity()+request.quantity()); + finish(order,operator,request.idempotencyKey(),"RECEIVE",request); + return snapshot(order); + } + + /** 采购完成后冻结实收结果,交给客户确认。 */ + @Transactional + public Workflow requestConfirmation(String customer,String no,String operator,Command request) { + var order=order(customer,no,true); + 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("请先登记收货照片和数量"); + order.setStatus("WAIT_CUSTOMER_CONFIRM"); + finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request); + return snapshot(order); + } + + /** 客户只能确认自己采购单的当前实收结果。 */ + @Transactional + public Workflow confirm(String customer,String no,String operator,Command request) { + var order=order(customer,no,true); + if(replay(order,request.idempotencyKey(),"CUSTOMER_CONFIRM",request)) return snapshot(order); + version(order,request.version());state(order,"WAIT_CUSTOMER_CONFIRM"); + order.setStatus("CONFIRMED"); + finish(order,operator,request.idempotencyKey(),"CUSTOMER_CONFIRM",request); + return snapshot(order); + } + + /** 创建出库先占用库存,避免其他批次重复使用相同数量;完成才实际扣除。 */ + @Transactional + public Workflow outbound(String customer,String no,String operator,Outbound request) { + var order=order(customer,no,true); + if(replay(order,request.idempotencyKey(),"OUTBOUND",request)) return snapshot(order); + version(order,request.version());state(order,"CONFIRMED","IN_TRANSIT","DELIVERING"); + requireReviewedFees(order); + if(request.quantity()<=0 || request.quantity()>snapshot(order).availableQuantity()) throw conflict("出库数量超过采购可用库存"); + if(request.trackingNo()==null || request.trackingNo().isBlank() || request.trackingNo().length()>255) throw bad("请填写物流跟踪号"); + repository.outbound(IdWorker.getId(),order.getId(),numbers.next("CK"),request.quantity(),request.trackingNo().trim(),operator); + refreshStatus(order); + finish(order,operator,request.idempotencyKey(),"OUTBOUND",request); + return snapshot(order); + } + + /** 本单有效应收须先添加并全部审核通过,作废费用不能替代费用明细。 */ + private void requireReviewedFees(OverseasProcurementOrder order) { + var current=fees.listForBusiness(order.getCustomerId(),"OVERSEAS_PROCUREMENT",order.getId()).stream() + .filter(f->"RECEIVABLE".equals(f.getDirection()) && !"VOIDED".equals(f.getSourceStatus())).toList(); + if(current.isEmpty()) throw conflict("请先添加费用明细并审核通过后再出库"); + if(current.stream().anyMatch(f->!"ACTIVE".equals(f.getSourceStatus()) || !"APPROVED".equals(f.getReviewStatus()))) + throw conflict("费用明细尚未全部审核通过,请先完成费用审核后再出库"); + } + + /** 按具体出库批次推进到派送中。 */ + @Transactional + public Workflow dispatch(String customer,String no,long batchId,String operator,Command request) { + var order=order(customer,no,true);String action="DISPATCH:"+batchId; + if(replay(order,request.idempotencyKey(),action,request)) return snapshot(order); + version(order,request.version());var batch=batch(order,batchId); + if(!"IN_TRANSIT".equals(batch.status())) throw conflict("仅运输中的出库单可开始派送"); + repository.outbound(batchId,order.getId(),"DELIVERING","[]",null); + refreshStatus(order);finish(order,operator,request.idempotencyKey(),action,request); + return snapshot(order); + } + + /** 签收凭证关联到本批次;同事务完成批次并扣除采购库存,重试不重复扣减。 */ + @Transactional + public Workflow complete(String customer,String no,long batchId,String operator,Completion request) { + var order=order(customer,no,true);String action="COMPLETE:"+batchId; + if(replay(order,request.idempotencyKey(),action,request)) return snapshot(order); + version(order,request.version());var batch=batch(order,batchId); + if(!"DELIVERING".equals(batch.status())) throw conflict("仅派送中的出库单可签收完成"); + files(order,request.fileIds(),false); + repository.outbound(batchId,order.getId(),"COMPLETED",serialize(request.fileIds()),LocalDateTime.now()); + order.setInboundQuantity(snapshot(order).stockQuantity()); + refreshStatus(order);finish(order,operator,request.idempotencyKey(),action,request); + return snapshot(order); + } + + /** 客户下载只允许查看已经关联实收或签收批次的凭证。 */ + public void requireVisibleFile(String customer,String no,long fileId) { + if(read(customer,no).files().stream().noneMatch(f->f.fileId()==fileId)) throw bad("文件未关联当前采购收货或出库批次"); + } + + /** 订单主状态优先展示未完成批次,库存尚有剩余时允许继续创建出库。 */ + private void refreshStatus(OverseasProcurementOrder order) { + var current=snapshot(order); + if(current.outbounds().stream().anyMatch(b->"IN_TRANSIT".equals(b.status()))) order.setStatus("IN_TRANSIT"); + else if(current.outbounds().stream().anyMatch(b->"DELIVERING".equals(b.status()))) order.setStatus("DELIVERING"); + else order.setStatus(current.stockQuantity()==0?"COMPLETED":"CONFIRMED"); + } + + /** 汇总采购库存,只有已完成出库才扣库存,进行中的出库数量归为占用。 */ + private Workflow snapshot(OverseasProcurementOrder order) { + var receipts=repository.receipts(order.getId()).stream().map(r->new ReceiptRow(number(r,"id"),number(r,"quantity"),ids(r.get("file_ids")),date(r.get("received_at")))).toList(); + var outbounds=repository.outbounds(order.getId()).stream().map(r->new OutboundRow(number(r,"id"),text(r,"outbound_no"),number(r,"quantity"),text(r,"tracking_no"),text(r,"status"),ids(r.get("file_ids")),date(r.get("created_at")),date(r.get("completed_at")))).toList(); + long received=receipts.stream().mapToLong(ReceiptRow::quantity).sum(); + long completed=outbounds.stream().filter(b->"COMPLETED".equals(b.status())).mapToLong(OutboundRow::quantity).sum(); + long reserved=outbounds.stream().filter(b->!"COMPLETED".equals(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); + } + + /** 文件数量、客户归属、用途和照片类型均由服务端验证。 */ + private void files(OverseasProcurementOrder order,List ids,boolean photos) { + if(ids==null || ids.isEmpty() || ids.size()>20 || new HashSet<>(ids).size()!=ids.size()) throw bad("请上传凭证,最多20个且不可重复"); + var available=repository.files(order); + for(Long id:ids) { + var file=available.stream().filter(f->Objects.equals(f.getFileId(),id)).findFirst().orElseThrow(()->bad("凭证不属于本采购单")); + if(!Set.of("RECEIPT","VOUCHER").contains(file.getCategory())) throw bad("凭证用途不符合收货或出库要求"); + if(photos && (file.getContentType()==null || !file.getContentType().startsWith("image/"))) throw bad("收货凭证须为图片"); + } + } + + /** 批次必须属于当前已通过客户隔离校验的采购单。 */ + private OutboundRow batch(OverseasProcurementOrder order,long id) { return snapshot(order).outbounds().stream().filter(b->b.id()==id).findFirst().orElseThrow(()->bad("出库单不属于本采购单")); } + /** 按客户锁单,全部库存变更在同一事务串行执行。 */ + private OverseasProcurementOrder order(String customer,String no,boolean lock) { var order=repository.order(customer,no,lock);if(order==null) throw bad("采购单不存在或不属于当前客户");return order; } + /** 版本变化时必须刷新,防止覆盖其他操作。 */ + private void version(OverseasProcurementOrder order,long version) { if(order.getVersion()!=version) throw conflict("采购单已更新,请刷新后重试"); } + /** 拒绝跳过客户确认等前置状态。 */ + private void state(OverseasProcurementOrder order,String... allowed) { if(!Set.of(allowed).contains(order.getStatus())) throw conflict("当前采购状态不支持此操作"); } + /** 同一个键跨动作或改参数重用均拒绝;已提交请求可忽略旧版本重放。 */ + private boolean replay(OverseasProcurementOrder order,String key,String action,Object request) { + if(key==null || key.isBlank() || key.length()>128) throw bad("操作幂等键无效"); + String prior=repository.command(order.getId(),key); + if(prior==null) return false; + if(!prior.equals(hash(action,request))) throw conflict("相同幂等键不可用于其他操作或参数"); + return true; + } + /** 幂等事实与订单版本同事务保存。 */ + private void finish(OverseasProcurementOrder order,String operator,String key,String action,Object request) { + repository.command(order.getId(),key,hash(action,request),operator); + order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now());repository.save(order); + } + /** 稳定序列化动作及参数生成操作指纹。 */ + private String hash(String action,Object request) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+serialize(request)).getBytes(java.nio.charset.StandardCharsets.UTF_8))); } catch(Exception e) { throw new IllegalStateException(e); } } + /** 文件主键按JSON存储,避免数据库JSON驱动差异。 */ + private String serialize(Object value) { try { return json.writeValueAsString(value); } catch(Exception e) { throw new IllegalStateException(e); } } + /** 还原文件主键列表。 */ + private List ids(Object value) { try { return json.readValue(String.valueOf(value),json.getTypeFactory().constructCollectionType(List.class,Long.class)); } catch(Exception e) { throw new IllegalStateException("采购凭证记录格式异常",e); } } + /** 统一驱动返回的时间类型。 */ + private LocalDateTime date(Object value) { if(value==null)return null;if(value instanceof LocalDateTime d)return d;return ((java.sql.Timestamp)value).toLocalDateTime(); } + /** 读取数字事实。 */ + private long number(Map row,String key) { return ((Number)row.get(key)).longValue(); } + /** 读取文本事实。 */ + private String text(Map row,String key) { return String.valueOf(row.get(key)); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java index b1afcaa80..e5b72a414 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -84,6 +84,16 @@ public class PlatformUsTruckOrderController { return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request)); } + @PostMapping("/{orderNo}/arrange") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + @Operation(summary = "Arrange an approved local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) + public ApiResponse arrange(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.Arrange request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.arrange(requiredCustomer(customerId), orderNo, operator(user), request)); + } + @PostMapping("/{orderNo}/receive-pallets") @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") @Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java index 22e6a1ab7..6f1f5f8c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -90,6 +90,19 @@ public final class UsTruckOrderRequests { @NotNull @Min(0) Long version, @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark, + @Positive Long warehouseId) { + /** 兼容旧调用;新审核通过应携带真实调度仓库。 */ + public Review(Long version, String decision, String idempotencyKey, String remark) { + this(version, decision, idempotencyKey, remark, null); + } + } + + /** 已审核订单补建本土卡车排车。 */ + public record Arrange( + @NotNull @Min(0) Long version, + @NotNull @Positive Long warehouseId, + @NotBlank @Size(min = 8, max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} public record ReviewPalletOverage( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index a0c9b6635..5251e8789 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -23,6 +23,8 @@ import org.springframework.web.server.ResponseStatusException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.Instant; import java.util.HexFormat; import java.util.HashSet; import java.util.List; @@ -56,6 +58,7 @@ public class UsTruckOrderService { @Resource private com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository repository; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks; /** 创建卡车需求,校验两端资料并保留创建幂等结果。 */ @Transactional @@ -150,7 +153,7 @@ public class UsTruckOrderService { order.getStatus(), CANCELLED, request.remark()); } - /** 审核订单,仅对真实已知预报建立待收托盘。 */ + /** 审核订单;通过后直接进入排车,取货与送达事实由排车任务记录。 */ @Transactional public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Review request) { @@ -159,10 +162,38 @@ public class UsTruckOrderService { if (replay != null) return replay; requireVersion(order, request.version()); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed"); - String next = "APPROVE".equals(norm(request.decision())) ? WAITING_INBOUND : REJECTED; - if (WAITING_INBOUND.equals(next)) createPlannedPallets(order); - return transition(order, operatorId, "REVIEW", request.idempotencyKey(), request, + boolean approve = "APPROVE".equals(norm(request.decision())); + if (approve && request.warehouseId() == null) throw badRequest("审核通过时必须选择真实调度仓库"); + String next = approve ? PENDING_DISPATCH : REJECTED; + UsTruckOrderResponses.Detail result = transition(order, operatorId, "REVIEW", request.idempotencyKey(), request, PENDING_REVIEW, next, request.remark()); + // 审核通过即按真实 A 仓生成本土卡车排车;仓库缺失时保留待排车状态,供补建入口完成。 + if (approve) { + arrangeApproved(order, operatorId, request.warehouseId(), "truck-review:" + request.idempotencyKey()); + result = detail(customerId, orderNo); + } + return result; + } + + /** 为已审核但尚未接入排车的本土卡车单补建运输任务。 */ + @Transactional + public UsTruckOrderResponses.Detail arrange(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Arrange request) { + UsTruckOrder order = lock(customerId, orderNo); + requireVersion(order, request.version()); + if (!PENDING_DISPATCH.equals(order.getStatus())) throw conflict("仅待取货订单可以安排本土卡车"); + arrangeApproved(order, operatorId, request.warehouseId(), request.idempotencyKey()); + return detail(customerId, orderNo); + } + + /** 复用统一运输编排,使用订单计划时间创建唯一的本土卡车排车。 */ + private void arrangeApproved(UsTruckOrder order, String operatorId, Long warehouseId, String idempotencyKey) { + if (transportTasks == null) return; // 纯订单单测不装配运输编排;Spring 运行时始终注入。 + Instant departure = order.getPickupAt() == null ? Instant.now() : order.getPickupAt().atZone(ZoneId.systemDefault()).toInstant(); + Instant driverEnd = order.getDeliveryAt() == null ? departure.plusSeconds(24 * 3600) : order.getDeliveryAt().atZone(ZoneId.systemDefault()).toInstant(); + if (!driverEnd.isAfter(departure)) driverEnd = departure.plusSeconds(24 * 3600); + transportTasks.createTruck(operatorId, new com.baiyue.lastleg.dispatch.transport.TransportTaskModels.TruckCreate( + order.getCustomerId(), order.getOrderNo(), warehouseId, departure, driverEnd, idempotencyKey)); } /** 记录实际收货,首次实收确定独立订单托数。 */ diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 7cefc3ead..335836035 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -4799,3 +4799,38 @@ DEALLOCATE PREPARE storage_rent_sku_stmt; -- 首版拆柜成本只记录分摊依据,客户应收另行人工登记审核;重复执行保持相同定义。 ALTER TABLE ll_destuff_pallet_cost_allocation MODIFY COLUMN fee_source_id BIGINT NULL COMMENT '人工审核后关联的海外费用来源ID'; + +-- 简化采购:独立采购库存,收货、占用和签收扣减均由订单锁串行控制。 +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt ( + id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键', + order_id BIGINT NOT NULL COMMENT '采购单主键', + quantity BIGINT NOT NULL COMMENT '本批次数量', + file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表', + received_at DATETIME(3) NOT NULL COMMENT '本批次实收时间', + created_by VARCHAR(128) NOT NULL COMMENT '作业人标识', + KEY idx_procurement_workflow_receipt (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批收货照片与实收数量'; + +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound ( + id BIGINT NOT NULL PRIMARY KEY COMMENT '批次主键', + order_id BIGINT NOT NULL COMMENT '采购单主键', + outbound_no VARCHAR(64) NOT NULL COMMENT '采购出库单号', + quantity BIGINT NOT NULL COMMENT '本批次数量', + tracking_no VARCHAR(255) NOT NULL COMMENT '物流跟踪号', + status VARCHAR(32) NOT NULL COMMENT '批次运输派送及签收状态', + file_ids TEXT NOT NULL COMMENT '本批次关联凭证文件主键列表', + created_at DATETIME(3) NOT NULL COMMENT '记录创建时间', + completed_at DATETIME(3) NULL COMMENT '本批次签收完成时间', + created_by VARCHAR(128) NOT NULL COMMENT '作业人标识', + UNIQUE KEY uk_procurement_workflow_outbound_no (outbound_no), + KEY idx_procurement_workflow_outbound (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购分批出库物流与签收凭证'; + +CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command ( + order_id BIGINT NOT NULL COMMENT '采购单主键', + command_key VARCHAR(128) NOT NULL COMMENT '本单操作幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '动作及请求参数哈希', + operator_id VARCHAR(128) NOT NULL COMMENT '作业人标识', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + PRIMARY KEY (order_id,command_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml index 2bdb4cd4c..1059082a9 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -597,11 +597,13 @@ }, "physicalPallets": { "type": "integer", - "minimum": 0 + "minimum": 0, + "nullable": true }, "palletSpaces": { "type": "integer", - "minimum": 0 + "minimum": 0, + "nullable": true }, "ownPackages": { "type": "integer", diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index 201a973f8..882fc618b 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -557,6 +557,7 @@ components: type: "string" nullable: true ownership: + nullable: true type: "string" enum: - "OWNED" @@ -715,6 +716,9 @@ components: type: "array" items: "$ref": "#/components/schemas/Destination" + taskType: + type: string + enum: [DELIVERY, LOCAL_TRUCK, PICKUP] actualPalletCount: type: "integer" minimum: 0 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 94b9dfdc2..5ca5d0706 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -5077,7 +5077,7 @@ components: actualVolumeCbm: { type: number, nullable: true } status: { type: string } version: { type: integer } - ProcurementStatus: { type: string, enum: [PENDING_REVIEW, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, WAIT_OVERSEAS_SHIPMENT, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, WAIT_CUSTOMER_SHIPMENT, SHIPPED_TO_CUSTOMER, REJECTED, CANCELLED, EXCEPTION_CLOSED] } + ProcurementStatus: { type: string, enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED, REJECTED, CANCELLED, EXCEPTION_CLOSED, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, WAIT_OVERSEAS_SHIPMENT, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, WAIT_CUSTOMER_SHIPMENT, SHIPPED_TO_CUSTOMER] } ProcurementReview: type: object required: [version, decision, idempotencyKey] diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml index 7f810acea..0b22dc215 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -20,7 +20,7 @@ paths: responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } } post: summary: 创建人工费用并锁定资金 - description: 需platform:finance:fee-source:edit。创建即PENDING。应收仅当前LASTLEG_ORDER且orderType=FIRST_LEG采用BILL,其余业务(含ONE_CLICK_DROPSHIP、退货、转运、柜级费用)为WALLET,发送异步FREEZE,本土卡车、本土散板应收同采购一样使用客户余额及授信扣款;余额及授信不足显示FREEZE_FAILED,不可审核通过。原USD按业务月有效汇率固化为CNY后冻结;原币与汇率快照保留。采购来源仍只允许采购业务节点处理。业务ID、客户、单号与补充订单/入仓关联由服务端核对;真实柜要求businessId=containerBookingId且businessNo=containerNo。调用重放不重复冻结。 + description: 需platform:finance:fee-source:edit。创建即PENDING。应收仅当前LASTLEG_ORDER且orderType=FIRST_LEG采用BILL,其余业务(含ONE_CLICK_DROPSHIP、退货、转运、柜级费用)为WALLET,发送异步FREEZE,本土卡车、本土散板应收同采购一样使用客户余额及授信扣款;余额及授信不足显示FREEZE_FAILED,不可审核通过。原USD按业务月有效汇率固化为CNY后冻结;原币与汇率快照保留。采购主单OVERSEAS_PROCUREMENT复用本入口,未传结算方式默认WALLET;旧PROCUREMENT_FEE子费用仍只由原采购节点处理。业务ID、客户、单号与补充订单/入仓关联由服务端核对;真实柜要求businessId=containerBookingId且businessNo=containerNo。调用重放不重复冻结。 security: [{ bearerAuth: [] }] parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } } @@ -86,7 +86,7 @@ paths: post: summary: 提交、审核、调整、作废或处理资金 x-headleg-delivery-review: 首版头程派送费由审核员核对原报价是否已包含;当前未实现自动识别。已含费用不应再建单或通过审核,另收费用人工添加后审核进入头程账单。 - description: 编辑动作需platform:finance:fee-source:edit,review与void需platform:finance:fee-source:review。WALLET通过审核只发送SETTLE,实际匹配成功回执含冻结与账本引用后才PUBLISHED,此时仅本地费用完成、不推送财务账单;失败为SETTLE_FAILED,保持未收。驳回释放冻结,冻结在途时回执成功后自动释放。驳回后的费用只读,不能继续操作;如需重新收费应在业务单据中新增费用。头程BILL审核通过推送结算;已发布BILL作废等待Finance持久化确认才VOIDED。历史无资金记录的海外费用必须重新提交完成冻结,不允许审核绕过。采购来源仍由采购节点处理。 + description: 编辑动作需platform:finance:fee-source:edit,review与void需platform:finance:fee-source:review。WALLET通过审核只发送SETTLE,实际匹配成功回执含冻结与账本引用后才PUBLISHED,此时仅本地费用完成、不推送财务账单;失败为SETTLE_FAILED,保持未收。驳回释放冻结,冻结在途时回执成功后自动释放。驳回后的费用只读,不能继续操作;如需重新收费应在业务单据中新增费用。头程BILL审核通过推送结算;已发布BILL作废等待Finance持久化确认才VOIDED。历史无资金记录的海外费用必须重新提交完成冻结,不允许审核绕过。采购主单费用沿此审核扣款链,旧PROCUREMENT_FEE子费用仍由原采购节点处理。 security: [{ bearerAuth: [] }] parameters: - { in: path, name: sourceFeeId, required: true, schema: { type: string } } diff --git a/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml b/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml new file mode 100644 index 000000000..860f1703d --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml @@ -0,0 +1,331 @@ +openapi: 3.0.3 +info: + title: 简化海外采购批次流程 API + version: 1.0.0 + description: 创建采购单后为待审核;审核通过进入收货中。实收可分批登记,客户确认后按采购库存分批出库。库存仅在本批次签收完成时扣减,创建出库单先占用可用库存。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/workflow: + get: + summary: 读取采购实收、库存和出库批次 + description: 权限 platform:overseas-business:purchase:list;仅平台主体,必须指定目标客户 customerId。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - $ref: '#/components/parameters/CustomerId' + responses: + '200': + $ref: '#/components/responses/WorkflowResponse' + '401': + description: 未认证 + '403': + description: 身份或操作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/receipts: + post: + summary: 分批登记实收数量和照片 + description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING;本次数量不得超过未收采购数量。文件须已上传到本采购单,须为图片,分类为 RECEIPT 或 VOUCHER。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - $ref: '#/components/parameters/CustomerId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReceiptCommand' + responses: + '200': + $ref: '#/components/responses/WorkflowResponse' + '401': + description: 未认证 + '403': + description: 身份或操作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/request-confirmation: + post: + summary: 采购完成并请求客户确认 + description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;提交后不再追加收货。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - $ref: '#/components/parameters/CustomerId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + responses: + '200': + $ref: '#/components/responses/WorkflowResponse' + '401': + description: 未认证 + '403': + description: 身份或操作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds: + post: + summary: 从采购库存创建出库单 + description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。创建后占用数量增加,不实际扣库存。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - $ref: '#/components/parameters/CustomerId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OutboundCommand' + responses: + '200': + $ref: '#/components/responses/WorkflowResponse' + '401': + description: 未认证 + '403': + description: 身份或操作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/dispatch: + post: + summary: 本批出库开始派送 + description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅本单 IN_TRANSIT 批次可转为 DELIVERING。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - $ref: '#/components/parameters/CustomerId' + - $ref: '#/components/parameters/BatchId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + responses: + '200': + $ref: '#/components/responses/WorkflowResponse' + '401': + description: 未认证 + '403': + description: 身份或操作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/complete: + post: + summary: 上传凭证并完成本批出库 + description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅本单 DELIVERING 批次;凭证须属于本单且分类为 RECEIPT 或 VOUCHER,支持文件或图片。批次完成与采购库存扣减同事务,重复请求不重复扣减。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - $ref: '#/components/parameters/CustomerId' + - $ref: '#/components/parameters/BatchId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CompletionCommand' + responses: + '200': + $ref: '#/components/responses/WorkflowResponse' + '401': + description: 未认证 + '403': + description: 身份或操作权限不允许 + /api/merchant/v1/last-leg/overseas-procurements/{orderNo}/workflow: + get: + summary: 客户读取采购实收与出库批次 + description: 权限 merchant:cross-border:procurement:list;客户范围只取认证身份,不接收 customerId。仅返回已关联收货或出库批次的文件元信息,不暴露内部文件。 + parameters: + - $ref: '#/components/parameters/OrderNo' + responses: + '200': + $ref: '#/components/responses/WorkflowResponse' + '401': + description: 未认证 + '403': + description: 身份或操作权限不允许 + /api/merchant/v1/last-leg/overseas-procurements/{orderNo}/confirm: + post: + summary: 客户确认采购结果 + description: 权限 merchant:cross-border:procurement:create;客户范围只取认证身份,不接收 customerId。仅 WAIT_CUSTOMER_CONFIRM 可确认,成功进入 CONFIRMED。 + parameters: + - $ref: '#/components/parameters/OrderNo' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + responses: + '200': + $ref: '#/components/responses/WorkflowResponse' + '401': + description: 未认证 + '403': + description: 身份或操作权限不允许 + /api/merchant/v1/last-leg/overseas-procurements/{orderNo}/files/{fileId}/content: + get: + summary: 客户下载收货照片或签收凭证 + description: 权限 merchant:cross-border:procurement:list;客户范围只取认证身份。fileId 必须已关联本单收货或出库批次,内部文件和其他客户文件均不可下载。成功为原始字节流,响应 Cache-Control 为 no-store。 + parameters: + - $ref: '#/components/parameters/OrderNo' + - name: fileId + in: path + required: true + schema: + $ref: '#/components/schemas/Id' + responses: + '200': + description: 原始凭证内容;Content-Type 采用文件真实类型,Content-Disposition 为 attachment 并携带文件名 + headers: + Cache-Control: + schema: {type: string, example: no-store} + Content-Disposition: + schema: {type: string} + content: + application/octet-stream: + schema: {type: string, format: binary} + image/*: + schema: {type: string, format: binary} + '401': + description: 未认证 + '403': + description: 身份或只读权限不允许 +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + OrderNo: + name: orderNo + in: path + required: true + schema: {type: string} + CustomerId: + name: customerId + in: query + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + BatchId: + name: batchId + in: path + required: true + schema: + $ref: '#/components/schemas/Id' + responses: + WorkflowResponse: + description: HTTP 200 业务响应;必须核对 code,版本冲突和非法状态返回非成功业务码 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + schemas: + Id: + type: string + pattern: '^[1-9][0-9]*$' + description: Long 主键在 JSON 中按字符串传输,避免前端精度损失 + Status: + type: string + enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED] + description: 依次为待审核、收货中、待客户确认、已确认、运输中、派送中、签收完成 + Command: + type: object + required: [version, idempotencyKey] + description: version 为当前采购单版本;同单原幂等键与相同参数重试不重复执行,跨动作或改参数复用键会被拒绝 + properties: + version: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 1, maxLength: 128} + FileIds: + type: array + minItems: 1 + maxItems: 20 + uniqueItems: true + items: + $ref: '#/components/schemas/Id' + ReceiptCommand: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: [quantity, fileIds] + properties: + quantity: {type: integer, format: int64, minimum: 1} + fileIds: + $ref: '#/components/schemas/FileIds' + OutboundCommand: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: [quantity, trackingNo] + properties: + quantity: {type: integer, format: int64, minimum: 1} + trackingNo: {type: string, minLength: 1, maxLength: 255} + CompletionCommand: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: [fileIds] + properties: + fileIds: + $ref: '#/components/schemas/FileIds' + Receipt: + type: object + required: [id, quantity, fileIds, receivedAt] + properties: + id: + $ref: '#/components/schemas/Id' + quantity: {type: integer, format: int64, minimum: 1} + fileIds: + type: array + items: + $ref: '#/components/schemas/Id' + receivedAt: {type: string, format: date-time} + Outbound: + type: object + required: [id, outboundNo, quantity, trackingNo, status, fileIds, createdAt, completedAt] + properties: + id: + $ref: '#/components/schemas/Id' + outboundNo: {type: string} + quantity: {type: integer, format: int64, minimum: 1} + trackingNo: {type: string} + status: {type: string, enum: [IN_TRANSIT, DELIVERING, COMPLETED]} + fileIds: + type: array + items: + $ref: '#/components/schemas/Id' + createdAt: {type: string, format: date-time} + completedAt: {type: string, format: date-time, nullable: true} + FileInfo: + type: object + required: [fileId, fileName, contentType] + properties: + fileId: + $ref: '#/components/schemas/Id' + fileName: {type: string} + contentType: {type: string} + Workflow: + type: object + required: [orderNo, version, status, receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files] + description: stockQuantity = receivedQuantity - 已完成出库数量;reservedQuantity 为未完成出库占用;availableQuantity = stockQuantity - reservedQuantity。主状态优先显示运输中批次,其次派送中;全部批次完成但仍有库存时回到已确认,无剩余库存才签收完成。 + properties: + orderNo: {type: string} + version: {type: integer, format: int64, minimum: 0} + status: + $ref: '#/components/schemas/Status' + receivedQuantity: {type: integer, format: int64, minimum: 0} + stockQuantity: {type: integer, format: int64, minimum: 0} + reservedQuantity: {type: integer, format: int64, minimum: 0} + availableQuantity: {type: integer, format: int64, minimum: 0} + receipts: + type: array + items: + $ref: '#/components/schemas/Receipt' + outbounds: + type: array + items: + $ref: '#/components/schemas/Outbound' + files: + type: array + items: + $ref: '#/components/schemas/FileInfo' + Response: + type: object + required: [code, message, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + $ref: '#/components/schemas/Workflow' diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 62ab9d7d9..fe9f14556 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -555,6 +555,7 @@ class DeliveryTransferServiceTest { verify(dispatchItems, never()).findByDispatchOrder(any(), any()); } + /** 派送详情仅回显当前目的仓货物,并保留独立于审核意见的费用说明。 */ @Test void deliveryDetailAndBolSourceRowsContainOnlyItsDestinationFbaWarehouse() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); @@ -569,6 +570,10 @@ class DeliveryTransferServiceTest { new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 901L, "SKU-A", "A", 12L), new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(202L, 902L, "SKU-B", "B", 8L))); when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of()); + var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); + fee.setFeeRemark("本次追加装车服务"); + fee.setReviewReason("审核通过"); + when(feeSources.listForBusiness("TENANT-A", "DELIVERY_ORDER", 401L)).thenReturn(List.of(fee)); DeliveryResponses.DeliveryDetail detail = service.deliveryDetail("TENANT-A", "DEL-ONT8"); @@ -576,6 +581,7 @@ class DeliveryTransferServiceTest { assertEquals(1, detail.skus().size()); assertEquals(12, detail.skus().get(0).packageCount()); assertEquals("SKU-A", detail.skus().get(0).skuCode()); + assertEquals("本次追加装车服务", detail.fees().get(0).remark()); verify(tailOrders).selectBatchIds(List.of(901L)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java index 2b055e062..86ca82eea 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java @@ -37,12 +37,16 @@ class DispatchFeedbackRegressionTest { when(profilePhotos.download(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,71,88)).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Download(photo,new byte[]{1,2,3})); var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence"); when(evidence.upload(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyString(),any())).thenReturn(new DispatchEvidenceGateway.File(303L,"司机.png","image/png",3)); - ReflectionTestUtils.setField(f.service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,evidence)); + var masterPhotos=new DispatchMasterPhotoService(profilePhotos,evidence); + var loadingPhotos=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class); + ReflectionTestUtils.setField(masterPhotos,"loadingPhotos",loadingPhotos); + ReflectionTestUtils.setField(f.service,"masterPhotos",masterPhotos); var input=f.configured("update",List.of()); var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(), new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,supplement?List.of(91L):List.of(),input.driver().evidence().expectedTaskEndAt(),List.of())), input.plannedDepartureAt(),null,null,null); var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); + verify(loadingPhotos).invalidateResourcePhotos(eq(f.repository.find(created.dispatchNo()).getId()),eq(f.actor.operatorId()),anySet()); f.prepareAppointmentNumbers(created.dispatchNo()); var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence(); assertThat(proof.fileIds()).containsExactlyElementsOf(supplement?List.of(81L,91L):List.of(81L));assertThat(proof.masterLicenseFileId()).isEqualTo(81L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index e4b118ed4..3248667e5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -11,6 +11,27 @@ import static org.assertj.core.api.Assertions.*; /** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ class DispatchReviewWorkflowPersistenceTest { + /** 本土卡车不建立仓库整车绑定,仍须完成同轮调度及理货审核。 */ + @Test void localTruckReviewsUseTransportTripWithoutWarehouseBinding() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("truck-review",List.of(f.source(1,"101",501,"ONT8",3))))); + f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK' WHERE dispatch_no=?",created.dispatchNo()); + var transport=org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class); + var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setStopsJson("[]"); + org.mockito.Mockito.when(transport.transport(org.mockito.ArgumentMatchers.eq(created.dispatchNo()),org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(trip); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport); + var fleet=org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"fleet",fleet); + var master=org.mockito.Mockito.mock(DispatchMasterPhotoService.class); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"masterPhotos",master); + var submitted=submit(f,created,"truck-submit"); + var dispatched=review(f,f.actor,submitted,false,"truck-dispatch",Decision.APPROVED,null); + var loaded=review(f,f.actor,dispatched,true,"truck-loading",Decision.APPROVED,null); + assertThat(loaded.stage()).isEqualTo(Stage.LOADING); + assertThat(f.repository.find(created.dispatchNo()).getReviewState()).isEqualTo("APPROVED"); + org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(3)).verifyLocalTruckReview(created.dispatchNo()); + org.mockito.Mockito.verifyNoInteractions(fleet,master); + } @Test void approvedLoadingReviewImportsOnceAndCopyFailureRollsBackApproval() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); var created=f.tx.execute(s->f.service.create(f.actor,f.configured("copy-create",List.of(f.source(1,"101",501,"ONT8",3))))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java index 7817a3072..5c59a8f13 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java @@ -22,6 +22,16 @@ class TransportTaskServiceTest { DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips; UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order; final Map commands=new HashMap<>(); + /** 本土卡车审核仍拦截执行中任务、无效时间和未维护车辆,不因分流而放松门禁。 */ + @Test void localTruckReviewRejectsInvalidExecutionTimeAndMissingResources() { + assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("未发车"); + trip.setStatus("PLANNED"); + assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("任务结束时间"); + trip.setPlannedDepartureAt(LocalDateTime.now(ZoneOffset.UTC)); + trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).plusHours(2)); + assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("分配有效车辆和司机"); + verifyNoInteractions(trucks,files); + } /** 审核已改变的旧页面不能通过仍相同的车次版本覆盖资源和照片。 */ @Test void staleDispatchVersionCannotAssignEvenWhenTripVersionMatches() { var root=roots.find("PC1");root.setTaskType("PICKUP");root.setRecordVersion(9L);when(roots.lock("PC1")).thenReturn(root); @@ -34,6 +44,7 @@ class TransportTaskServiceTest { roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class); trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class); service=new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,mock(FleetPickupService.class),mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules()); + org.springframework.test.util.ReflectionTestUtils.setField(service,"reviewPhotos",mock(TransportReviewPhotos.class)); var root=new DispatchAggregate();root.setDispatchNo("PC1");root.setTaskType("LOCAL_TRUCK");root.setTripId(1L); when(roots.find("PC1")).thenReturn(root);when(links.byTrip(1L)).thenReturn(root);when(links.tripNo(1L)).thenReturn("TR1"); when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));when(links.evidence("PC1")).thenReturn(List.of()); @@ -66,6 +77,16 @@ class TransportTaskServiceTest { @Test void evidenceDownloadRequiresPersistedConfirmation(){ assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files); } + /** 刷新运输详情时按命令顺序拆分 B 点取货与 C 点送达照片,不能把两阶段混成一组。 */ + @Test void detailSeparatesDepartureAndCompletionEvidence(){ + trip.setStatus("COMPLETED"); + when(links.evidence("PC1")).thenReturn(List.of("[11]", "[22,23]")); + when(files.metadata(eq(1L),anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile( + Objects.toString(call.getArgument(1), ""), "现场-" + call.getArgument(1) + ".jpg", "image/jpeg", 10L)); + var detail=service.detail("PC1"); + assertThat(detail.departureEvidence()).extracting(com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile::fileId).containsExactly("11"); + assertThat(detail.completionEvidence()).extracting(com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile::fileId).containsExactly("22", "23"); + } /** 只对审核通过的上门取货生成排车,已有及取消任务都不得重建。 */ @Test void approvalCreatesOnlyEligibleUnassignedPickup() { var facts=mock(FleetPickupRepository.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeRoleAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeRoleAuthorizationTest.java new file mode 100644 index 000000000..819cadc0b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeRoleAuthorizationTest.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.financefee; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.financefee.controller.PlatformOverseasFeeSourceController; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest; +import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.financefee.service.OverseasManualFeeService; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class OverseasFeeRoleAuthorizationTest { + /** 使用真实方法鉴权拦截器验证只读、编辑、审核权限相互独立,拒绝请求不得进入业务层。 */ + @ParameterizedTest + @CsvSource({ + "platform:overseas-fee:list,true,false,false", + "platform:finance:customer-arap:list,true,false,false", + "platform:finance:fee-source:edit,false,true,false", + "platform:finance:fee-source:review,false,false,true", + "unrelated,false,false,false" + }) + void roleCannotPerformOtherRolesOperations(String authority, boolean read, boolean edit, boolean review) { + var sources = mock(OverseasFeeSourceService.class); + var manual = mock(OverseasManualFeeService.class); + when(manual.detail(1L)).thenReturn(new com.baiyue.lastleg.financefee.entity.OverseasFeeSource()); + var delivery = mock(OverseasFeeDeliveryService.class); + var factory = new ProxyFactory(new PlatformOverseasFeeSourceController(sources, manual, delivery)); + factory.setProxyTargetClass(true); + factory.addAdvisor(AuthorizationManagerBeforeMethodInterceptor.preAuthorize()); + var controller = (PlatformOverseasFeeSourceController) factory.getProxy(); + var actor = new AuthenticatedUser(7L, 0L, 0L, 1L, "fee-acceptance", "PLATFORM", "WEB", Set.of(authority)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + actor, null, List.of(new SimpleGrantedAuthority(authority)))); + try { + check(read, () -> controller.list(actor, "42", null, null, null), sources, manual, delivery); + check(read, () -> controller.detail(actor, 1L), sources, manual, delivery); + check(edit, () -> controller.create(actor, "create", null), sources, manual, delivery); + check(edit, () -> controller.submit(actor, 1L, new ActionRequest("submit")), sources, manual, delivery); + check(edit, () -> controller.cancel(actor, 1L, new ActionRequest("cancel")), sources, manual, delivery); + check(edit, () -> controller.retryFunding(actor, 1L, new ActionRequest("retry")), sources, manual, delivery); + check(review, () -> controller.review(actor, 1L, new ReviewRequest("APPROVE", null, "review")), sources, manual, delivery); + } finally { + SecurityContextHolder.clearContext(); + } + } + + /** 对每次入口调用同时核对放行结果与拒绝时零业务副作用。 */ + private void check(boolean allowed, Runnable action, Object... services) { + clearInvocations(services); + if (allowed) assertDoesNotThrow(action::run); + else { + assertThrows(AccessDeniedException.class, action::run); + verifyNoInteractions(services); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasManualFeePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasManualFeePersistenceTest.java index a3f27c4fb..4a51196b2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasManualFeePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasManualFeePersistenceTest.java @@ -38,6 +38,7 @@ class OverseasManualFeePersistenceTest { jdbc.execute(ddl); jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(128),customer_name VARCHAR(200),business_type VARCHAR(64),business_no VARCHAR(128),fee_item_code VARCHAR(64),direction VARCHAR(16),review_status VARCHAR(16),created_at TIMESTAMP,exchange_rate DECIMAL(20,6),wallet_settlement VARCHAR(1000),payment_voucher_file_ids VARCHAR(1000))"); jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),customer_name VARCHAR(200),order_no VARCHAR(128),order_type VARCHAR(32),overseas_warehouse_id VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_overseas_procurement_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),customer_name VARCHAR(200),order_no VARCHAR(128))"); for (String table : java.util.List.of("ll_warehouse_transfer_order", "ll_return_order", "ll_us_truck_order", "ll_local_looseboard_order", "ll_relabel_application", "ll_cargo_discard_application", "ll_inbound_plan", "ll_delivery_order")) jdbc.execute("CREATE TABLE " + table + "(customer_id VARCHAR(128))"); var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); @@ -91,6 +92,19 @@ class OverseasManualFeePersistenceTest { assertThat(queries.count(query)).isEqualTo(4); } + /** 仅有采购业务的客户也能被费用候选检索,业务单候选与关联始终按客户隔离。 */ + @Test void procurementCustomersAndOptionsRequireOwnedOrder() { + jdbc.update("INSERT INTO ll_overseas_procurement_order VALUES(81,'10003','采购客户','CG-01'),(82,'10004','其他客户','CG-02')"); + assertThat(businesses.customers()).extracting("customerId").contains("10003", "10004"); + var options = businesses.options("OVERSEAS_PROCUREMENT", "10003", "CG-"); + assertThat(options).singleElement().satisfies(option -> { + assertThat(option.businessId()).isEqualTo(81L); + assertThat(option.businessNo()).isEqualTo("CG-01"); + }); + assertThat(businesses.countOwned("OVERSEAS_PROCUREMENT", 81, "10004", "CG-01")).isZero(); + assertThat(businesses.countOwned("OVERSEAS_PROCUREMENT", 81, "10003", "CG-01")).isEqualTo(1); + } + /** 一件代发使用实际订单类型且归属筛选不可能返回另一客户订单。 */ @Test void orderCandidatesRetainRealTypeAndRejectForeignOwnership() { jdbc.update("INSERT INTO ll_order VALUES(91,'10001','客户甲','DS-01','ONE_CLICK_DROPSHIP','8')"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java index addce77ef..528b93eb1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java @@ -468,6 +468,19 @@ class OverseasManualFeeServiceTest { verifyNoInteractions(wallet, outbox); } + /** 头程目的地为自有海外仓时,未显式选择结算方式默认走余额/授信。 */ + @Test void firstLegSelfOwnedOverseasWarehouseDefaultsToWallet() { + fee.setBusinessType("LASTLEG_ORDER"); + when(businesses.orderType(91L, "10001")).thenReturn("FIRST_LEG"); + when(businesses.destinationType(91L, "10001")).thenReturn("OVERSEAS"); + when(repository.lockFunding(701L)).thenReturn(null); + var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"); + when(sources.create("operator", "self-warehouse-create", request)).thenReturn(fee); + var created = service.create("operator", "self-warehouse-create", request); + assertThat(created.getCollectionMode()).isEqualTo("WALLET"); + verify(wallet).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(), any(), anyString(), eq(1L)); + } + /** 旧失败回执在重试后重放不覆盖新扣款,成功仍只入池一次。 */ @Test void staleFailureReplayCannotOverwriteRetrySettlement() { when(wallet.publish(eq("SETTLE"), anyLong(), anyString(), anyString(), any(), anyString(), eq(3L))).thenReturn("settle-retry"); @@ -486,6 +499,36 @@ class OverseasManualFeeServiceTest { assertThat(service.detail(701L).getAllowedActions()).isEmpty(); verifyNoInteractions(wallet, outbox); } + /** 新采购主单费用默认冻结余额,冻结完成后审核实扣,重复审核不重复扣款。 */ + @Test void procurementOrderFeesUseSharedWalletWorkflow() { + fee.setSourceType("OVERSEAS_PROCUREMENT_FEE"); fee.setBusinessType("OVERSEAS_PROCUREMENT"); + fee.setReviewStatus("DRAFT"); fee.setSettlementMode(null); + when(repository.lockFunding(701L)).thenReturn(null); + var submitted = service.submit(701L, "operator", "purchase-submit"); + assertThat(submitted.getCollectionMode()).isEqualTo("WALLET"); + assertThat(submitted.getWalletStatus()).isEqualTo("FREEZE_PENDING"); + var state = ArgumentCaptor.forClass(OverseasFeeFunding.class); + verify(repository).insertFunding(state.capture()); + funding = state.getValue(); + when(repository.lockFunding(701L)).thenReturn(funding); + funding.setWalletStatus("FROZEN"); funding.setWalletHoldId("9001"); + var reviewed = service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "purchase-review")); + assertThat(reviewed.getWalletStatus()).isEqualTo("SETTLE_PENDING"); + assertThat(reviewed.getWalletSettlement()).isNull(); + verify(wallet, times(1)).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(), eq(new BigDecimal("88.00")), anyString(), eq(1L)); + verify(wallet, times(1)).publish(eq("SETTLE"), eq(10001L), anyString(), anyString(), isNull(), anyString(), eq(2L)); + verifyNoInteractions(outbox); + } + + /** 采购费用读取范围固定为同客户主单,资金状态由共用描述器提供。 */ + @Test void procurementFeesRemainScopedToCustomerAndOrder() { + fee.setBusinessType("OVERSEAS_PROCUREMENT"); + when(sources.listForBusiness("10001", "OVERSEAS_PROCUREMENT", 91L)).thenReturn(List.of(fee)); + assertThat(service.procurementFees("10001", 91L)).containsExactly(fee); + verify(sources).listForBusiness("10001", "OVERSEAS_PROCUREMENT", 91L); + verifyNoInteractions(wallet, outbox); + } + @Test void oldRelabelHoldBlocksEquivalentCatalogItemButNotAnotherService() { fee.setBusinessType("RELABEL_ORDER"); fee.setFeeItemCode("FEE_TYPE_071"); fee.setFeeItemName("贴标费"); fee.setReviewStatus("DRAFT"); when(repository.lockFunding(701L)).thenReturn(null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java index 808e42279..b02f04191 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java @@ -16,6 +16,27 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class ProcurementSettledFeeSourceTest { + /** 新采购费用关联主单,从通用来源创建并默认余额支付,旧子费用入口仍隔离。 */ + @Test void procurementOrderCanCreateSharedFeeWithWalletDefault() { + var mapper = mock(OverseasFeeSourceMapper.class); + var converter = mock(OverseasFeeCurrencyConverter.class); + var guard = mock(OverseasFeeBusinessGuard.class); + var service = new OverseasFeeSourceService(mapper, mock(OverseasFeeCommandMapper.class), guard, + mock(OverseasFeeOutboxService.class), converter, new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class)); + var request = new SaveRequest("9", "客户", "OVERSEAS_PROCUREMENT_FEE", "OVERSEAS_PROCUREMENT", 11L, + "CG-1", null, null, null, null, null, null, null, "SERVICE", "采购服务费", null, "RECEIVABLE", + null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 10, 27), "CNY", + BigDecimal.TEN, null, null, null, null, null); + when(converter.convert("CNY", BigDecimal.TEN, request.businessDate())).thenReturn( + new OverseasFeeCurrencyConverter.Conversion("CNY", BigDecimal.TEN, BigDecimal.ONE, "2026-10", BigDecimal.TEN)); + var result = service.create("7", "purchase-fee", request); + assertThat(result.getBusinessType()).isEqualTo("OVERSEAS_PROCUREMENT"); + assertThat(result.getSettlementMode()).isEqualTo("WALLET"); + assertThat(result.getReviewStatus()).isEqualTo("DRAFT"); + assertThat(result.getWalletSettlement()).isNull(); + verify(guard).requireOwned(request); + verify(mapper).insert(result); + } /** 本土运输不得通过应付来源绕开客户余额和授信扣费。 */ @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.CsvSource({"US_TRUCK,OVERSEAS_TRUCK_FEE", "LOCAL_LOOSEBOARD,OVERSEAS_LOOSEBOARD_FEE"}) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java index cbcda59df..8a5612be5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java @@ -43,7 +43,7 @@ class OwnedBusinessPersistenceTest { CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(128),trip_id BIGINT,status VARCHAR(32), stage VARCHAR(32),record_version BIGINT,planned_departure_at DATETIME(3),loading_completed_at DATETIME(3),departed_at DATETIME(3),delivery_completed_at DATETIME(3), loading_completion_hash VARCHAR(128),loading_confirmation_json LONGTEXT,resources_json LONGTEXT, - vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128)) + vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128),task_type VARCHAR(32)) """); jdbc.execute(""" CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64), @@ -52,6 +52,8 @@ class OwnedBusinessPersistenceTest { destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128), delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT,plan_id BIGINT,plan_pallet_id BIGINT) """); + jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(128),status VARCHAR(32),stops_json LONGTEXT)"); + jdbc.execute("CREATE TABLE ll_fleet_pickup_task(trip_no VARCHAR(128),status VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,trip_id BIGINT,dispatch_no VARCHAR(128),customer_id VARCHAR(128),offer_id BIGINT,status VARCHAR(32),incoming_no VARCHAR(128),receipt_json LONGTEXT)"); jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(128),transfer_no VARCHAR(128),inbound_code VARCHAR(128))"); jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),inbound_code VARCHAR(128))"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java index ca91c14c5..19d2b36a3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java @@ -40,6 +40,17 @@ class OwnedBusinessProjectionTest { var ops=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).operations(record); assertEquals("0",ops.actualDistanceMiles());assertEquals("18.600",ops.fuelConsumptionLitresPer100Km()); } + /** 本土卡车及上门取货读取真实行程,不补造仓库托盘和车辆归属。 */ + @Test void transportTasksExposeRouteWithoutInventingLoadingPallets() { + for(String task:List.of("LOCAL_TRUCK","PICKUP")) { + var source=OwnedBusinessFixtures.source();source.setTaskType(task);source.setLoadingConfirmationJson(null); + source.setOwnedVehicleOwnership(null);source.setTransportStopsJson("[{\"name\":\"取货地址\",\"address\":\"起点街道\"},{\"name\":\"收货地址\",\"address\":\"终点街道\"}]"); + var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(source,null,List.of(),List.of(),OwnedBusinessFixtures.actor()); + assertNull(row.actualPalletCount());assertNull(row.unitPrice());assertNull(row.vehicle().ownership()); + assertEquals("取货地址",row.origin().warehouseName());assertEquals("收货地址",row.destinations().get(0).warehouseName()); + assertEquals("终点街道",row.destinations().get(0).address());assertFalse(row.sourceIssues().isEmpty()); + } + } /** 虚拟记录和空照片保持真实空态。 */ private Record record(){var r=new Record();r.setRecordVersion(1L);r.setArrivalsJson("[]");r.setPhotosJson("[]");return r;} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java index a467180db..80bd86b0d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java @@ -51,6 +51,20 @@ class OwnedBusinessQueryTest { source.setPooledCargo(true);assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); source.setPooledCargo(false);source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); } + /** 运输任务以真实取货发车为依据,未发车和未取齐仍不可进入运营。 */ + @Test void transportRequiresDepartureAndDoesNotLeakIntoPooledBusiness() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); + var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class)); + for(String task:List.of("LOCAL_TRUCK","PICKUP")) { + var source=OwnedBusinessFixtures.source();source.setTaskType(task);source.setOwnedVehicleOwnership(null); + source.setLoadingConfirmationJson(null);source.setStage("DELIVERING");source.setTransportTripStatus("IN_TRANSIT"); + source.setTransportReady(true);source.setDepartedAt(LocalDateTime.parse("2026-10-26T00:00:00")); + when(repo.source("DS-OWN-ONE")).thenReturn(source); + assertSame(source,service.requireOwned("DS-OWN-ONE")); + assertThrows(BizException.class,()->service.requireScoped("DS-OWN-ONE",com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OUTSOURCED)); + source.setDepartedAt(null);assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); + } + } /** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */ @Test void readOnlyActorHasOnlyReadGates() { var actor=new Actor("reader","只读账号",Set.of(LIST));var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index c4206011c..68a02e013 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -57,6 +57,33 @@ class OwnedBusinessScopePersistenceTest { } /** 数据库仅在本机隔离H2,用后关闭。 */ @AfterEach void close(){baseline.closeDatabase();} + /** 两类运输仅在实提发车后进自有业务,运营到达保存沿用真实行程键。 */ + @Test void transportSourcesAreQueryableAndSupportArrivalRecordsWithoutWarehouseCargo() { + for(int i=10;i<=11;i++) { + String task=i==10?"LOCAL_TRUCK":"PICKUP",number="DS-TRANSPORT-"+i; + baseline.jdbc.update("INSERT INTO ll_fleet_trip(id,trip_no,status,stops_json) VALUES(?,?,'IN_TRANSIT',?)",i,"TR-"+i, + "[{\"name\":\"取货地\"},{\"name\":\"送达地\"}]"); + baseline.jdbc.update(""" + INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,status,stage,record_version,planned_departure_at,departed_at, + resources_json,vehicle_plate,overseas_warehouse_id,task_type) + SELECT ?,?,?,'ACTIVE','DELIVERING',1,planned_departure_at,planned_departure_at,resources_json,vehicle_plate,overseas_warehouse_id,? + FROM ll_dispatch_order WHERE id=1 + """,i,number,i,task); + if(i==11)baseline.jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES('TR-11','PICKED')"); + var detail=queries.detail(OwnedBusinessFixtures.actor(),number); + assertNull(detail.row().actualPalletCount());assertEquals(task,detail.row().taskType());assertEquals(1,detail.row().destinations().size()); + assertThrows(BizException.class,()->outsourced.detail(actor(),number)); + var saved=records.save(OwnedBusinessFixtures.actor(),number,new OwnedBusinessModels.SaveOperations("0","save-transport-"+i, + new OwnedBusinessModels.Operations(null,null,null,List.of(new OwnedBusinessModels.Arrival("TRANSPORT_STOP:1","2026-10-26T00:00:00Z"))),null)); + assertEquals("2026-10-26T00:00:00Z",saved.row().destinations().get(0).actualArrivedAt()); + } + var q=new OwnedBusinessModels.Query(1,100,null,null,null,null,null); + var rows=queries.list(OwnedBusinessFixtures.actor(),q).records(); + assertEquals(2,rows.stream().filter(r->r.dispatchNo().startsWith("DS-TRANSPORT-")).count()); + baseline.jdbc.update("UPDATE ll_fleet_pickup_task SET status='ASSIGNED' WHERE trip_no='TR-11'"); + assertFalse(queries.list(OwnedBusinessFixtures.actor(),q).records().stream().anyMatch(r->r.dispatchNo().equals("DS-TRANSPORT-11"))); + assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-TRANSPORT-11")); + } /** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */ @SuppressWarnings("unchecked") private T tx(T target) { var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true); @@ -71,7 +98,7 @@ class OwnedBusinessScopePersistenceTest { String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id)); baseline.jdbc.update(""" INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at, - loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1 + loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name,task_type FROM ll_dispatch_order WHERE id=1 """,id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id); baseline.jdbc.update(""" INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java index d8cf8267a..d2d17e462 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java @@ -20,6 +20,18 @@ class TripOperationsServiceTest { "externalBookings":[{"bookingNo":"FB1","status":"BOUND","customerId":"1","dispatchNo":"D-A","agreedAmount":0,"cargo":{"currency":"USD"}, "receipt":{"source":{"receivedPackages":4,"pallets":[{"warehousePalletId":"P1","packages":4}]}}}]} """; + /** 两类运输发车没有仓库装载快照,也能独立列示真实出车且不伪造实托。 */ + @Test void transportDeparturesUseTheirOwnEvidenceAndKeepPalletsUnknown() { + var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + for(String purpose:List.of("LOCAL_TRUCK","PICKUP")) { + String snapshot="{\"trip\":{\"tripNo\":\"TR-T\",\"tripPurpose\":\""+purpose+"\",\"departedAt\":\"2026-10-26T00:00:00Z\",\"vehiclePlate\":\"TEST\",\"stops\":[{\"name\":\"取货地\"},{\"name\":\"送达地\"}]}}"; + var row=service.project(new TripOperationsRepository.Departure("TR-T","IN_TRANSIT",null,snapshot,null,"{\"taskType\":\""+purpose+"\",\"packages\":10}")); + assertThat(row.ownPackages()).isEqualTo(10);assertThat(row.externalPackages()).isZero(); + assertThat(row.physicalPallets()).isNull();assertThat(row.palletSpaces()).isNull(); + assertThat(row.destinations().get(0).label()).isEqualTo("送达地"); + assertThat(TripOperationsCharts.from(List.of(row)).physicalPallets().get(0).points()).isEmpty(); + } + } @Test void mixedCustomersDestinationsAndCargoCountOneDepartureAndOnePhysicalPallet() { var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java index f70a2e3c7..5eb526cbf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java @@ -8,6 +8,43 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class OverseasProcurementOpenApiContractTest { + /** 简化采购契约须覆盖分批作业、客户确认、库存与受控文件读取。 */ + @Test + void simplifiedWorkflowDocumentsActualBatchContractAndCustomerIsolation() throws Exception { + String yaml; + try (var input = getClass().getResourceAsStream("/openapi/procurement-workflow-v1.yaml")) { + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + String platform = "/api/platform/v1/last-leg/overseas-procurements/{orderNo}"; + for (String suffix : new String[]{"/workflow:", "/receipts:", "/request-confirmation:", + "/outbounds:", "/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) { + assertTrue(yaml.contains(platform + suffix), suffix); + } + String merchant = "/api/merchant/v1/last-leg/overseas-procurements/{orderNo}"; + assertTrue(yaml.contains(merchant + "/workflow:")); + assertTrue(yaml.contains(merchant + "/confirm:")); + assertTrue(yaml.contains(merchant + "/files/{fileId}/content:")); + for (String permission : new String[]{"platform:overseas-business:purchase:list", + "platform:overseas-business:purchase:receive-overseas", + "platform:overseas-business:purchase:ship-customer", + "merchant:cross-border:procurement:list", "merchant:cross-border:procurement:create"}) { + assertTrue(yaml.contains(permission), permission); + } + assertTrue(yaml.contains("enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED]")); + assertTrue(yaml.contains("required: [version, idempotencyKey]")); + assertTrue(yaml.contains("required: [quantity, trackingNo]")); + assertTrue(yaml.contains("required: [quantity, fileIds]")); + assertTrue(yaml.contains("maxItems: 20")); + assertTrue(yaml.contains("uniqueItems: true")); + assertTrue(yaml.contains("receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files")); + assertTrue(yaml.contains("内部文件和其他客户文件均不可下载")); + assertTrue(yaml.contains("format: binary")); + assertTrue(yaml.contains("no-store")); + String merchantPaths = yaml.substring(yaml.indexOf(merchant + "/workflow:"), yaml.indexOf("components:")); + assertFalse(merchantPaths.contains("#/components/parameters/CustomerId")); + assertFalse(merchantPaths.contains("purchase:review")); + } + @Test void procurementContractDocumentsCandidatesClearSemanticsAndExcludesOutbound() throws Exception { String yaml; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowAuthorizationTest.java new file mode 100644 index 000000000..ea02f1f13 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowAuthorizationTest.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*; +import com.baiyue.lastleg.procurement.service.*; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 正式方法权限与客户隔离均在控制器边界验证。 */ +class ProcurementWorkflowAuthorizationTest { + AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(Config.class); + ProcurementWorkflowService service=context.getBean(ProcurementWorkflowService.class); + @Configuration @EnableMethodSecurity(proxyTargetClass=true) + static class Config { + @Bean ProcurementWorkflowService service() { return mock(ProcurementWorkflowService.class); } + @Bean ProcurementWorkflowController platform(ProcurementWorkflowService service) { return new ProcurementWorkflowController(service); } + @Bean static org.springframework.beans.factory.config.BeanFactoryPostProcessor orderMock() { + return f->f.registerSingleton("orders",mock(OverseasProcurementService.class)); + } + @Bean MerchantProcurementWorkflowController merchant(ProcurementWorkflowService service,OverseasProcurementService orders) { return new MerchantProcurementWorkflowController(service,orders); } + } + @AfterEach void cleanup() { SecurityContextHolder.clearContext();context.close(); } + @Test void readerCannotReceiveOrCreateOutbounds() { + var controller=context.getBean(ProcurementWorkflowController.class); + var user=login(0,"PLATFORM","platform:overseas-business:purchase:list"); + controller.read(user,"9","CG-1");verify(service).read("9","CG-1"); + assertThatThrownBy(()->controller.receive(user,"9","CG-1",new Receipt(0,"r",1,List.of(1L)))).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.outbound(user,"9","CG-1",new Outbound(0,"o",1,"TN"))).isInstanceOf(AccessDeniedException.class); + } + @Test void receiverCanSubmitConfirmationButCannotShip() { + var controller=context.getBean(ProcurementWorkflowController.class); + var user=login(0,"PLATFORM","platform:overseas-business:purchase:receive-overseas"); + var command=new Command(1,"ask");controller.requestConfirmation(user,"9","CG-1",command); + verify(service).requestConfirmation("9","CG-1","7",command); + assertThatThrownBy(()->controller.dispatch(user,"9","CG-1",1,command)).isInstanceOf(AccessDeniedException.class); + } + @Test void customerCannotUsePlatformEvenWithInjectedAuthorityAndCannotChooseCustomer() { + var platform=context.getBean(ProcurementWorkflowController.class); + var user=login(9,"MERCHANT","platform:overseas-business:purchase:ship-customer"); + assertThatThrownBy(()->platform.outbound(user,"10","CG-1",new Outbound(0,"o",1,"T"))).isInstanceOf(ResponseStatusException.class); + var merchant=context.getBean(MerchantProcurementWorkflowController.class); + var customer=login(9,"MERCHANT","merchant:cross-border:procurement:create"); + var command=new Command(1,"c");merchant.confirm(customer,"CG-1",command);verify(service).confirm("9","CG-1","7",command); + } + @Test void customerListPermissionCannotConfirm() { + var merchant=context.getBean(MerchantProcurementWorkflowController.class); + var user=login(9,"MERCHANT","merchant:cross-border:procurement:list"); + merchant.read(user,"CG-1");verify(service).read("9","CG-1"); + assertThatThrownBy(()->merchant.confirm(user,"CG-1",new Command(0,"c"))).isInstanceOf(AccessDeniedException.class); + } + private AuthenticatedUser login(long customer,String app,String permission) { + var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index bb02ba618..6d51e0f4d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -138,14 +138,14 @@ class OverseasProcurementServiceTest { var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved", 10L); var approved = service.review("100", "HC1", "operator-7", request); - assertEquals(OverseasProcurementService.WAIT_PURCHASE, approved.summary().status()); + assertEquals("RECEIVING", approved.summary().status()); assertEquals(1L, approved.summary().version()); assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version()); verify(histories).insert(org.mockito.ArgumentMatchers .argThat(value -> "REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId()) && "approve-1".equals(value.getIdempotencyKey()) - && OverseasProcurementService.WAIT_PURCHASE.equals(value.getToStatus()))); + && "RECEIVING".equals(value.getToStatus()))); assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload", 10L))); @@ -153,7 +153,7 @@ class OverseasProcurementServiceTest { new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version", 10L))); assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse", 10L))); - assertEquals(OverseasProcurementService.WAIT_PURCHASE, order.getStatus()); + assertEquals("RECEIVING", order.getStatus()); } private OverseasProcurementOrder order(String status, long inbound, long outbound) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java index cc449ad89..86d040df2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java @@ -115,13 +115,10 @@ class ProcurementPurchasePersistenceTest { @Test void unreviewedPurchaseFactCannotConfirmOrCharge() { jdbc.update("UPDATE ll_overseas_procurement_order SET reviewed_at=NULL,status='PENDING_REVIEW' WHERE id=11"); - var recorded=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); - assertThat(recorded.purchase().recorded()).isTrue(); - assertThat(recorded.purchase().confirmed()).isFalse(); - assertThat(recorded.purchase().issue()).contains("尚未审核"); - var again=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-unreviewed","actual fact"))); - assertThat(again.purchase().confirmed()).isFalse(); - assertThat(again.status()).isEqualTo("PENDING_REVIEW"); + assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("简化采购"); + assertThat(service.flow(11)).isEmpty(); + assertThat(orders.findNo("9","PO-1").getVersion()).isZero(); verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java index 1c674e655..696a860ba 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java @@ -30,6 +30,7 @@ class ProcurementQueryServiceTest { base=mock(OverseasProcurementService.class); when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of())); query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc); + org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class)); } @Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception { fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11"); @@ -60,12 +61,40 @@ class ProcurementQueryServiceTest { @Test void anotherCustomerCannotLoadEvenPublicProjection() { assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base); } + /** 共用费用只公开当前采购单应收和资金结果,过滤其他客户、单据及内部诊断。 */ + @Test void customerReadsOnlyOwnedManualProcurementFeeResults() throws Exception { + var manual = mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class); + org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", manual); + var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); + fee.setSourceFeeId(901L); fee.setSourceVersion(1); fee.setCustomerId("9"); fee.setBusinessId(11L); + fee.setBusinessType("OVERSEAS_PROCUREMENT"); fee.setDirection("RECEIVABLE"); fee.setSourceStatus("ACTIVE"); + fee.setFeeItemCode("SERVICE"); fee.setFeeItemName("采购服务费"); fee.setReviewStatus("PENDING"); + fee.setWalletStatus("FREEZE_PENDING"); fee.setWalletErrorMessage("private funding diagnosis"); + fee.setAmount(BigDecimal.TEN); fee.setCurrency("CNY"); fee.setReviewReason("private review reason"); + var other = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); + other.setBusinessType("OVERSEAS_PROCUREMENT"); other.setDirection("RECEIVABLE"); + other.setCustomerId("10"); other.setBusinessId(11L); + when(manual.procurementFees("9", 11L)).thenReturn(List.of(fee, other)); + var result = query.customer("9", "PO-1"); + assertThat(result.fees()).singleElement().satisfies(row -> { + assertThat(row.feeNo()).isEqualTo("901"); + assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING"); + assertThat(row.sourceLinked()).isFalse(); + }); + assertThat(json.writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason"); + } @Test void draftAndSupersededPackingAreNotDisplayedAsConfirmedCustomerBoxes() { fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'SUPERSEDED','7'),(302,11,'9',2,'DRAFT','7')"); fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)"); assertThat(query.customer("9","PO-1").boxes()).isEmpty(); } + /** 新流程不再提示旧购买、装箱和两段核费动作。 */ + @Test void simplifiedWorkflowDoesNotExposeLegacySampleActions() { + fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='RECEIVING' WHERE id=11"); + assertThat(query.platform("9", "PO-1", Set.of("platform:overseas-business:purchase:buy")).actions()).isEmpty(); + } + @Test void exportProjectionExposesDirectionAndLabelsWithoutRewritingHistoricalLegs() { fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=? WHERE id=11",ProcurementDirection.EXPORT); var result=query.platform("9","PO-1",Set.of("platform:overseas-business:purchase:receive-overseas")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java new file mode 100644 index 000000000..d867e0286 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java @@ -0,0 +1,164 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*; +import com.baiyue.lastleg.procurement.entity.*; +import com.baiyue.lastleg.procurement.mapper.*; +import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository; +import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实Mapper验证采购库存分批、隔离、事务与幂等。 */ +class ProcurementWorkflowPersistenceTest { + JdbcTemplate jdbc; + TransactionTemplate tx; + ProcurementWorkflowService service; + OverseasProcurementOrderMapper orders; + OverseasProcurementFileMapper files; + OverseasFeeSourceService fees; + + @BeforeEach void setup() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_workflow_receipt","ll_procurement_workflow_outbound","ll_procurement_workflow_command")) { + var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); + assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); + } + var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(ProcurementWorkflowMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var session=new SqlSessionTemplate(factory.getObject());orders=session.getMapper(OverseasProcurementOrderMapper.class);files=session.getMapper(OverseasProcurementFileMapper.class); + var repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files); + var numbers=mock(LastlegDocumentNumberGenerator.class);var sequence=new AtomicInteger();when(numbers.next("CK")).thenAnswer(i->"CK-"+sequence.incrementAndGet()); + fees=mock(OverseasFeeSourceService.class); + when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"))); + service=new ProcurementWorkflowService(repository,numbers,new ObjectMapper().findAndRegisterModules(),fees); + var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setOrderNo("CG-1");order.setProductName("采购商品");order.setPurchaseCategory("");order.setPurchaseQuantity(10L);order.setCurrency("USD");order.setStatus("RECEIVING");order.setVersion(0L);order.setInboundQuantity(0L);order.setContactName("buyer");order.setContactPhone("123456789");order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));orders.insert(order); + file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png"); + } + + @Test void twoReceiptsCustomerConfirmationAndTwoOutboundsDeductOnlyAtCompletion() { + var first=new Receipt(0,"receive1",4,List.of(101L)); + var current=tx.execute(s->service.receive("9","CG-1","7",first)); + assertThat(current.stockQuantity()).isEqualTo(4); + current=tx.execute(s->service.receive("9","CG-1","7",new Receipt(1,"receive2",6,List.of(104L)))); + assertThat(current.receipts()).hasSize(2);assertThat(current.availableQuantity()).isEqualTo(10); + tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(2,"ask"))); + assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"early",4,"TN")))).isInstanceOf(ResponseStatusException.class); + tx.execute(s->service.confirm("9","CG-1","8",new Command(3,"confirm"))); + current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(4,"out1",4,"TN-1"))); + long batch1=current.outbounds().get(0).id(); + assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.reservedQuantity()).isEqualTo(4);assertThat(current.availableQuantity()).isEqualTo(6); + current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(5,"out2",6,"TN-2"))); + long batch2=current.outbounds().get(1).id(); + tx.execute(s->service.dispatch("9","CG-1",batch1,"7",new Command(6,"dispatch1"))); + var completion=new Completion(7,"complete1",List.of(102L)); + current=tx.execute(s->service.complete("9","CG-1",batch1,"7",completion)); + assertThat(current.stockQuantity()).isEqualTo(6);assertThat(current.reservedQuantity()).isEqualTo(6);assertThat(current.status()).isEqualTo("IN_TRANSIT"); + var replay=tx.execute(s->service.complete("9","CG-1",batch1,"7",completion)); + assertThat(replay.version()).isEqualTo(8);assertThat(replay.stockQuantity()).isEqualTo(6); + tx.execute(s->service.dispatch("9","CG-1",batch2,"7",new Command(8,"dispatch2"))); + current=tx.execute(s->service.complete("9","CG-1",batch2,"7",new Completion(9,"complete2",List.of(101L)))); + assertThat(current.status()).isEqualTo("COMPLETED");assertThat(current.stockQuantity()).isZero();assertThat(current.availableQuantity()).isZero(); + assertThat(orders.findNo("9","CG-1").getInboundQuantity()).isZero(); + assertThat(current.files()).hasSize(3); + } + + @Test void partialPurchaseCanFinishButRemainingStockRequiresMoreOutbounds() { + tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",6,List.of(101L)))); + tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask"))); + tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok"))); + var current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"o",2,"TRACK"))); + long batch=current.outbounds().get(0).id(); + tx.execute(s->service.dispatch("9","CG-1",batch,"7",new Command(4,"d"))); + current=tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(5,"c",List.of(102L)))); + assertThat(current.status()).isEqualTo("CONFIRMED");assertThat(current.stockQuantity()).isEqualTo(4); + current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(6,"o2",4,"TRACK-2"))); + assertThat(current.availableQuantity()).isZero();assertThat(current.outbounds()).hasSize(2); + } + + @Test void validatesQuantityPhotosCustomerFilesVersionsAndIdempotency() { + assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"pdf",1,List.of(102L))))).hasMessageContaining("图片"); + assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"foreign",1,List.of(103L))))).hasMessageContaining("不属于"); + assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"over",11,List.of(101L))))).hasMessageContaining("超过"); + assertThatThrownBy(()->service.read("10","CG-1")).hasMessageContaining("不属于"); + assertThatThrownBy(()->tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(0,"empty")))).hasMessageContaining("收货"); + var request=new Receipt(0,"r",6,List.of(101L));tx.execute(s->service.receive("9","CG-1","7",request)); + assertThat(tx.execute(s->service.receive("9","CG-1","7",request)).version()).isEqualTo(1); + assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",5,List.of(101L))))).hasMessageContaining("幂等键"); + assertThatThrownBy(()->tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(0,"stale")))).hasMessageContaining("已更新"); + service.requireVisibleFile("9","CG-1",101L); + assertThatThrownBy(()->service.requireVisibleFile("9","CG-1",102L)).hasMessageContaining("未关联"); + } + + @Test void concurrentOutboundsCannotOversellAndCompletionRequiresDispatchAndProof() throws Exception { + tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L)))); + tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask"))); + tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok"))); + var pool=Executors.newFixedThreadPool(2); + try { + var start=new CountDownLatch(1);var succeeded=new AtomicInteger(); + var tasks=new ArrayList>(); + for(int i=0;i<2;i++) {int index=i;tasks.add(pool.submit(()->{try {start.await();tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"out"+index,7,"TN"+index)));succeeded.incrementAndGet();}catch(ResponseStatusException expected) { }catch(InterruptedException e) {throw new RuntimeException(e);}}));} + start.countDown();for(var future:tasks)future.get(10,TimeUnit.SECONDS); + assertThat(succeeded.get()).isEqualTo(1); + } finally {pool.shutdownNow();} + var current=service.read("9","CG-1");assertThat(current.availableQuantity()).isEqualTo(3); + long batch=current.outbounds().get(0).id(); + assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",new Outbound(4,"over",4,"T")))).hasMessageContaining("超过"); + assertThatThrownBy(()->tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(4,"early",List.of(102L))))).hasMessageContaining("派送中"); + tx.execute(s->service.dispatch("9","CG-1",batch,"7",new Command(4,"d"))); + assertThatThrownBy(()->tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(5,"empty",List.of())))).hasMessageContaining("凭证"); + assertThat(service.read("9","CG-1").stockQuantity()).isEqualTo(10); + } + + @Test void outboundRequiresCurrentReviewedReceivablesAndRejectedAttemptsLeaveNoInventoryChanges() { + tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L)))); + tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask"))); + tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok"))); + var request=new Outbound(3,"out",3,"TRACK"); + for(var rows:List.of(List.of(),List.of(fee("VOIDED","APPROVED")))) { + when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(rows); + assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细"); + } + var payable=fee("ACTIVE","APPROVED");payable.setDirection("PAYABLE"); + when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(payable)); + assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细"); + for(String review:List.of("PENDING","REJECTED")) { + when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("ACTIVE",review))); + assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("尚未全部审核通过"); + } + assertThat(service.read("9","CG-1").version()).isEqualTo(3); + assertThat(service.read("9","CG-1").reservedQuantity()).isZero(); + assertThat(service.read("9","CG-1").outbounds()).isEmpty(); + when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("VOIDED","REJECTED"))); + var result=tx.execute(s->service.outbound("9","CG-1","7",request)); + assertThat(result.reservedQuantity()).isEqualTo(3); + when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of()); + assertThat(tx.execute(s->service.outbound("9","CG-1","7",request)).version()).isEqualTo(4); + } + + private OverseasFeeSource fee(String status,String review) { + var fee=new OverseasFeeSource();fee.setDirection("RECEIVABLE");fee.setSourceStatus(status);fee.setReviewStatus(review);return fee; + } + + private void file(long id,long orderId,String customer,String type) { + var file=new OverseasProcurementFile();file.setId(id);file.setOrderId(orderId);file.setCustomerId(customer);file.setFileId(id);file.setCategory("RECEIPT");file.setFileName("proof-"+id);file.setContentType(type);file.setCreatedBy("7");files.insert(file); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index b69f00f26..5de12fe2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -97,14 +97,12 @@ class UsTruckOrderServiceTest { } @Test - void firstActualReceiptAcceptsRealQuantityWithoutForecastOverage() { + void reviewEntersDispatchWithoutInventingPallets() { order.setPalletCount(0); - service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(0L, "APPROVE", "review-new", null)); + var reviewed = service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(0L, "APPROVE", "review-new", null, 1L)); + assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status()); assertTrue(palletRows.isEmpty()); - var received = service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2), "first-real", null)); - assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, received.summary().status()); - assertEquals(3, received.summary().receivedPalletCount()); - assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus()))); + assertEquals(0, reviewed.summary().receivedPalletCount()); } @Test @@ -116,90 +114,53 @@ class UsTruckOrderServiceTest { @Test void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() { - var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve"); + var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "approve", "approve", 1L); var reviewed = service.review("100", "TK1", "operator-7", review); - assertEquals(UsTruckOrderService.WAITING_INBOUND, reviewed.summary().status()); - assertEquals(2, palletRows.size()); + assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status()); + assertTrue(palletRows.isEmpty()); assertEquals(1L, reviewed.summary().version()); assertEquals(1L, service.review("100", "TK1", "operator-7", review).summary().version()); assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator-7", new UsTruckOrderRequests.Review(1L, "REJECT", "review-1", "different"))); - var receive = new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2), - "receive-1", "receive overage"); - var received = service.receivePallets("100", "TK1", "receiver-8", receive); - assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, received.summary().status()); - assertEquals(3, received.summary().receivedPalletCount()); - assertEquals(3, palletRows.size()); - assertEquals(2L, service.receivePallets("100", "TK1", "receiver-8", receive).summary().version()); - - var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE", - "overage-1", "approve overage"); - var overageReviewed = service.reviewPalletOverage("100", "TK1", "reviewer-9", overage); - assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, overageReviewed.summary().status()); - assertEquals(3, overageReviewed.summary().palletCount()); - assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus()))); - - Long first = palletRows.get(0).getId(); - List remaining = palletRows.subList(1, 3).stream().map(UsTruckOrderPallet::getId).toList(); - var partial = service.allocatePallets("100", "TK1", "allocator-10", - new UsTruckOrderRequests.AllocatePallets(3L, List.of(first), "allocate-1", "partial")); - assertEquals(UsTruckOrderService.PARTIALLY_ALLOCATED, partial.summary().status()); - assertEquals(1, partial.summary().allocatedPalletCount()); - var allocated = service.allocatePallets("100", "TK1", "allocator-10", - new UsTruckOrderRequests.AllocatePallets(4L, remaining, "allocate-2", "all")); - assertEquals(UsTruckOrderService.ALLOCATED, allocated.summary().status()); - assertEquals(3, allocated.summary().allocatedPalletCount()); - - assertEquals(UsTruckOrderService.PENDING_DISPATCH, service.prepareDispatch("100", "TK1", "dispatcher-11", - command(5L, "prepare-1")).summary().status()); assertEquals(UsTruckOrderService.DISPATCHED, service.dispatch("100", "TK1", "dispatcher-11", - command(6L, "dispatch-1")).summary().status()); + command(1L, "dispatch-1")).summary().status()); assertEquals(UsTruckOrderService.IN_TRANSIT, service.startTransit("100", "TK1", "driver-12", - command(7L, "transit-1")).summary().status()); - var completed = service.complete("100", "TK1", "driver-12", command(8L, "complete-1")); + command(2L, "transit-1")).summary().status()); + var completed = service.complete("100", "TK1", "driver-12", command(3L, "complete-1")); assertEquals(UsTruckOrderService.COMPLETED, completed.summary().status()); - assertEquals(9L, completed.summary().version()); - assertEquals(9L, service.complete("100", "TK1", "driver-12", command(8L, "complete-1")) + assertEquals(4L, completed.summary().version()); + assertEquals(4L, service.complete("100", "TK1", "driver-12", command(3L, "complete-1")) .summary().version()); assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7", - new UsTruckOrderRequests.ChangeStatus(9L, "CANCELLED", "cancel-terminal", "forbidden"))); + new UsTruckOrderRequests.ChangeStatus(4L, "CANCELLED", "cancel-terminal", "forbidden"))); assertThrows(ResponseStatusException.class, () -> service.startTransit("100", "TK1", "driver-12", - command(8L, "reverse-state"))); + command(3L, "reverse-state"))); assertThrows(ResponseStatusException.class, () -> service.dispatch("100", "TK1", "dispatcher-11", command(1L, "stale-version"))); - assertEquals(List.of("CREATE", "REVIEW", "RECEIVE_PALLETS", "REVIEW_PALLET_OVERAGE", - "ALLOCATE_PALLETS", "ALLOCATE_PALLETS", "PREPARE_DISPATCH", "DISPATCH", - "START_TRANSIT", "COMPLETE"), + assertEquals(List.of("CREATE", "REVIEW", "DISPATCH", "START_TRANSIT", "COMPLETE"), historyRows.stream().map(UsTruckOrderStatusHistory::getOperationType).toList()); assertTrue(historyRows.stream().skip(1).allMatch(row -> row.getOperatorId() != null && row.getIdempotencyKey() != null)); } @Test - void partialReceiptUsesReceivingThenExactReceiptMovesToConsolidation() { - service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( - 0L, "APPROVE", "review-partial", null)); - var partial = service.receivePallets("100", "TK1", "receiver", - new UsTruckOrderRequests.ReceivePallets(1L, 1, 2, List.of(2), "receive-partial", null)); - assertEquals(UsTruckOrderService.RECEIVING, partial.summary().status()); - var exact = service.receivePallets("100", "TK1", "receiver", - new UsTruckOrderRequests.ReceivePallets(2L, 2, 4, List.of(2, 2), "receive-exact", null)); - assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, exact.summary().status()); + void reviewIsIdempotentAtDispatchBoundary() { + var reviewed = service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( + 0L, "APPROVE", "review-partial", null, 1L)); + assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status()); + assertEquals(1L, service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( + 0L, "APPROVE", "review-partial", null, 1L)).summary().version()); } @Test - void rejectedOverageExcludesExtraPalletFromAcceptedCount() { - service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( - 0L, "APPROVE", "review-reject-overage", null)); - service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets( - 1L, 3, 6, List.of(2, 2, 2), "receive-reject-overage", null)); - var reviewed = service.reviewPalletOverage("100", "TK1", "reviewer", - new UsTruckOrderRequests.ReviewPalletOverage(2L, "REJECT", "reject-overage", null)); - assertEquals(2, reviewed.summary().receivedPalletCount()); - assertEquals("OVERAGE_REJECTED", palletRows.get(2).getStatus()); + void rejectedReviewDoesNotEnterDispatch() { + var reviewed = service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( + 0L, "REJECT", "review-reject", "资料不完整")); + assertEquals(UsTruckOrderService.REJECTED, reviewed.summary().status()); + assertTrue(palletRows.isEmpty()); } private UsTruckOrderRequests.Command command(long version, String key) { @@ -222,3 +183,4 @@ class UsTruckOrderServiceTest { return value; } } + diff --git a/scripts/db/local-platform-admin-dispatch-depart-repair-20261027.sql b/scripts/db/local-platform-admin-dispatch-depart-repair-20261027.sql new file mode 100644 index 000000000..49cf5d3e4 --- /dev/null +++ b/scripts/db/local-platform-admin-dispatch-depart-repair-20261027.sql @@ -0,0 +1,28 @@ +-- 本机旧权限目录修复:按权限编码补齐内置平台管理员的确认发车授权。 +-- 只连接本机 baiyue_iam 执行;保留权限真实 ID,不覆盖其他角色或现有授权。 +USE baiyue_iam; +START TRANSACTION; +CREATE TEMPORARY TABLE tmp_local_admin_depart_repair AS +SELECT r.id AS role_id, p.id AS permission_id +FROM iam_role r +JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code='platform:overseas-warehouse:dispatch:depart' + AND p.status='ACTIVE' AND p.deleted=0 +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' + AND r.status='ACTIVE' AND r.deleted=0 + AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp + WHERE rp.tenant_id=0 AND rp.role_id=r.id AND rp.permission_id=p.id); +INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at) +SELECT 0,role_id,permission_id,CURRENT_TIMESTAMP(3) FROM tmp_local_admin_depart_repair; +-- 与授权同事务推进所有受影响有效平台成员的安全版本,旧会话不能继续使用旧权限。 +INSERT INTO iam_security_subject_version (auth_user_id,permission_version) +SELECT DISTINCT u.auth_user_id,1 +FROM iam_user u +JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0 AND m.status='ACTIVE' AND m.deleted=0 +JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0 +JOIN tmp_local_admin_depart_repair change_row ON change_row.role_id=mr.role_id +WHERE u.status='ACTIVE' AND u.deleted=0 AND u.auth_user_id IS NOT NULL +ON DUPLICATE KEY UPDATE permission_version=permission_version+1; +SELECT COUNT(*) AS added_admin_depart_grants FROM tmp_local_admin_depart_repair; +DROP TEMPORARY TABLE tmp_local_admin_depart_repair; +COMMIT;