Compare commits
16
Commits
4f2d90b490
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a9ce7d27 | ||
|
|
035e7e06af | ||
|
|
5825739b92 | ||
|
|
edd4b1cd38 | ||
|
|
51a93a69af | ||
|
|
872215367b | ||
|
|
ac013c265b | ||
|
|
e1bfcfc8f5 | ||
|
|
0bd25de906 | ||
|
|
849f8dc5f2 | ||
|
|
1b27d7e6c2 | ||
|
|
353c86f4cf | ||
|
|
71a1026e21 | ||
|
|
f3d57869f2 | ||
|
|
7086713fdd | ||
|
|
66b0935b11 |
@@ -55,7 +55,7 @@
|
|||||||
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
|
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
|
||||||
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
|
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
|
||||||
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
|
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
|
||||||
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` |
|
| 海外业务费用来源跨区入池、人工费用与自动仓租 | `lastleg-service/`(`financefee/`、`storagerent/`)、`common-event-contract/`、`finance/` |
|
||||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
|
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
|
||||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
|
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
|
||||||
| 供应商、国内整柜库存与出入库 | `warehouse/` |
|
| 供应商、国内整柜库存与出入库 | `warehouse/` |
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
# 单测试环境数据库空库初始化
|
# 单测试环境数据库空库初始化
|
||||||
|
|
||||||
|
散板收货闭环:`migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql` 为已有 Lastleg 库增加入仓方式、收货仓、联系人、预计到仓、物流号及转运关联字段;新库使用同步后的海外统一基线。IAM 使用 `migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql` 补齐“散板需求”菜单及“加入散板货物”按钮,并为既有平台管理员补取货排车创建权限;权限源同步于 `iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql` 和国内统一基线。两项已在本机应用并通过两场景HTTP验收,未据此认定TEST或生产已部署;已有库不可重放空库基线,V37执行前须核对九个新增字段尚不存在。
|
||||||
|
|
||||||
|
`migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql` 修复海外人工费用资金重试报 S0500:仅将 `baiyue_lastleg.ll_finance_fee_command.action` 扩为 `VARCHAR(32)`,容纳 20 字符的 `MANUAL_FUNDING_RETRY`,保留中文备注、非空及原幂等唯一键。Lastleg 源基线和海外空库基线同步。已有环境先核对目标库及列定义,经授权执行 V35、回查列长度和记录总数,再通过原费用重试入口验证;可重复执行,不修改费用、审核、余额或资金记录,不重放空库基线。DDL 隐式提交,遇错停止;本次仅准备修复文件,尚未执行测试环境 SQL 或部署,文件存在不表示已经执行。
|
||||||
|
|
||||||
`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。
|
`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。
|
||||||
|
|
||||||
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
|
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
|
||||||
|
|
||||||
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
|
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
|
||||||
|
|
||||||
|
`finance-reconciliation-line-uniqueness-repair.sql` 修正 TEST 作废客户对账单后原费用无法重新生成对账单的问题:以一条原子 ALTER 将 `baiyue_finance.fin_bill_line` 的费用版本全历史唯一键改为单内唯一键,并保留费用来源普通索引。作废明细和金额快照完整保留;有效费用占用继续由 Finance 生成时的行锁、AVAILABLE → LOCKED 状态校验及作废单重开校验保证。模块基线与统一空库基线同步。先核对上述服务校验及目标表结构,经单独授权执行,再核对结构和记录总数;遇错停止,结果不明确先回查,不盲目重放,不更新业务数据或重放空库基线。脚本存在不表示已执行。
|
||||||
|
|
||||||
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
|
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
|
||||||
|
|
||||||
`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。
|
`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。
|
||||||
@@ -18,6 +24,8 @@
|
|||||||
|
|
||||||
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
||||||
|
|
||||||
|
船节点日志在 `fl_carrier_schedule_node_fact.operator_id/operator_name` 保存操作时的账号与用户名快照;人工补录和确认出仓写入,船司自动采集为空。两列定义同步于 Firstleg 船节点模块基线与国内统一空库基线,更新结构后配套发布 Firstleg 与 Admin。既有 TEST 的字段变更须单独授权执行,不重放空库初始化,不回填历史节点。
|
||||||
|
|
||||||
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
|
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
|
||||||
|
|
||||||
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
|
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
|
||||||
@@ -220,3 +228,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
|||||||
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
|
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
|
||||||
|
|
||||||
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
|
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
|
||||||
|
|
||||||
|
|
||||||
|
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 仅用于单独授权的 TEST:修正作废对账单历史明细阻止原费用重新生成对账单的问题。
|
||||||
|
-- 固定目标 MySQL 8 / InnoDB;保留全部账单、明细、金额及修订,不更新、删除或回填业务记录。
|
||||||
|
-- 单内同一费用版本保持唯一;跨单有效占用由 Finance 生成时的费用行锁和状态校验保证。
|
||||||
|
-- 前置:Finance 已具备生成时 AVAILABLE -> LOCKED 校验,以及作废单重开时的来源重新占用校验。
|
||||||
|
-- 执行前后核对 SHOW CREATE TABLE 与记录总数;本文件存在不表示已经执行。
|
||||||
|
-- 使用独立连接并开启遇错停止;一条原子 ALTER 替换索引,结果不明确先回查,不盲目重放。
|
||||||
|
ALTER TABLE baiyue_finance.fin_bill_line
|
||||||
|
DROP INDEX uk_fin_bill_line_source,
|
||||||
|
ADD UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
|
||||||
|
ADD KEY idx_fin_bill_line_source (source_type, source_id, source_version);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
|
||||||
|
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
|
||||||
|
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
|
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- 海外费用资金重试动作长度修复:兼容 MANUAL_FUNDING_RETRY 等费用幂等动作。
|
||||||
|
ALTER TABLE baiyue_lastleg.ll_finance_fee_command
|
||||||
|
MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Region: OVERSEAS
|
||||||
|
-- Schema: baiyue_lastleg
|
||||||
|
-- Purpose: 预算释放查询的客户和订单号关联字段与采购单、卡车单排序规则一致
|
||||||
|
-- Preconditions: ll_order_budget_reservation 已存在;核对列长度、可空性及索引
|
||||||
|
-- Postconditions: customer_id、order_no 使用 utf8mb4_unicode_ci;不修改任何业务数据
|
||||||
|
-- DDL 非事务,执行前核对目标库;失败后检查列定义再继续。
|
||||||
|
ALTER TABLE baiyue_lastleg.ll_order_budget_reservation
|
||||||
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
|
||||||
|
MODIFY COLUMN customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离标识',
|
||||||
|
MODIFY COLUMN order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '成功创建的业务单号';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE ll_local_looseboard_order
|
||||||
|
ADD COLUMN delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货',
|
||||||
|
ADD COLUMN operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID',
|
||||||
|
ADD COLUMN destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址',
|
||||||
|
ADD COLUMN destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编',
|
||||||
|
ADD COLUMN delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人',
|
||||||
|
ADD COLUMN delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话',
|
||||||
|
ADD COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
|
||||||
|
ADD COLUMN tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号',
|
||||||
|
ADD COLUMN transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因'''));
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照'''));
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
|
||||||
|
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
|
||||||
|
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
|
||||||
|
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
|
||||||
|
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
|
||||||
|
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
|
||||||
|
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
|
||||||
|
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
|
||||||
|
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
|
||||||
|
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
|
||||||
|
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
|
||||||
|
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
|
||||||
|
KEY idx_order_budget_release(status,release_requested,created_at),
|
||||||
|
KEY idx_order_budget_order(customer_id,kind,order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
|
||||||
@@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
|
|||||||
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
|
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
|
||||||
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
|
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
|
||||||
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
||||||
|
operator_id bigint unsigned DEFAULT NULL COMMENT '节点操作人账号ID;自动采集时为空',
|
||||||
|
operator_name varchar(128) DEFAULT NULL COMMENT '节点操作人用户名快照;自动采集时为空',
|
||||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
||||||
@@ -4763,7 +4765,8 @@ CREATE TABLE fin_bill_line (
|
|||||||
currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_fin_bill_line_source (source_type,source_id,source_version),
|
UNIQUE KEY uk_fin_bill_line_bill_source (bill_id,source_type,source_id,source_version),
|
||||||
|
KEY idx_fin_bill_line_source (source_type,source_id,source_version),
|
||||||
KEY idx_fin_bill_line_bill (tenant_id,bill_id),
|
KEY idx_fin_bill_line_bill (tenant_id,bill_id),
|
||||||
CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)),
|
CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)),
|
||||||
CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')),
|
CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')),
|
||||||
@@ -8465,3 +8468,24 @@ USE baiyue_iam;
|
|||||||
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
|
||||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
|
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
-- 现场扫描清单独立于客户选箱快照;重复执行不改现有业务数据。
|
||||||
|
SET @discard_actual_marks_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_carton_marks_json'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_carton_marks_json JSON NULL COMMENT ''PDA现场实际废弃箱唛清单,与客户选箱快照独立保存''');
|
||||||
|
PREPARE discard_actual_marks_stmt FROM @discard_actual_marks_ddl;
|
||||||
|
EXECUTE discard_actual_marks_stmt;
|
||||||
|
DEALLOCATE PREPARE discard_actual_marks_stmt;
|
||||||
|
|
||||||
|
USE baiyue_iam;
|
||||||
|
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
|
||||||
|
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
|
||||||
|
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
|
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
|
|||||||
@@ -917,7 +917,7 @@ CREATE TABLE ll_return_carton_stock_receipt (
|
|||||||
CREATE TABLE ll_finance_fee_command (
|
CREATE TABLE ll_finance_fee_command (
|
||||||
id bigint NOT NULL COMMENT '幂等命令ID',
|
id bigint NOT NULL COMMENT '幂等命令ID',
|
||||||
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
|
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
|
||||||
action varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID',
|
action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试',
|
||||||
idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键',
|
idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键',
|
||||||
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256',
|
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256',
|
||||||
source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID',
|
source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID',
|
||||||
@@ -1599,6 +1599,15 @@ CREATE TABLE ll_local_looseboard_order (
|
|||||||
id bigint NOT NULL COMMENT '主键ID',
|
id bigint NOT NULL COMMENT '主键ID',
|
||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||||
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
|
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
|
||||||
|
delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货',
|
||||||
|
operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID',
|
||||||
|
destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址',
|
||||||
|
destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编',
|
||||||
|
delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人',
|
||||||
|
delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话',
|
||||||
|
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
|
||||||
|
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号',
|
||||||
|
transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号',
|
||||||
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
|
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
|
||||||
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
|
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
|
||||||
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
|
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
|
||||||
@@ -2105,6 +2114,8 @@ CREATE TABLE ll_overseas_procurement_order (
|
|||||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
||||||
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
||||||
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
|
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
|
||||||
|
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
|
||||||
|
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
|
||||||
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
|
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
|
||||||
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
|
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
|
||||||
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
|
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
|
||||||
@@ -4897,4 +4908,25 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
|
|||||||
PRIMARY KEY (order_id,command_key)
|
PRIMARY KEY (order_id,command_key)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables;
|
CREATE TABLE ll_order_budget_reservation (
|
||||||
|
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
|
||||||
|
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
|
||||||
|
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
|
||||||
|
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
|
||||||
|
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
|
||||||
|
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
|
||||||
|
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
|
||||||
|
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
|
||||||
|
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
|
||||||
|
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
|
||||||
|
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
|
||||||
|
KEY idx_order_budget_release(status,release_requested,created_at),
|
||||||
|
KEY idx_order_budget_order(customer_id,kind,order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
|
||||||
|
|
||||||
|
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 223 AS initialized_tables;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ enum BusinessFileCategory {
|
|||||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
@@ -82,7 +84,8 @@ enum BusinessFileCategory {
|
|||||||
&& tenantId != 0) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||||
}
|
}
|
||||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE)
|
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE
|
||||||
|
|| this == OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE || this == OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE)
|
||||||
&& tenantId != 0) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class LooseboardTransportPhotoFileTest {
|
||||||
|
/** 两节点文件独立归属原排车,客户文件及PDF不能充当现场照片。 */
|
||||||
|
@Test void photosHaveDistinctPlatformOwnershipAndImagePolicy(){
|
||||||
|
var pickup=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE");
|
||||||
|
var arrival=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE");
|
||||||
|
assertThat(pickup).isNotEqualTo(arrival);
|
||||||
|
for(var category:new BusinessFileCategory[]{pickup,arrival}){
|
||||||
|
category.validateOwner(0);assertThatThrownBy(()->category.validateOwner(100)).hasMessageContaining("平台");
|
||||||
|
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
|
||||||
|
assertThat(category.storageBusinessId("100")).isEqualTo("100");
|
||||||
|
assertThatThrownBy(()->category.storageBusinessId("../100")).isInstanceOf(RuntimeException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,7 +148,10 @@ public class BusinessFeeSourceEventService {
|
|||||||
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
|
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
|
||||||
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
|
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
|
||||||
walletIdentity(value);
|
walletIdentity(value);
|
||||||
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
|
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())
|
||||||
|
// 新采购主单复用人工收费,只有显式账单结算可入池;旧采购和余额费用仍必须引用实扣。
|
||||||
|
&& !("LASTLEG".equals(value.sourceSystem()) && "OVERSEAS_PROCUREMENT".equals(value.businessType())
|
||||||
|
&& "BILL".equals(value.settlementMode()))) {
|
||||||
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
|
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
|
||||||
}
|
}
|
||||||
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
|
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
|
|||||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version),
|
UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
|
||||||
|
KEY idx_fin_bill_line_source (source_type, source_id, source_version),
|
||||||
KEY idx_fin_bill_line_bill (tenant_id, bill_id),
|
KEY idx_fin_bill_line_bill (tenant_id, bill_id),
|
||||||
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
|
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
|
||||||
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
|
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
|
||||||
|
|||||||
@@ -328,6 +328,53 @@ class BusinessFeeSourceEventServiceTest {
|
|||||||
noFinancialWrites();
|
noFinancialWrites();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 新采购主单只有明确选择账单时才可作为未收应收入池。 */
|
||||||
|
@Test
|
||||||
|
void manualProcurementExplicitBillCanEnterReceivablePool() {
|
||||||
|
EventEnvelope event = manualProcurementEvent("BILL");
|
||||||
|
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
|
||||||
|
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
|
||||||
|
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
|
||||||
|
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||||
|
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采购主单的余额或默认模式不能因兼容账单而绕过实扣校验。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.NullSource
|
||||||
|
@ValueSource(strings = {"WALLET"})
|
||||||
|
void manualProcurementWithoutConsumptionCannotEnterPool(String mode) {
|
||||||
|
assertThatThrownBy(() -> service.consume(manualProcurementEvent(mode))).hasMessageContaining("必须引用成功实扣");
|
||||||
|
noFinancialWrites();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保留旧采购子费用的扣款约束,即使载荷声明账单结算也不能入池。 */
|
||||||
|
@Test
|
||||||
|
void legacyProcurementCannotBypassConsumptionWithBillMode() {
|
||||||
|
EventEnvelope base = procurementEvent(false);
|
||||||
|
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
|
||||||
|
payload.put("settlementMode", "BILL");
|
||||||
|
assertThatThrownBy(() -> service.consume(resignProcurementEvent(base, payload))).hasMessageContaining("必须引用成功实扣");
|
||||||
|
noFinancialWrites();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventEnvelope manualProcurementEvent(String mode) {
|
||||||
|
EventEnvelope base = procurementEvent(false);
|
||||||
|
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
|
||||||
|
payload.put("businessType", "OVERSEAS_PROCUREMENT");
|
||||||
|
if (mode == null) payload.putNull("settlementMode"); else payload.put("settlementMode", mode);
|
||||||
|
return resignProcurementEvent(base, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventEnvelope resignProcurementEvent(EventEnvelope base, ObjectNode payload) {
|
||||||
|
payload.putNull("action"); payload.putNull("sourceHash");
|
||||||
|
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||||
|
payload.put("action", "PUBLISHED");
|
||||||
|
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
|
||||||
|
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
|
||||||
|
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
|
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
|
||||||
EventEnvelope event = procurementEvent(true);
|
EventEnvelope event = procurementEvent(true);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class FinanceReconciliationSchemaContractTest {
|
||||||
|
|
||||||
|
/** 执行真实明细表约束,复现两笔费用作废后重新出单,保留原单快照且单内不能重复。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = {false, true})
|
||||||
|
void voidedStatementKeepsHistoryWhileItsTwoSourcesCanEnterANewStatement(boolean unified) throws Exception {
|
||||||
|
JdbcTemplate jdbc = createSchema(unified);
|
||||||
|
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT NOT NULL,status VARCHAR(32) NOT NULL)");
|
||||||
|
jdbc.update("INSERT INTO fin_bill VALUES(501,9,'ISSUED'),(502,9,'DRAFT')");
|
||||||
|
insertLine(jdbc, 1, 501, 88);
|
||||||
|
insertLine(jdbc, 2, 501, 89);
|
||||||
|
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=501");
|
||||||
|
|
||||||
|
insertLine(jdbc, 3, 502, 88);
|
||||||
|
insertLine(jdbc, 4, 502, 89);
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=501", Integer.class)).isEqualTo(2);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=502", Integer.class)).isEqualTo(2);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT status FROM fin_bill WHERE id=501", String.class)).isEqualTo("VOIDED");
|
||||||
|
assertThat(jdbc.queryForObject("SELECT SUM(l.amount) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id WHERE b.status<>'VOIDED'", BigDecimal.class))
|
||||||
|
.isEqualByComparingTo("200.00");
|
||||||
|
assertThatThrownBy(() -> insertLine(jdbc, 5, 502, 88)).isInstanceOf(DuplicateKeyException.class);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line", Integer.class)).isEqualTo(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从模块或统一空库入口读取真实建表语句,仅移除 H2 不支持的 MySQL 存储语法。 */
|
||||||
|
private static JdbcTemplate createSchema(boolean unified) throws Exception {
|
||||||
|
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||||
|
if (!Files.exists(unifiedPath)) unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||||
|
String sql = Files.readString(unified ? unifiedPath : Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||||
|
var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_bill_line \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql);
|
||||||
|
assertThat(table.find()).isTrue();
|
||||||
|
String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");")
|
||||||
|
.replaceAll("(?i) COMMENT '(?:[^']|'')*'", "")
|
||||||
|
.replaceAll("(?i)\\bUNSIGNED\\b|_utf8mb4", "")
|
||||||
|
.replaceAll("(?m)^\\s*KEY [^\\r\\n]+\\r?\\n", "")
|
||||||
|
.replaceAll("UNIQUE KEY (\\w+)", "CONSTRAINT $1 UNIQUE");
|
||||||
|
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"));
|
||||||
|
jdbc.execute(ddl);
|
||||||
|
return jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入完整合成明细,仅改变本测试关注的单据和费用来源身份。 */
|
||||||
|
private static void insertLine(JdbcTemplate jdbc, long id, long billId, long sourceId) {
|
||||||
|
jdbc.update("INSERT INTO fin_bill_line(id,tenant_id,bill_id,source_type,source_id,source_version,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,9,?,'FIRSTLEG_FEE',?,1,'SYNTHETIC','合成测试费用',100,'CNY')",
|
||||||
|
id, billId, sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,22 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已被占用的费用不能完成第二张有效对账单的生成,事务必须拒绝提交。 */
|
||||||
|
@Test
|
||||||
|
void createRejectsSourceWhoseAvailableStatusCannotBeLocked() throws Exception {
|
||||||
|
JdbcTemplate jdbc = createJdbc();
|
||||||
|
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)))
|
||||||
|
.thenReturn(0);
|
||||||
|
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
|
||||||
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> serviceWithNumber(jdbc).create(71L, "create-key", request))
|
||||||
|
.isInstanceOf(FinanceEventException.class)
|
||||||
|
.hasMessage("费用来源已被其他对账单占用")
|
||||||
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
|
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
|
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
|
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
|
||||||
@@ -428,6 +444,24 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
|
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 作废单的两笔原费用有任一已进入新单时,旧单不得重开为另一张有效单。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(ints = {0, 1})
|
||||||
|
void reopenRejectsVoidedStatementWhenReplacementOccupiesAnySource(int reacquired) throws Exception {
|
||||||
|
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-conflict-key");
|
||||||
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L)))
|
||||||
|
.thenReturn(2);
|
||||||
|
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)))
|
||||||
|
.thenReturn(reacquired);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "reopen-conflict-key",
|
||||||
|
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of())))
|
||||||
|
.isInstanceOf(FinanceEventException.class)
|
||||||
|
.hasMessage("原费用来源已被占用,无法重开对账单")
|
||||||
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
|
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
|
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
|
||||||
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
|
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@
|
|||||||
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
|
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
|
||||||
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
|
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
|
||||||
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” |
|
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” |
|
||||||
| 费用项目、价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`pricebookquery/application/PriceBookQueryService.java`;查“价格簿查询内部切片” |
|
| 费用项目、共享费用目录与价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`controller/InternalFeeCatalogController.java`、`pricebookquery/application/PriceBookQueryService.java`;共享目录契约 `src/main/resources/openapi/fee-catalog-internal-v1.yaml`,价格簿查“价格簿查询内部切片” |
|
||||||
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
|
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
|
||||||
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
|
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
|
||||||
| 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” |
|
| 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” |
|
||||||
|
|||||||
+12
-5
@@ -35,6 +35,13 @@ public interface ContainerCustomsMapper {
|
|||||||
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
|
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
|
||||||
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
|
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
|
||||||
""";
|
""";
|
||||||
|
String CONTAINER_COLUMNS = """
|
||||||
|
id id,loading_plan_id loadingPlanId,container_booking_id containerBookingId,status status,
|
||||||
|
started_by startedBy,started_by_name startedByName,started_at startedAt,
|
||||||
|
completed_by completedBy,completed_by_name completedByName,completed_at completedAt,
|
||||||
|
broker_submitted_by brokerSubmittedBy,broker_submitted_by_name brokerSubmittedByName,
|
||||||
|
broker_submitted_at brokerSubmittedAt,created_at createdAt,updated_at updatedAt,version version
|
||||||
|
""";
|
||||||
|
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
|
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
|
||||||
@@ -132,19 +139,19 @@ public interface ContainerCustomsMapper {
|
|||||||
int insertContainer(@Param("id") long id,@Param("planId") long planId,
|
int insertContainer(@Param("id") long id,@Param("planId") long planId,
|
||||||
@Param("containerBookingId") long containerBookingId,@Param("status") String status);
|
@Param("containerBookingId") long containerBookingId,@Param("status") String status);
|
||||||
|
|
||||||
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}")
|
@Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId}")
|
||||||
ContainerRow container(@Param("planId") long planId);
|
ContainerRow container(@Param("planId") long planId);
|
||||||
|
|
||||||
@Select("SELECT * FROM fl_customs_container WHERE id=#{id}")
|
@Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id}")
|
||||||
ContainerRow containerById(@Param("id") long id);
|
ContainerRow containerById(@Param("id") long id);
|
||||||
|
|
||||||
@Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
|
@Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
|
||||||
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
|
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
|
||||||
|
|
||||||
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
|
@Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
|
||||||
ContainerRow lockContainer(@Param("planId") long planId);
|
ContainerRow lockContainer(@Param("planId") long planId);
|
||||||
|
|
||||||
@Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
|
@Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
|
||||||
ContainerRow lockContainerById(@Param("id") long id);
|
ContainerRow lockContainerById(@Param("id") long id);
|
||||||
|
|
||||||
@Update("""
|
@Update("""
|
||||||
|
|||||||
+2
-1
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
|
|||||||
String providerNodeCode, String standardNode,
|
String providerNodeCode, String standardNode,
|
||||||
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||||
LocalDateTime actualAt, String locationCode, String locationName,
|
LocalDateTime actualAt, String locationCode, String locationName,
|
||||||
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
|
String sourceReference, String rawPayloadHash, LocalDateTime createdAt,
|
||||||
|
String operatorName) {}
|
||||||
|
|
||||||
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
|
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
|
||||||
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
|
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
|
||||||
|
|||||||
+2
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
|
|||||||
private String locationName;
|
private String locationName;
|
||||||
private String sourceReference;
|
private String sourceReference;
|
||||||
private String rawPayloadHash;
|
private String rawPayloadHash;
|
||||||
|
private Long operatorId;
|
||||||
|
private String operatorName;
|
||||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -11,14 +11,15 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
||||||
|
/** 保存不可变节点事实及执行该操作的账号快照。 */
|
||||||
@Insert("""
|
@Insert("""
|
||||||
INSERT INTO fl_carrier_schedule_node_fact
|
INSERT INTO fl_carrier_schedule_node_fact
|
||||||
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
|
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
|
||||||
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
|
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,operator_id,operator_name,created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
|
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
|
||||||
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
|
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
|
||||||
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
|
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
|
||||||
CURRENT_TIMESTAMP(3))
|
CURRENT_TIMESTAMP(3))
|
||||||
""")
|
""")
|
||||||
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
|
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
|
||||||
|
|||||||
+17
-9
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
|
|||||||
|
|
||||||
public long customerId(long id) { return required(id).getCustomerId(); }
|
public long customerId(long id) { return required(id).getCustomerId(); }
|
||||||
|
|
||||||
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
|
/** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
|
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
|
||||||
long containerBookingId,
|
long containerBookingId,
|
||||||
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
|
|||||||
persistenceStage = "NODE_FACT";
|
persistenceStage = "NODE_FACT";
|
||||||
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
sha256("OUTBOUND_CONFIRM|" + containerBookingId), operatorId, operatorName);
|
||||||
if (departed != null) {
|
if (departed != null) {
|
||||||
persistenceStage = "CROSS_REGION_OUTBOX";
|
persistenceStage = "CROSS_REGION_OUTBOX";
|
||||||
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||||
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
|
|||||||
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
|
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
|
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
|
||||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||||
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
|
|||||||
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
|
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
|
||||||
request.providerNodeCode(), node,
|
request.providerNodeCode(), node,
|
||||||
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
||||||
request.locationName(), request.sourceReference(), request.rawPayloadHash());
|
request.locationName(), request.sourceReference(), request.rawPayloadHash(), null, null);
|
||||||
if (fact == null) {
|
if (fact == null) {
|
||||||
return detail(value);
|
return detail(value);
|
||||||
}
|
}
|
||||||
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
|
|||||||
return detail(value);
|
return detail(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
|
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
|
||||||
ManualNodeRequest request) {
|
ManualNodeRequest request) {
|
||||||
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
|
|||||||
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
|
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
|
||||||
request.observedAt(),
|
request.observedAt(),
|
||||||
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
||||||
"MANUAL:" + operatorId, hash(operation, id, request));
|
"MANUAL:" + operatorId, hash(operation, id, request), operatorId, operatorName);
|
||||||
if (fact == null) {
|
if (fact == null) {
|
||||||
return complete(id, operatorId, key, detail(value));
|
return complete(id, operatorId, key, detail(value));
|
||||||
}
|
}
|
||||||
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
|
|||||||
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
|
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
|
||||||
String operatorName, String key,
|
String operatorName, String key,
|
||||||
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
|
|||||||
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
|
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
|
||||||
"MANUAL", providerEventId,
|
"MANUAL", providerEventId,
|
||||||
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
|
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
|
||||||
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
|
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash,
|
||||||
|
operatorId, operatorName);
|
||||||
if (fact != null && query != null) {
|
if (fact != null && query != null) {
|
||||||
CarrierScheduleQueryEntity before = copy(query);
|
CarrierScheduleQueryEntity before = copy(query);
|
||||||
requireManualActualAdvance(query, node, request.actualAt());
|
requireManualActualAdvance(query, node, request.actualAt());
|
||||||
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
|
|||||||
repository.histories(value.getId()).stream().map(this::history).toList());
|
repository.histories(value.getId()).stream().map(this::history).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
|
||||||
private NodeFact node(CarrierScheduleNodeFactEntity value) {
|
private NodeFact node(CarrierScheduleNodeFactEntity value) {
|
||||||
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
|
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
|
||||||
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
|
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
|
||||||
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
|
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
|
||||||
value.getRawPayloadHash(), value.getCreatedAt());
|
value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
|
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
|
||||||
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
|
|||||||
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为查询任务追加节点事实并透传人工操作账号。 */
|
||||||
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
|
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
|
||||||
String providerNodeCode,
|
String providerNodeCode,
|
||||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||||
LocalDateTime actualAt, String locationCode, String locationName,
|
LocalDateTime actualAt, String locationCode, String locationName,
|
||||||
String sourceReference, String rawPayloadHash) {
|
String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
|
||||||
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
|
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
|
||||||
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
|
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
|
||||||
sourceReference, rawPayloadHash);
|
sourceReference, rawPayloadHash, operatorId, operatorName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
|
||||||
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
|
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
|
||||||
String providerCode, String providerEventId, String providerNodeCode,
|
String providerCode, String providerEventId, String providerNodeCode,
|
||||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||||
LocalDateTime actualAt, String locationCode, String locationName,
|
LocalDateTime actualAt, String locationCode, String locationName,
|
||||||
String sourceReference, String rawPayloadHash) {
|
String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
|
||||||
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
|
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
|
||||||
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
|
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
|
||||||
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
|
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
|
||||||
@@ -589,6 +596,7 @@ public class CarrierScheduleQueryService {
|
|||||||
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
|
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
|
||||||
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||||
|
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
|
||||||
if (repository.insertIfAbsent(fact)) return fact;
|
if (repository.insertIfAbsent(fact)) return fact;
|
||||||
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
|
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
|
||||||
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
|
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
|||||||
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
|
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
|
||||||
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
|
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
|
||||||
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
||||||
|
operator_id BIGINT UNSIGNED NULL COMMENT '节点操作人账号ID;自动采集时为空',
|
||||||
|
operator_name VARCHAR(128) NULL COMMENT '节点操作人用户名快照;自动采集时为空',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ components:
|
|||||||
locationCode: {type: string, maxLength: 32, nullable: true}
|
locationCode: {type: string, maxLength: 32, nullable: true}
|
||||||
locationName: {type: string, maxLength: 128, nullable: true}
|
locationName: {type: string, maxLength: 128, nullable: true}
|
||||||
sourceReference: {type: string, maxLength: 500, nullable: true}
|
sourceReference: {type: string, maxLength: 500, nullable: true}
|
||||||
|
operatorName: {type: string, maxLength: 128, nullable: true, description: '节点操作人用户名快照;人工补录和确认出仓时记录,自动采集为空'}
|
||||||
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
|
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||||
createdAt: {type: string, format: date-time}
|
createdAt: {type: string, format: date-time}
|
||||||
ExceptionFact:
|
ExceptionFact:
|
||||||
|
|||||||
+8
@@ -28,6 +28,14 @@ class ContainerCustomsMapperContractTest {
|
|||||||
assertThat(currentRead.getCache()).isNull();
|
assertThat(currentRead.getCache()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void containerQueriesExplicitlyMapEveryRecordComponentInOrder() throws Exception {
|
||||||
|
assertRecordProjection("container", ContainerCustomsMapper.ContainerRow.class, long.class);
|
||||||
|
assertRecordProjection("containerById", ContainerCustomsMapper.ContainerRow.class, long.class);
|
||||||
|
assertRecordProjection("containerByBooking", ContainerCustomsMapper.ContainerRow.class, long.class);
|
||||||
|
assertRecordProjection("lockContainer", ContainerCustomsMapper.ContainerRow.class, long.class);
|
||||||
|
assertRecordProjection("lockContainerById", ContainerCustomsMapper.ContainerRow.class, long.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception {
|
@Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception {
|
||||||
String value=sql("completedPlanForOrder",long.class,long.class);
|
String value=sql("completedPlanForOrder",long.class,long.class);
|
||||||
assertThat(value)
|
assertThat(value)
|
||||||
|
|||||||
+10
@@ -8,6 +8,16 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class CarrierScheduleQueryOpenApiContractTest {
|
class CarrierScheduleQueryOpenApiContractTest {
|
||||||
|
/** 两个节点读取入口使用相同的操作用户名响应契约。 */
|
||||||
|
@Test
|
||||||
|
void nodeFactsExposeTheRecordedOperatorUsername() throws Exception {
|
||||||
|
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
String node = yaml.substring(yaml.indexOf(" NodeFact:"), yaml.indexOf(" ExceptionFact:"));
|
||||||
|
assertThat(node).contains("operatorName: {type: string, maxLength: 128, nullable: true")
|
||||||
|
.contains("节点操作人用户名快照");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
|
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
|
||||||
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
|
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
|
||||||
|
|||||||
+17
@@ -16,6 +16,23 @@ import java.util.List;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class CarrierScheduleQuerySqlContractTest {
|
class CarrierScheduleQuerySqlContractTest {
|
||||||
|
/** 模块与部署基线共同保存节点署名,Mapper 同次写入两项快照。 */
|
||||||
|
@Test
|
||||||
|
void nodeFactsPersistOperatorSnapshotInBothBaselines() throws Exception {
|
||||||
|
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
for (String source : List.of(moduleSql(), unified)) {
|
||||||
|
String lower = source.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
int start = lower.indexOf("fl_carrier_schedule_node_fact (");
|
||||||
|
String table = lower.substring(start, lower.indexOf("engine=innodb", start));
|
||||||
|
assertThat(table).contains("operator_id bigint unsigned", "operator_name varchar(128)");
|
||||||
|
}
|
||||||
|
String insert = String.join("\n", com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper.class
|
||||||
|
.getMethod("insertStrict", com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)
|
||||||
|
.getAnnotation(org.apache.ibatis.annotations.Insert.class).value());
|
||||||
|
assertThat(insert).contains("operator_id,operator_name", "#{value.operatorId},#{value.operatorName}");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception {
|
void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception {
|
||||||
JdbcDataSource database = new JdbcDataSource();
|
JdbcDataSource database = new JdbcDataSource();
|
||||||
|
|||||||
+9
-2
@@ -133,14 +133,21 @@ class CarrierScheduleExceptionPersistenceTest {
|
|||||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8);
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 真实 Mapper 保存并返回操作用户名,账号更名后重放不改写原署名。 */
|
||||||
@Test
|
@Test
|
||||||
void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() {
|
void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() {
|
||||||
var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
|
var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
|
||||||
"ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles",
|
"ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles",
|
||||||
"synthetic container manual fact");
|
"synthetic container manual fact");
|
||||||
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body);
|
var detail = service.addContainerManualNode(3,10001,"platform_admin","original-body-key",body);
|
||||||
|
assertThat(detail.nodes().stream().filter(node -> "MANUAL".equals(node.providerCode())).toList())
|
||||||
|
.singleElement().satisfies(node -> assertThat(node.operatorName()).isEqualTo("platform_admin"));
|
||||||
|
assertThat(jdbc.queryForObject("SELECT operator_id FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", Long.class))
|
||||||
|
.isEqualTo(10001L);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT operator_name FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", String.class))
|
||||||
|
.isEqualTo("platform_admin");
|
||||||
var saved = allSnapshot();
|
var saved = allSnapshot();
|
||||||
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body);
|
service.addContainerManualNode(3,10001,"renamed_operator","original-body-key",body);
|
||||||
assertThat(allSnapshot()).isEqualTo(saved);
|
assertThat(allSnapshot()).isEqualTo(saved);
|
||||||
assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key",
|
assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key",
|
||||||
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
|
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
|
||||||
|
|||||||
+19
-8
@@ -183,6 +183,7 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
|
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 出仓节点记录确认人的账号,供两个货柜详情展示。 */
|
||||||
@Test
|
@Test
|
||||||
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
|
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
|
||||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||||
@@ -194,7 +195,7 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||||
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
|
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
|
||||||
|
|
||||||
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM");
|
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "platform_admin");
|
||||||
|
|
||||||
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
|
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
|
||||||
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
|
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
|
||||||
@@ -204,23 +205,25 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
|
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
|
||||||
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
|
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||||
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
|
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
|
||||||
assertThat(result.getCreatedBy()).isZero();
|
assertThat(result.getCreatedBy()).isEqualTo(7L);
|
||||||
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM");
|
assertThat(result.getCreatedByName()).isEqualTo("platform_admin");
|
||||||
assertThat(result.getUpdatedBy()).isZero();
|
assertThat(result.getUpdatedBy()).isEqualTo(7L);
|
||||||
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM");
|
assertThat(result.getUpdatedByName()).isEqualTo("platform_admin");
|
||||||
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
|
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
|
||||||
verify(nodeOutbox).publish(eq(result), argThat(fact ->
|
verify(nodeOutbox).publish(eq(result), argThat(fact ->
|
||||||
"FIRSTLEG".equals(fact.getProviderCode())
|
"FIRSTLEG".equals(fact.getProviderCode())
|
||||||
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
|
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
|
||||||
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
|
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
|
||||||
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
|
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
|
||||||
|
&& Long.valueOf(7L).equals(fact.getOperatorId())
|
||||||
|
&& "platform_admin".equals(fact.getOperatorName())
|
||||||
&& fact.getActualAt() != null), eq("ZCSU7881970"));
|
&& fact.getActualAt() != null), eq("ZCSU7881970"));
|
||||||
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
|
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
|
||||||
"AUTO_CREATE".equals(history.getAction())
|
"AUTO_CREATE".equals(history.getAction())
|
||||||
&& history.getFromVersion() == null
|
&& history.getFromVersion() == null
|
||||||
&& Integer.valueOf(0).equals(history.getToVersion())
|
&& Integer.valueOf(0).equals(history.getToVersion())
|
||||||
&& Long.valueOf(0L).equals(history.getOperatorId())
|
&& Long.valueOf(7L).equals(history.getOperatorId())
|
||||||
&& "SYSTEM".equals(history.getOperatorName())
|
&& "platform_admin".equals(history.getOperatorName())
|
||||||
&& "国内确认出仓自动创建".equals(history.getReason())));
|
&& "国内确认出仓自动创建".equals(history.getReason())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +363,7 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自动采集推进原节点,但不冒用平台操作账号。 */
|
||||||
@Test
|
@Test
|
||||||
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
|
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
|
||||||
CarrierScheduleQueryEntity task = task(0, 0);
|
CarrierScheduleQueryEntity task = task(0, 0);
|
||||||
@@ -377,6 +381,8 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
assertThat(result.query().query().status()).isEqualTo("READY");
|
assertThat(result.query().query().status()).isEqualTo("READY");
|
||||||
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
|
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
|
||||||
assertThat(result.nodes()).isEmpty();
|
assertThat(result.nodes()).isEmpty();
|
||||||
|
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
|
||||||
|
fact.getOperatorId() == null && fact.getOperatorName() == null));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -448,6 +454,7 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
|
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 人工实际节点保留操作账号及既有还柜幂等行为。 */
|
||||||
@Test
|
@Test
|
||||||
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
|
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
|
||||||
CarrierScheduleQueryEntity task = task(0, 3);
|
CarrierScheduleQueryEntity task = task(0, 3);
|
||||||
@@ -462,6 +469,8 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
|
|
||||||
assertThat(result.query().query().status()).isEqualTo("STOPPED");
|
assertThat(result.query().query().status()).isEqualTo("STOPPED");
|
||||||
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
|
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
|
||||||
|
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
|
||||||
|
Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
|
||||||
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
|
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +497,7 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
verify(repository).updateState(before, 3, 7L, "admin");
|
verify(repository).updateState(before, 3, 7L, "admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 柜级补录沿用当前账号,并保持已有跟踪任务和事件投递。 */
|
||||||
@Test
|
@Test
|
||||||
void containerManualNodeJoinsAndAdvancesExistingCarrierTask() {
|
void containerManualNodeJoinsAndAdvancesExistingCarrierTask() {
|
||||||
CarrierScheduleQueryEntity task = task(0, 8);
|
CarrierScheduleQueryEntity task = task(0, 8);
|
||||||
@@ -509,7 +519,8 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
"PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup"));
|
"PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup"));
|
||||||
|
|
||||||
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId())
|
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId())
|
||||||
&& fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode())));
|
&& fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode())
|
||||||
|
&& Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
|
||||||
verify(repository).updateState(task, 8, 7L, "admin");
|
verify(repository).updateState(task, 8, 7L, "admin");
|
||||||
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
|
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
|
||||||
"MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId())
|
"MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId())
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
|
||||||
|
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
|
||||||
|
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
|
||||||
|
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
|
||||||
|
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
|
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
|
||||||
|
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
|
||||||
|
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
|
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
|
||||||
|
|
||||||
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有业务按无实际拼车货物、外配任务按已实际接入需求池货物分类;车辆归属仍读取装车快照,完整判定唯一见外配业务契约。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
|
`fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 为装车后运营视图;判定与权限分别见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`;OpenAPI 为各自 `fleet-*-business-v1.yaml`,部署/验收只查环境主契约。
|
||||||
|
|
||||||
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
||||||
|
|
||||||
@@ -68,18 +68,19 @@
|
|||||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` |
|
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` |
|
||||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
|
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
|
||||||
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` |
|
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` |
|
||||||
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java` |
|
| 海外业务费用来源、费用明细审核与重试 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`;目录入口 `financefee/controller/PlatformOverseasFeeCatalogController.java`,细则查详细索引同主题 |
|
||||||
|
| 自动仓租、仓租库存与出库资金预校验 | `storagerent/controller/PlatformStorageRentController.java`、`storagerent/controller/PdaStorageRentController.java`、`storagerent/service/preparation/StorageRentPreparationService.java`;查详细索引“自动仓租及出库资金门禁” |
|
||||||
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` |
|
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` |
|
||||||
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
|
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
|
||||||
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
|
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
|
||||||
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
|
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
|
||||||
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 |
|
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 |
|
||||||
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 |
|
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 |
|
||||||
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 |
|
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java`;两级审核、依据失效与零扫码退回规则查当前排车主契约 |
|
||||||
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
|
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
|
||||||
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
|
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
|
||||||
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
| 实物装载、逐段容量与双端整车发车 | `dispatch/service/DispatchLoadingDepartureService.java`、`DispatchLoadingDocumentService.java`;双端使用 `recordVersion`;装车确认、照片齐全后的自动派送、容量与幂等只查当前排车主契约,无公开 `/depart` |
|
||||||
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
|
| 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
|
||||||
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
|
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
|
||||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
|
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
|
||||||
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
|
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
|
||||||
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
|
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
|
||||||
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
|
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
|
||||||
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
|
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。全车正常派送完成自动结案,结案不改客户状态/费用;结案与车/司机释放占用同事务,提货全部到仓、本土卡车完成也直接释放,不需二次确认。自动释放记录运输完成时刻及路线终点,保留车辆主档状态。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
|
||||||
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
|
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
|
||||||
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
|
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
|
||||||
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。
|
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
||||||
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
||||||
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
|
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
|
||||||
- 海外采购按 `procurement-workflow.md` 的七状态流程执行:创建待审核,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。
|
- 海外采购按 `procurement-workflow.md` 的状态流程执行:客户创建资金处理中,异步冻结成功待审核、失败原单重试,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。
|
||||||
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
|
- 派车状态只允许 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`。
|
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
|
||||||
|
|
||||||
@@ -171,3 +171,5 @@
|
|||||||
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
|
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
|
||||||
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
|
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
|
||||||
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
|
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
|
||||||
|
|
||||||
|
- 废弃PDA:无箱唛关联的普通库存先扫本单托盘,再扫描现场箱唛自动累计各明细实际箱数,禁止手填数量绕过扫码。现场箱唛独立保存到 actual_carton_marks_json,不改客户选箱快照;提交校验数量一致、同托及同单跨托盘不重复,失败不落盘、可刷新重扫。客户/平台详情返回 actualCartonMarks、palletNo、实际箱数和原备注。数量申请达到目标后释放未用预留。
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
### 当前维护入口(2026-10-02)
|
### 当前维护入口(2026-10-02)
|
||||||
|
|
||||||
|
散板照片运输(2026-11-02):`dispatch.port.LooseboardTransportPort` 由排车声明、`LocalLooseboardService` 实现,排车只输出已经保存照片的运输进度,原单域校验同客户关联及累计送达箱数;不从排车直接访问散板持久化,不生成库存或PDA流水。该依赖按精确类引用登记,不授权其他跨切片实现引用。
|
||||||
|
|
||||||
现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。
|
现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。
|
||||||
|
|
||||||
- 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。
|
- 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
| 主状态 | 编码 | 操作 |
|
| 主状态 | 编码 | 操作 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 |
|
| 资金处理中 | `FUNDING_PENDING` | 订单已受理,等待Finance冻结回执 |
|
||||||
|
| 资金失败 | `FUNDING_FAILED` | 展示失败原因,可在原订单重试 |
|
||||||
|
| 待审核 | `PENDING_REVIEW` | 冻结成功后,平台审核并确定采购仓 |
|
||||||
| 收货中 | `RECEIVING` | 平台分批登记数量和照片 |
|
| 收货中 | `RECEIVING` | 平台分批登记数量和照片 |
|
||||||
| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 |
|
| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 |
|
||||||
| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 |
|
| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 |
|
||||||
@@ -14,13 +16,13 @@
|
|||||||
|
|
||||||
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
|
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
|
||||||
|
|
||||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。重新上传成功后清除当前驳回原因,本轮其他批次仍可继续换图;`photosNeedUpdate` 只标识仍绑定本轮驳回照片,不作为重新提交门禁。再次提交采购完成成功后清除上一轮驳回原因和照片基线,待客户确认及确认后的页面均不展示旧驳回提示。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||||
|
|
||||||
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
|
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
|
||||||
|
|
||||||
主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。
|
主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。
|
||||||
|
|
||||||
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。每次创建出库批次前,本采购主单必须至少有一项未作废应收费用,且全部未作废应收费用均须有效并审核通过;缺少费用、待审核或驳回时提示并阻止出库,前后端共同校验。应付与已作废费用不能替代应收费用明细;原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
|
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。费用明细缺失或尚未审核时可提示运营补充,但不阻止创建出库批次;出库资格只由采购状态、可用数量、物流跟踪号及操作权限决定。原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
|
||||||
|
|
||||||
客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。
|
客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。
|
||||||
|
|
||||||
@@ -28,13 +30,13 @@
|
|||||||
|
|
||||||
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
|
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
|
||||||
|
|
||||||
- 创建采购单后为待审核,审核后直接收货中。
|
- 客户创建采购单后先资金处理中,冻结成功进入待审核,失败保留原单重试;审核后直接收货中。
|
||||||
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
|
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
|
||||||
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
|
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
|
||||||
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
|
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
|
||||||
- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。
|
- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。
|
||||||
- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。
|
- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。
|
||||||
- 无费用、仅作废费用、仅应付费用或任一应收尚未审核通过时,不能创建出库;全部有效应收审核通过后允许出库。
|
- 无费用、费用尚未审核或驳回时均允许创建出库;页面提示不影响出库操作。
|
||||||
|
|
||||||
自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。
|
自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。
|
||||||
|
|
||||||
@@ -46,3 +48,12 @@
|
|||||||
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
|
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
|
||||||
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
|
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
|
||||||
- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。
|
- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-10-29 客户下单预算与页面调整
|
||||||
|
|
||||||
|
客户采购直接提交创建请求;预算预占、FUNDING_PENDING订单及冻结Outbox在同一事务落库,响应表示订单已受理。Finance冻结成功后进入PENDING_REVIEW,明确失败进入FUNDING_FAILED,展示失败原因并允许以原订单、原金额及币种重试。处理中重复点击不重复冻结,旧版本回执不能推进新指令。免费样品零金额不发资金指令并直接进入待审核。旧预算预校验接口仅兼容保留,客户端不再等待冻结后建单。未绑定预占15分钟后释放,已绑定订单终态释放,失败定时重试;迟到回执不得复活取消订单。预算预占独立于人工费用冻结/实扣,不计入已扣款汇总。
|
||||||
|
|
||||||
|
本土卡车采用相同的先受理后异步冻结流程,平台仅可审核PENDING_REVIEW订单。托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。
|
||||||
|
|
||||||
|
结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。
|
||||||
|
|||||||
+5
@@ -30,6 +30,11 @@ public interface AppointmentWorkDispatchPort {
|
|||||||
/** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */
|
/** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */
|
||||||
void bindItems(long dispatchId,List<PhysicalItem> touchedItems,List<Allocation> allocations);
|
void bindItems(long dispatchId,List<PhysicalItem> touchedItems,List<Allocation> allocations);
|
||||||
void requireDeparture(String dispatchNo);
|
void requireDeparture(String dispatchNo);
|
||||||
|
/** 纯散板照片发运锁定原预约并核对计划或实收来源,不要求仓内拣货。 */
|
||||||
|
void requirePhotoDeparture(String dispatchNo);
|
||||||
|
/** 混排仅指定权威异地取货明细按计划核验,其余明细仍核验实际拣货。 */
|
||||||
|
void requireMixedDeparture(String dispatchNo,java.util.Set<Long> remoteItemIds);
|
||||||
|
String mixedDepartureBlockReason(String dispatchNo,java.util.Set<Long> remoteItemIds);
|
||||||
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
|
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
|
||||||
String departureBlockReason(String dispatchNo);
|
String departureBlockReason(String dispatchNo);
|
||||||
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
|
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
|
||||||
|
|||||||
+15
-2
@@ -39,6 +39,11 @@ public class DispatchDeliveryRepository {
|
|||||||
public void create(OutboundOrder value) { outbounds.insert(value); }
|
public void create(OutboundOrder value) { outbounds.insert(value); }
|
||||||
/** 不可变出库来源明细在同一安排事务保存。 */
|
/** 不可变出库来源明细在同一安排事务保存。 */
|
||||||
public void create(OutboundPalletItem value) { items.insert(value); }
|
public void create(OutboundPalletItem value) { items.insert(value); }
|
||||||
|
/** 实际扫码归属核平后,原计划明细统一替换为实际来源明细。 */
|
||||||
|
public void replaceItems(Long outboundId) {
|
||||||
|
items.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<OutboundPalletItem>()
|
||||||
|
.eq(OutboundPalletItem::getOutboundOrderId,outboundId));
|
||||||
|
}
|
||||||
/** 每客户目的地分别保存派送单。 */
|
/** 每客户目的地分别保存派送单。 */
|
||||||
public void create(DeliveryOrder value) { deliveries.insert(value); }
|
public void create(DeliveryOrder value) { deliveries.insert(value); }
|
||||||
/** 仅更新已锁定并复验的客户出库执行事实。 */
|
/** 仅更新已锁定并复验的客户出库执行事实。 */
|
||||||
@@ -58,14 +63,22 @@ public class DispatchDeliveryRepository {
|
|||||||
/** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */
|
/** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */
|
||||||
public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); }
|
public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); }
|
||||||
/** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */
|
/** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */
|
||||||
public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now) {
|
public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now,boolean outboundCompleted) {
|
||||||
if (sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId())
|
if (sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId())
|
||||||
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
|
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
|
||||||
.set(LastlegOrder::getDeliveryOrderNo,deliveryNo)
|
.set(LastlegOrder::getDeliveryOrderNo,deliveryNo)
|
||||||
.set(LastlegOrder::getSubOrderStatus,"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY")
|
.set(LastlegOrder::getSubOrderStatus,outboundCompleted?"OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY":"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY")
|
||||||
.set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1)
|
.set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1)
|
||||||
.set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化");
|
.set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化");
|
||||||
}
|
}
|
||||||
|
/** PDA拣货核平即完成出库节点,保留尚未生成的派送单为空。 */
|
||||||
|
public void markOutbound(LastlegOrder source,String outboundNo,java.time.LocalDateTime now) {
|
||||||
|
if(sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId())
|
||||||
|
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
|
||||||
|
.set(LastlegOrder::getSubOrderStatus,"OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY")
|
||||||
|
.set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1)
|
||||||
|
.set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("出库来源状态已变化");
|
||||||
|
}
|
||||||
/** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */
|
/** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */
|
||||||
public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); }
|
public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); }
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-9
@@ -26,7 +26,7 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */
|
/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */
|
||||||
@Service
|
@Service
|
||||||
public class DispatchDeliveryService implements DispatchDeliveryPort {
|
public class DispatchDeliveryService implements DispatchDeliveryPort, com.baiyue.lastleg.dispatch.port.DispatchOutboundPort {
|
||||||
private final DispatchExecutionPort execution;
|
private final DispatchExecutionPort execution;
|
||||||
private final DispatchDeliveryRepository repository;
|
private final DispatchDeliveryRepository repository;
|
||||||
private final DeliveryCargoGroups grouping;
|
private final DeliveryCargoGroups grouping;
|
||||||
@@ -118,9 +118,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复");
|
for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复");
|
||||||
if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地");
|
if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地");
|
||||||
// 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。
|
// 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。
|
||||||
var existingOutbounds=repository.outbounds(context.dispatchId(),true);
|
var existingOutbounds=activeOutbounds(context.dispatchId(),true);
|
||||||
var existingDeliveries=repository.deliveries(context.dispatchId(),true);
|
var existingDeliveries=repository.deliveries(context.dispatchId(),true);
|
||||||
if (!existingOutbounds.isEmpty() || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对");
|
if (existingOutbounds.stream().anyMatch(o->!"OUTBOUNDED".equals(o.getStatus())) || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对");
|
||||||
var external=fees.external(context);var coverages=new HashMap<String,DeliveryFeeCoverageModels.Evidence>();
|
var external=fees.external(context);var coverages=new HashMap<String,DeliveryFeeCoverageModels.Evidence>();
|
||||||
for (var group:groups) {
|
for (var group:groups) {
|
||||||
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
|
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
|
||||||
@@ -153,7 +153,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
return fact(saved);
|
return fact(saved);
|
||||||
}
|
}
|
||||||
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
|
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
|
||||||
if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty())
|
if(activeOutbounds(context.dispatchId(),true).stream().anyMatch(o->!"OUTBOUNDED".equals(o.getStatus())) || !repository.deliveries(context.dispatchId(),true).isEmpty())
|
||||||
throw conflict("已存在关联单据但缺少完整安排事实,请核对");
|
throw conflict("已存在关联单据但缺少完整安排事实,请核对");
|
||||||
var groups=grouping.groups(context);var external=fees.external(context);
|
var groups=grouping.groups(context);var external=fees.external(context);
|
||||||
var inputs=new HashMap<String,ArrangeGroup>();
|
var inputs=new HashMap<String,ArrangeGroup>();
|
||||||
@@ -167,13 +167,97 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of());
|
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 理货审核后只创建待出库单,不提前生成派送、费用或真实拣货事实。 */
|
||||||
|
@Override
|
||||||
|
@Transactional(propagation=Propagation.MANDATORY)
|
||||||
|
public void prepare(Context context, Actor actor) {
|
||||||
|
if(context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING || context.pickingReconciled()
|
||||||
|
|| context.cargo().isEmpty() || context.cargo().stream().mapToLong(CargoLine::packages).sum()!=context.counts().packageCount())
|
||||||
|
throw conflict("理货审核货物不完整,不能生成出库单");
|
||||||
|
var existing=repository.outbounds(context.dispatchId(),true);
|
||||||
|
if(existing.stream().anyMatch(o->!"CANCELLED".equals(o.getStatus())))throw conflict("本车已有有效出库单");
|
||||||
|
for(var customer:context.cargo().stream().map(CargoLine::customerId).distinct().sorted().toList()) {
|
||||||
|
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
|
||||||
|
var order=existing.stream().filter(o->customer.equals(o.getCustomerId())).findFirst().orElse(null);
|
||||||
|
boolean created=order==null;
|
||||||
|
if(created) {order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);order.setOutboundNo(numbers.next("OUTBOUND"));
|
||||||
|
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
|
||||||
|
order.setOutboundType("DISPATCH_DELIVERY");order.setLxCodes("");order.setCreatedBy(actor.operatorId());order.setCreatedAt(local(Instant.now()));
|
||||||
|
order.setIdempotencyKey(grouping.hash(List.of("REVIEW_OUTBOUND",context.dispatchId(),customer)));}
|
||||||
|
else repository.replaceItems(order.getId());
|
||||||
|
order.setStatus("WAIT_OUTBOUND");order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum()));
|
||||||
|
order.setPalletCount((int)cargo.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count());
|
||||||
|
order.setGrossWeightKg(metric(cargo,CargoLine::grossWeightKg));order.setVolumeCbm(metric(cargo,CargoLine::volumeCbm));
|
||||||
|
order.setRequestHash(context.cargoHash());order.setUpdatedAt(local(Instant.now()));
|
||||||
|
if(created)repository.create(order);else repository.save(order);
|
||||||
|
for(var line:cargo)repository.create(outboundItem(order,line,false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDA 全部来源核平后,把对应客户出库单推进为已出库,发车阶段只推进派送单。 */
|
||||||
|
@Override
|
||||||
|
@Transactional(propagation=Propagation.MANDATORY)
|
||||||
|
public void complete(Context context) {
|
||||||
|
if(!context.pickingReconciled() || context.cargo().stream().anyMatch(c->c.attributionId()==null))throw conflict("真实拣货来源尚未核平");
|
||||||
|
var outbounds=activeOutbounds(context.dispatchId(),true);
|
||||||
|
if(outbounds.isEmpty())throw conflict("理货审核出库单缺失");
|
||||||
|
for (var outbound:outbounds) {
|
||||||
|
var cargo=context.cargo().stream().filter(c->outbound.getCustomerId().equals(c.customerId())).toList();
|
||||||
|
if(cargo.stream().mapToLong(CargoLine::packages).sum()!=outbound.getPackageCount())throw conflict("出库单与真实客户拣货数量不一致");
|
||||||
|
repository.replaceItems(outbound.getId());
|
||||||
|
for(var line:cargo)repository.create(outboundItem(outbound,line,true));
|
||||||
|
outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(Instant.now()));repository.save(outbound);
|
||||||
|
var now=local(Instant.now());
|
||||||
|
for(var orderId:cargo.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList()) {
|
||||||
|
var source=repository.lockSource(outbound.getCustomerId(),orderId);
|
||||||
|
if(source==null)throw conflict("真实拣货来源订单缺失");
|
||||||
|
repository.markOutbound(source,outbound.getOutboundNo(),now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 零扫码退回时撤销审核阶段预生成的下游单据。 */
|
||||||
|
@Override
|
||||||
|
@Transactional(propagation=Propagation.MANDATORY)
|
||||||
|
public void revoke(long dispatchId) {
|
||||||
|
for (var outbound: repository.outbounds(dispatchId, true)) {
|
||||||
|
if(!Set.of("WAIT_OUTBOUND","CANCELLED").contains(outbound.getStatus()))throw conflict("已出库不能退回重审");
|
||||||
|
outbound.setStatus("CANCELLED");outbound.setUpdatedAt(local(Instant.now()));repository.save(outbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预生成和实拣共用字段映射,计划行永远不携带虚构拣货数量。 */
|
||||||
|
private OutboundPalletItem outboundItem(OutboundOrder order,CargoLine row,boolean picked) {
|
||||||
|
var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(order.getId());
|
||||||
|
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
|
||||||
|
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId());
|
||||||
|
item.setWarehousePalletId(row.warehousePalletId());item.setPalletNo(row.palletNo());item.setInboundCode(row.inboundCode());
|
||||||
|
item.setDestinationWarehouseId(row.destination().warehouseId());item.setDestinationWarehouseCode(row.destination().warehouseCode());item.setDestinationWarehouseName(row.destination().warehouseName());
|
||||||
|
item.setPackageCount(Math.toIntExact(row.packages()));item.setPickedPackageCount(picked?Math.toIntExact(row.packages()):0);item.setStatus(picked?"PICKED":"WAIT_PICK");
|
||||||
|
item.setGrossWeightKg(row.grossWeightKg());item.setVolumeCbm(row.volumeCbm());item.setCartonMarks(row.cartonMark());item.setOrderRefs(row.orderNo());item.setCreatedAt(local(Instant.now()));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已撤销的审核出库保留审计,但不参与本轮有效单据集合校验。 */
|
||||||
|
private List<OutboundOrder> activeOutbounds(long id,boolean lock) {
|
||||||
|
return repository.outbounds(id,lock).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */
|
/** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */
|
||||||
private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups,
|
private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups,
|
||||||
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
|
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
|
||||||
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
|
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
|
||||||
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
|
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
|
||||||
|
var prepared=activeOutbounds(context.dispatchId(),true);
|
||||||
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
|
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
|
||||||
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
|
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
|
||||||
|
var existing=prepared.stream().filter(o->customer.equals(o.getCustomerId())).findFirst().orElse(null);
|
||||||
|
if(existing!=null) {
|
||||||
|
if(!"OUTBOUNDED".equals(existing.getStatus()) || existing.getPackageCount()!=cargo.stream().mapToLong(CargoLine::packages).sum())
|
||||||
|
throw conflict("审核出库单尚未按真实拣货完成");
|
||||||
|
outbounds.put(customer,existing);continue;
|
||||||
|
}
|
||||||
var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
|
var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
|
||||||
order.setOutboundNo(numbers.next("OUTBOUND"));
|
order.setOutboundNo(numbers.next("OUTBOUND"));
|
||||||
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
|
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
|
||||||
@@ -193,6 +277,8 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now);
|
var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now);
|
||||||
repository.create(delivery);deliveries.put(group.key(),delivery);
|
repository.create(delivery);deliveries.put(group.key(),delivery);
|
||||||
for (var row:group.cargo()) {
|
for (var row:group.cargo()) {
|
||||||
|
// 提前创建的出库单已在PDA拣货核平时写入实际归属,发运不能再插一次。
|
||||||
|
if(prepared.stream().anyMatch(o->Objects.equals(o.getId(),outbound.getId())))continue;
|
||||||
var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId());
|
var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId());
|
||||||
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
|
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
|
||||||
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId());
|
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId());
|
||||||
@@ -224,12 +310,12 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
@Transactional(propagation=Propagation.MANDATORY)
|
@Transactional(propagation=Propagation.MANDATORY)
|
||||||
public DepartureFacts departureFacts(Context context,boolean lock) {
|
public DepartureFacts departureFacts(Context context,boolean lock) {
|
||||||
var arrangement=repository.arrangement(context.dispatchId(),lock);
|
var arrangement=repository.arrangement(context.dispatchId(),lock);
|
||||||
var outbounds=repository.outbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock);
|
var outbounds=activeOutbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock);
|
||||||
var groups=context.cargo()==null||context.cargo().isEmpty()?List.<Group>of():grouping.groups(context);
|
var groups=context.cargo()==null||context.cargo().isEmpty()?List.<Group>of():grouping.groups(context);
|
||||||
String reason=arrangement==null?"请先安排本车全部客户派送":
|
String reason=arrangement==null?"请先安排本车全部客户派送":
|
||||||
!Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash())
|
!Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash())
|
||||||
?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排":
|
?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排":
|
||||||
outbounds.stream().anyMatch(o->!"WAIT_OUTBOUND".equals(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus())
|
outbounds.stream().anyMatch(o->!Set.of("WAIT_OUTBOUND","OUTBOUNDED").contains(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus())
|
||||||
|| d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null;
|
|| d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null;
|
||||||
return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(),
|
return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(),
|
||||||
arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),
|
arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),
|
||||||
@@ -242,7 +328,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) {
|
public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) {
|
||||||
if (departedAt==null) throw conflict("缺少实际发车时间");
|
if (departedAt==null) throw conflict("缺少实际发车时间");
|
||||||
var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason());
|
var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason());
|
||||||
var outbounds=repository.outbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false);
|
var outbounds=activeOutbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false);
|
||||||
for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); }
|
for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); }
|
||||||
for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt));
|
for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt));
|
||||||
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); }
|
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); }
|
||||||
@@ -256,7 +342,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
public NormalDeliveryFacts normalDeliveryFacts(Context context) {
|
public NormalDeliveryFacts normalDeliveryFacts(Context context) {
|
||||||
var saved=repository.arrangement(context.dispatchId(),false);
|
var saved=repository.arrangement(context.dispatchId(),false);
|
||||||
var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class);
|
var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class);
|
||||||
var outbounds=repository.outbounds(context.dispatchId(),false);
|
var outbounds=activeOutbounds(context.dispatchId(),false);
|
||||||
var actual=new ArrayList<NormalDeliveryFact>();
|
var actual=new ArrayList<NormalDeliveryFact>();
|
||||||
for (var delivery:repository.deliveries(context.dispatchId(),false)) {
|
for (var delivery:repository.deliveries(context.dispatchId(),false)) {
|
||||||
var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId())
|
var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId())
|
||||||
@@ -311,7 +397,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId());
|
var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId());
|
||||||
var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey())))
|
var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey())))
|
||||||
.map(g->deliveries.get(g.key())).toList();
|
.map(g->deliveries.get(g.key())).toList();
|
||||||
repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now));
|
repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now),"OUTBOUNDED".equals(outbound.getStatus()));
|
||||||
for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId());
|
for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId());
|
||||||
history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo());
|
history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo());
|
||||||
history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);}
|
history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);}
|
||||||
@@ -397,3 +483,4 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
/** 任一客户失败由外层事务回滚全部单据和费用。 */
|
/** 任一客户失败由外层事务回滚全部单据和费用。 */
|
||||||
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); }
|
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -17,7 +17,10 @@ public final class CargoDiscardRequests {
|
|||||||
|
|
||||||
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
|
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
@Size(max = 1000) String remark) {}
|
@Size(max = 1000) String remark, @Positive Integer requestedPackages) {
|
||||||
|
/** 逐箱申请从明细计数,无箱唛申请另传整单目标箱数。 */
|
||||||
|
public Create(List<Item> items, String idempotencyKey, String remark) { this(items, idempotencyKey, remark, null); }
|
||||||
|
}
|
||||||
|
|
||||||
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
|
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
|
||||||
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
|
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
|
||||||
|
|||||||
+6
-1
@@ -42,7 +42,12 @@ public final class CargoDiscardResponses {
|
|||||||
|
|
||||||
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
||||||
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
|
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
|
||||||
String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
|
String quantityUnit, Integer actualDiscardPackages, String pdaStatus,
|
||||||
|
List<String> actualCartonMarks, String palletNo) {
|
||||||
|
/** 保留历史详情调用,未记录的现场箱唛和托盘不补造。 */
|
||||||
|
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks, String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
|
||||||
|
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, quantityUnit, actualDiscardPackages, pdaStatus, List.of(), null);
|
||||||
|
}
|
||||||
/** 无来源事实的调用不猜测数量单位或实际完成数。 */
|
/** 无来源事实的调用不猜测数量单位或实际完成数。 */
|
||||||
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
|
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
|
||||||
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null);
|
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class CargoDiscardItem {
|
|||||||
private Long returnCartonId;
|
private Long returnCartonId;
|
||||||
private Long inventoryPositionId;
|
private Long inventoryPositionId;
|
||||||
private String cartonMarksJson;
|
private String cartonMarksJson;
|
||||||
|
private String actualCartonMarksJson;
|
||||||
private Boolean cartonScanRequired;
|
private Boolean cartonScanRequired;
|
||||||
private String inboundCode;
|
private String inboundCode;
|
||||||
private Long profileId;
|
private Long profileId;
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package com.baiyue.lastleg.discard.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||||
|
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 已确认待发换标箱的废弃归属账本;取消及扣减均保留原换标事实。 */
|
||||||
|
@Mapper
|
||||||
|
public interface CargoDiscardRelabelMapper {
|
||||||
|
/** 与排车使用相同原申请行锁,锁顺序由调用方统一排列。 */
|
||||||
|
@Select("SELECT relabel_no FROM ll_relabel_application WHERE relabel_no=#{no} FOR UPDATE")
|
||||||
|
String lock(@Param("no") String no);
|
||||||
|
String ELIGIBLE = "a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' "
|
||||||
|
+ "AND a.customer_confirmed_at IS NOT NULL AND COALESCE(a.outbound_order_no,'')='' "
|
||||||
|
+ "AND i.return_carton_id IS NULL AND i.notified_package_count=1 "
|
||||||
|
+ "AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id "
|
||||||
|
+ "AND dc.carton_id=i.id AND dc.source_type IN ('RELABEL','RELABEL_DISCARDED')) ";
|
||||||
|
|
||||||
|
/** 已确认箱必须仍在实际冻结仓位;当前轮次绑定由业务端口继续复核。 */
|
||||||
|
@Select("SELECT i.* FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||||
|
+ "WHERE i.customer_id=#{customerId} AND i.source_inventory_position_id=#{positionId} AND " + ELIGIBLE + " ORDER BY i.id")
|
||||||
|
List<RelabelItem> cartons(@Param("customerId") String customerId, @Param("positionId") Long positionId);
|
||||||
|
|
||||||
|
/** 通过原入仓单及客户/系统箱唛定位唯一转运实收箱,废弃扫码始终使用系统箱唛。 */
|
||||||
|
@Select("SELECT DISTINCT c.carton_mark FROM ll_relabel_item i "
|
||||||
|
+ "JOIN ll_warehouse_transfer_order t ON t.customer_id=i.customer_id AND t.inbound_order_id=i.source_inbound_order_id "
|
||||||
|
+ "JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id "
|
||||||
|
+ "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) "
|
||||||
|
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id} ORDER BY c.carton_mark")
|
||||||
|
List<String> systemCartonMarks(@Param("customerId") String customerId, @Param("id") Long id);
|
||||||
|
|
||||||
|
/** 箱级交接使用锁定读复核最新归属,不能复用提交前的一致性读快照。 */
|
||||||
|
@Select("SELECT i.id FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||||
|
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id} AND " + ELIGIBLE + " FOR UPDATE")
|
||||||
|
Long lockEligible(@Param("customerId") String customerId, @Param("id") Long id);
|
||||||
|
|
||||||
|
/** 增补仍有已确认换标冻结实箱的候选仓位,不开放其他业务冻结库存。 */
|
||||||
|
@Select("<script>SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "
|
||||||
|
+ "WHERE p.customer_id=#{customerId} AND p.hold_packages>0 AND b.hold_packages>0 "
|
||||||
|
+ "AND EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||||
|
+ "WHERE i.customer_id=p.customer_id AND i.source_inventory_position_id=p.id AND a.overseas_warehouse_id=p.overseas_warehouse_id AND " + ELIGIBLE + ") "
|
||||||
|
+ "AND NOT EXISTS(SELECT 1 FROM ll_warehouse_transfer_inventory_allocation t WHERE t.customer_id=p.customer_id AND t.inventory_position_id=p.id AND t.status='RESERVED') "
|
||||||
|
+ "<if test='keyword != null and keyword != ""'>AND b.inbound_code LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY p.id</script>")
|
||||||
|
List<InventoryPosition> positions(@Param("customerId") String customerId, @Param("keyword") String keyword);
|
||||||
|
|
||||||
|
/** 排车与废弃统一先锁原换标申请,再锁库存仓位。 */
|
||||||
|
@Select("<script>SELECT DISTINCT a.relabel_no FROM ll_relabel_application a JOIN ll_relabel_item i ON i.application_id=a.id AND i.customer_id=a.customer_id "
|
||||||
|
+ "WHERE i.customer_id=#{customerId} AND i.source_inventory_position_id IN "
|
||||||
|
+ "<foreach collection='positions' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY a.relabel_no</script>")
|
||||||
|
List<String> applications(@Param("customerId") String customerId, @Param("positions") List<Long> positions);
|
||||||
|
|
||||||
|
/** 原申请业务号用于交接及取消恢复冻结。 */
|
||||||
|
@Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||||
|
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id}")
|
||||||
|
String relabelNo(@Param("customerId") String customerId, @Param("id") Long id);
|
||||||
|
|
||||||
|
/** 识别已转交换标的旧转运箱,避免同一箱在原托盘再次出现。 */
|
||||||
|
@Select("SELECT DISTINCT c.id FROM ll_warehouse_transfer_carton c JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id "
|
||||||
|
+ "JOIN ll_relabel_item i ON i.customer_id=c.customer_id AND i.source_inbound_order_id=t.inbound_order_id "
|
||||||
|
+ "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) "
|
||||||
|
+ "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
|
||||||
|
+ "WHERE c.customer_id=#{customerId} AND a.status NOT IN ('CANCELLED','REJECTED')")
|
||||||
|
List<Long> transferredCartons(@Param("customerId") String customerId);
|
||||||
|
|
||||||
|
/** 使用原状态条件将本次箱级占用改为取消或已实际废弃。 */
|
||||||
|
@Update("UPDATE ll_cargo_discard_carton SET source_type=#{target} WHERE customer_id=#{customerId} AND carton_id=#{id} AND source_type='RELABEL'")
|
||||||
|
int finish(@Param("customerId") String customerId, @Param("id") Long id, @Param("target") String target);
|
||||||
|
|
||||||
|
/** 有待废弃箱时暂停排车,结算后剩余箱恢复;全部实际废弃后终止履约。 */
|
||||||
|
@Update("UPDATE ll_relabel_application SET fulfillment_status=CASE "
|
||||||
|
+ "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.carton_id=i.id AND dc.customer_id=i.customer_id "
|
||||||
|
+ "AND dc.source_type='RELABEL' WHERE i.application_id=ll_relabel_application.id) THEN 'DISCARD_PENDING' "
|
||||||
|
+ "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i WHERE i.application_id=ll_relabel_application.id AND NOT EXISTS "
|
||||||
|
+ "(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED')) "
|
||||||
|
+ "THEN 'WAIT_PRE_DISPATCH' ELSE 'DISCARDED' END,updated_at=NOW(3) "
|
||||||
|
+ "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND status='READY_FOR_OUTBOUND' "
|
||||||
|
+ "AND fulfillment_status IN ('WAIT_PRE_DISPATCH','DISCARD_PENDING','DISCARDED') AND COALESCE(outbound_order_no,'')=''")
|
||||||
|
int refresh(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
|
||||||
|
}
|
||||||
+11
@@ -6,6 +6,17 @@ import java.util.List;
|
|||||||
|
|
||||||
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
|
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
|
||||||
public final class CargoDiscardQuantities {
|
public final class CargoDiscardQuantities {
|
||||||
|
/** 明细保留候选托盘预留容量,整单总箱数小于容量时按客户目标跨托盘分配。 */
|
||||||
|
public static boolean quantityPool(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
|
||||||
|
return application.getTotalPackages() != null && !lines.isEmpty()
|
||||||
|
&& lines.stream().allMatch(i -> "INVENTORY".equals(i.getSourceType()) && i.getInventoryPositionId() != null
|
||||||
|
&& !Boolean.TRUE.equals(i.getCartonScanRequired()) && (i.getCartonMarksJson() == null || "[]".equals(i.getCartonMarksJson())))
|
||||||
|
&& application.getTotalPackages() < boxes(lines, false);
|
||||||
|
}
|
||||||
|
/** 客户申请箱数与候选库存容量分别统计。 */
|
||||||
|
public static int requested(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
|
||||||
|
return lines.isEmpty() || quantityPool(application, lines) ? application.getTotalPackages() : boxes(lines, false);
|
||||||
|
}
|
||||||
/** 数量规则不需要实例化。 */
|
/** 数量规则不需要实例化。 */
|
||||||
private CargoDiscardQuantities() { }
|
private CargoDiscardQuantities() { }
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -13,7 +13,11 @@ public final class PdaCargoDiscardDtos {
|
|||||||
public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
|
public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
|
||||||
public Scan(String palletNo) { this(palletNo, null); }
|
public Scan(String palletNo) { this(palletNo, null); }
|
||||||
}
|
}
|
||||||
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
|
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages,
|
||||||
|
List<@NotBlank @Size(max = 128) String> scannedCartonMarks) {
|
||||||
|
/** 已关联箱唛的旧调用继续使用整托扫码清单。 */
|
||||||
|
public ActualLine(Long itemId, int actualPackages) { this(itemId, actualPackages, null); }
|
||||||
|
}
|
||||||
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
|
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
|
||||||
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
|
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
|
||||||
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
|
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
|
||||||
@@ -29,5 +33,6 @@ public final class PdaCargoDiscardDtos {
|
|||||||
boolean actualQuantityEditable) { }
|
boolean actualQuantityEditable) { }
|
||||||
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
|
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
|
||||||
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
|
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
|
||||||
String scanMode, List<String> cartonMarks) { }
|
String scanMode, List<String> cartonMarks, String remark,
|
||||||
|
Integer requestedPackages, Integer remainingPackages) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-9
@@ -69,18 +69,34 @@ public class PdaCargoDiscardService {
|
|||||||
if (!line.actualQuantityEditable() && actual != line.plannedPackages())
|
if (!line.actualQuantityEditable() && actual != line.plannedPackages())
|
||||||
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
|
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
|
||||||
}
|
}
|
||||||
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) {
|
// 有真实箱号必须逐箱核对,兼容字段不能绕过扫码校验。
|
||||||
|
if ("CARTON".equals(task.scanMode())) {
|
||||||
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
|
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
|
||||||
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
|
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
|
||||||
throw conflict("必须扫描全部箱唛,或选择一键全部废弃");
|
throw conflict("必须扫描本次废弃的全部箱号");
|
||||||
}
|
}
|
||||||
List<CargoDiscardItem> palletItems = new ArrayList<>();
|
List<CargoDiscardItem> palletItems = new ArrayList<>();
|
||||||
|
// 无箱唛关联的库存按现场逐箱扫码计数,箱唛仅记录实弃事实,不冒充原箱级关联。
|
||||||
|
var observed = new HashSet<String>();
|
||||||
|
var otherMarks = repository.items(application.getCustomerId(), application.getId()).stream()
|
||||||
|
.filter(i -> "INVENTORY".equals(i.getSourceType()))
|
||||||
|
.filter(i -> !physicalCode(application, i).equals(task.palletNo()))
|
||||||
|
.flatMap(i -> stockService.actualCartonMarks(i).stream()).collect(Collectors.toSet());
|
||||||
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
|
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
|
||||||
CargoDiscardItem item = repository.item(line.itemId());
|
CargoDiscardItem item = repository.item(line.itemId());
|
||||||
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|
||||||
|| !Objects.equals(application.getId(), item.getApplicationId())
|
|| !Objects.equals(application.getId(), item.getApplicationId())
|
||||||
|| !physicalCode(application, item).equals(task.palletNo()))
|
|| !physicalCode(application, item).equals(task.palletNo()))
|
||||||
throw conflict("废弃明细不属于当前申请或托盘");
|
throw conflict("废弃明细不属于当前申请或托盘");
|
||||||
|
if (line.actualQuantityEditable()) {
|
||||||
|
var input = request.lines().stream().filter(i -> i.itemId().equals(line.itemId())).findFirst().orElseThrow();
|
||||||
|
var scanned = input.scannedCartonMarks() == null ? List.<String>of() : input.scannedCartonMarks();
|
||||||
|
if (scanned.size() != input.actualPackages() || scanned.stream().anyMatch(mark -> mark == null || mark.isBlank()
|
||||||
|
|| mark.length() > 128 || !mark.equals(mark.trim()) || !observed.add(mark) || otherMarks.contains(mark)))
|
||||||
|
throw conflict("实际废弃箱数必须与逐箱扫码一致,箱唛不能重复或跨托盘重复废弃");
|
||||||
|
if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned)))
|
||||||
|
throw conflict("已完成托盘的实际废弃箱唛不能修改");
|
||||||
|
}
|
||||||
if ("COMPLETED".equals(item.getPdaStatus())
|
if ("COMPLETED".equals(item.getPdaStatus())
|
||||||
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
|
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
|
||||||
throw conflict("已完成托盘的实际数量不能修改");
|
throw conflict("已完成托盘的实际数量不能修改");
|
||||||
@@ -91,10 +107,23 @@ public class PdaCargoDiscardService {
|
|||||||
|| !application.getCustomerId().equals(item.getCustomerId())
|
|| !application.getCustomerId().equals(item.getCustomerId())
|
||||||
|| !Objects.equals(application.getId(), item.getApplicationId())))
|
|| !Objects.equals(application.getId(), item.getApplicationId())))
|
||||||
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
|
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
|
||||||
photoService.confirm(application, task.palletNo(), request.photoFileIds(),
|
// 已完成托盘在整单未结束时也只核对原结果,不能再次计入数量池或扣减库存。
|
||||||
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus())));
|
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) {
|
||||||
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
|
if (!terminal) photoService.confirm(application, task.palletNo(), request.photoFileIds(), true);
|
||||||
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
|
return task;
|
||||||
|
}
|
||||||
|
var allItems = repository.items(application.getCustomerId(), application.getId());
|
||||||
|
boolean quantityPool = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.quantityPool(application, allItems);
|
||||||
|
if (quantityPool) {
|
||||||
|
int done = allItems.stream().filter(i -> "COMPLETED".equals(i.getPdaStatus()))
|
||||||
|
.mapToInt(i -> Objects.requireNonNullElse(i.getActualDiscardPackages(), 0)).sum();
|
||||||
|
int current = actuals.values().stream().mapToInt(Integer::intValue).sum();
|
||||||
|
int availableLater = allItems.stream().filter(i -> !"COMPLETED".equals(i.getPdaStatus()) && !actuals.containsKey(i.getId()))
|
||||||
|
.mapToInt(CargoDiscardItem::getDiscardPackages).sum();
|
||||||
|
if ((long)done + current > application.getTotalPackages()) throw conflict("累计废弃箱数不能超过客户申请箱数,请刷新核对剩余数量");
|
||||||
|
if ((long)done + current + availableLater < application.getTotalPackages()) throw conflict("剩余托盘数量不足以完成申请,请核对本托盘实际废弃箱数");
|
||||||
|
}
|
||||||
|
photoService.confirm(application, task.palletNo(), request.photoFileIds(), false);
|
||||||
if (terminal) throw conflict("废弃终态不能执行未完成托盘");
|
if (terminal) throw conflict("废弃终态不能执行未完成托盘");
|
||||||
storageRent.consume(String.valueOf(identity.overseasWarehouseId()),new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
|
storageRent.consume(String.valueOf(identity.overseasWarehouseId()),new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
|
||||||
"DISCARD",discardNo,null,null,null,null,palletNo,request.idempotencyKey(),null,null,null,
|
"DISCARD",discardNo,null,null,null,null,palletNo,request.idempotencyKey(),null,null,null,
|
||||||
@@ -113,12 +142,25 @@ public class PdaCargoDiscardService {
|
|||||||
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
|
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
|
||||||
actuals.get(item.getId()), identity.operatorKey(), now);
|
actuals.get(item.getId()), identity.operatorKey(), now);
|
||||||
}
|
}
|
||||||
|
var actualMarks = task.skus().stream().filter(s -> s.itemId().equals(item.getId())).findFirst().orElseThrow().actualQuantityEditable()
|
||||||
|
? request.lines().stream().filter(i -> i.itemId().equals(item.getId())).findFirst().orElseThrow().scannedCartonMarks()
|
||||||
|
: cartonMarks(application, item);
|
||||||
|
if ("INVENTORY".equals(item.getSourceType())) stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
|
||||||
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
|
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
|
||||||
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
|
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
|
||||||
}
|
}
|
||||||
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
|
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
|
||||||
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
|
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
|
||||||
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
|
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
|
||||||
|
// 达到客户申请总数后无需逐个空扫其余托盘,仅释放未使用预留,不扣库存、不伪造照片。
|
||||||
|
allItems = repository.items(application.getCustomerId(), application.getId());
|
||||||
|
if (quantityPool && com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true) == application.getTotalPackages()) {
|
||||||
|
for (var unused : allItems) if (!"COMPLETED".equals(unused.getPdaStatus())) {
|
||||||
|
stockService.discard(application.getCustomerId(), identity.operatorKey(), application, unused, 0);
|
||||||
|
unused.setActualDiscardPackages(0); unused.setPdaStatus("COMPLETED");
|
||||||
|
unused.setPdaOperatorId(identity.operatorKey()); unused.setPdaCompletedAt(now); repository.saveItem(unused);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (repository.items(application.getCustomerId(), application.getId()).stream()
|
if (repository.items(application.getCustomerId(), application.getId()).stream()
|
||||||
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
|
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
|
||||||
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
|
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
|
||||||
@@ -179,13 +221,16 @@ public class PdaCargoDiscardService {
|
|||||||
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
|
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
|
||||||
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
|
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
|
||||||
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
|
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
|
||||||
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
|
// 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
|
||||||
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
|
boolean cartonScan = !marks.isEmpty() || entry.getValue().stream()
|
||||||
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
|
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
|
||||||
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
|
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
|
||||||
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
|
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
|
||||||
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
|
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
|
||||||
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks);
|
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks,
|
||||||
|
application.getRemark(), com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, allItems),
|
||||||
|
application.getTotalPackages() == null ? null : Math.max(0, application.getTotalPackages()
|
||||||
|
- com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true)));
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
/** 相关托盘只从客户库存事实读取,不从请求推断。 */
|
/** 相关托盘只从客户库存事实读取,不从请求推断。 */
|
||||||
|
|||||||
+1
-1
@@ -15,6 +15,6 @@ public class CargoDiscardQuantityRepository {
|
|||||||
public int boxes(CargoDiscardApplication application) {
|
public int boxes(CargoDiscardApplication application) {
|
||||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||||
boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus());
|
boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus());
|
||||||
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed);
|
return lines.isEmpty() ? application.getTotalPackages() : completed ? CargoDiscardQuantities.boxes(lines, true) : CargoDiscardQuantities.requested(application, lines);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.lastleg.discard.repository;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper;
|
||||||
|
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||||
|
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 换标废弃事实仅经本库 Mapper 读取和原状态条件更新。 */
|
||||||
|
@Repository
|
||||||
|
public class CargoDiscardRelabelRepository {
|
||||||
|
private final CargoDiscardRelabelMapper mapper;
|
||||||
|
public CargoDiscardRelabelRepository(CargoDiscardRelabelMapper mapper) { this.mapper = mapper; }
|
||||||
|
/** 按原申请业务号取得正式行锁。 */
|
||||||
|
public String lock(String no) { return mapper.lock(no); }
|
||||||
|
/** 复验最新箱级状态,拒绝并发废弃或已经排车的旧页面。 */
|
||||||
|
public boolean lockEligible(String customer, Long id) { return mapper.lockEligible(customer, id) != null; }
|
||||||
|
/** 已确认待发实箱。 */
|
||||||
|
public List<RelabelItem> cartons(String customer, Long position) { return mapper.cartons(customer, position); }
|
||||||
|
/** 读取换标箱对应的原系统箱唛,避免以 FBA 箱唛替代仓内扫码身份。 */
|
||||||
|
public List<String> systemCartonMarks(String customer, Long item) { return mapper.systemCartonMarks(customer, item); }
|
||||||
|
/** 已确认冻结仓位。 */
|
||||||
|
public List<InventoryPosition> positions(String customer, String keyword) { return mapper.positions(customer, keyword); }
|
||||||
|
/** 原申请锁集合。 */
|
||||||
|
public List<String> applications(String customer, List<Long> positions) { return positions.isEmpty() ? List.of() : mapper.applications(customer, positions); }
|
||||||
|
/** 换标业务归属。 */
|
||||||
|
public String relabelNo(String customer, Long item) { return mapper.relabelNo(customer, item); }
|
||||||
|
/** 原托盘旧箱记录。 */
|
||||||
|
public List<Long> transferredCartons(String customer) { return mapper.transferredCartons(customer); }
|
||||||
|
/** 结束箱级独占。 */
|
||||||
|
public int finish(String customer, Long item, String target) { return mapper.finish(customer, item, target); }
|
||||||
|
/** 刷新整单剩余履约资格。 */
|
||||||
|
public int refresh(String customer, String no) { return mapper.refresh(customer, no); }
|
||||||
|
}
|
||||||
+5
-3
@@ -51,7 +51,7 @@ public class CargoDiscardOutboundService {
|
|||||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
|
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
|
||||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
||||||
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
|
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
|
||||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
|
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
|
||||||
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
|
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
|
||||||
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
|
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
|
||||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
|
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
|
||||||
@@ -67,7 +67,8 @@ public class CargoDiscardOutboundService {
|
|||||||
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
|
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
|
||||||
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
|
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
|
||||||
palletNo = position.getPalletNo();
|
palletNo = position.getPalletNo();
|
||||||
cartonMarks = String.join(",", stockService.cartonMarks(line));
|
cartonMarks = String.join(",", line.getActualCartonMarksJson() == null
|
||||||
|
? stockService.cartonMarks(line) : stockService.actualCartonMarks(line));
|
||||||
} else if (returned) {
|
} else if (returned) {
|
||||||
var carton = returnCartons.selectById(line.getReturnCartonId());
|
var carton = returnCartons.selectById(line.getReturnCartonId());
|
||||||
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
|
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
|
||||||
@@ -134,7 +135,8 @@ public class CargoDiscardOutboundService {
|
|||||||
};
|
};
|
||||||
outbound.setStatus(status);
|
outbound.setStatus(status);
|
||||||
outbound.setLxCodes(null);
|
outbound.setLxCodes(null);
|
||||||
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
|
outbound.setPackageCount("OUTBOUNDED".equals(status) ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true)
|
||||||
|
: com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
|
||||||
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
|
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
|
||||||
}
|
}
|
||||||
/** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */
|
/** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package com.baiyue.lastleg.discard.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.discard.entity.*;
|
||||||
|
import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow;
|
||||||
|
import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository;
|
||||||
|
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||||
|
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||||
|
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||||
|
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** 已换标实箱在原换标冻结与废弃冻结之间原子交接,保留原箱历史。 */
|
||||||
|
@Service
|
||||||
|
public class CargoDiscardRelabelStockService {
|
||||||
|
@Resource private CargoDiscardRelabelRepository repository;
|
||||||
|
@Resource private RelabelDispatchSourcePort sources;
|
||||||
|
@Resource private InventoryCommandService commands;
|
||||||
|
@Resource private ObjectMapper json;
|
||||||
|
|
||||||
|
/** 仅增补有待发换标库存的位置。 */
|
||||||
|
public List<InventoryPosition> positions(String customer, String keyword) { return repository.positions(customer, keyword); }
|
||||||
|
|
||||||
|
/** 从当前轮次实托绑定核对箱级贡献,换标完成但未绑定的箱不能猜测位置。 */
|
||||||
|
public List<CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
|
||||||
|
var items = repository.cartons(customer, position.getId());
|
||||||
|
if (items.isEmpty() || position.getPalletId() == null || position.getPalletId() <= 0) return List.of();
|
||||||
|
var snapshot = sources.snapshot(position.getOverseasWarehouseId(), List.of(position.getPalletId()));
|
||||||
|
if (!snapshot.complete()) return List.of();
|
||||||
|
var bound = snapshot.sources().stream().filter(s -> customer.equals(s.customerId())
|
||||||
|
&& Objects.equals(position.getId(), s.inventoryPositionId()) && s.boundPackageCount() == 1)
|
||||||
|
.map(RelabelDispatchSourcePort.BoundSource::itemId).collect(java.util.stream.Collectors.toSet());
|
||||||
|
var result = new ArrayList<CargoDiscardStockCartonRow>();
|
||||||
|
for (var item : items) {
|
||||||
|
if (!bound.contains(item.getId())) continue;
|
||||||
|
var box = new CargoDiscardStockCartonRow(); box.setId(item.getId()); box.setSourceType("RELABEL");
|
||||||
|
// 系统箱唛是仓内实箱身份;换标仅更新 FBA 箱唛和商品标签,不替换废弃扫码身份。
|
||||||
|
var systemMarks = repository.systemCartonMarks(customer, item.getId());
|
||||||
|
if (systemMarks.size() > 1) throw conflict("换标箱对应多个系统箱唛,请先核对原转运箱记录");
|
||||||
|
box.setCartonMark(systemMarks.isEmpty() ? item.getCartonMark() : systemMarks.get(0));
|
||||||
|
box.setSkuCode(item.getNewSkuCode() == null || item.getNewSkuCode().isBlank() ? item.getSkuCode() : item.getNewSkuCode());
|
||||||
|
try {
|
||||||
|
var values = json.readTree(Objects.toString(item.getSkuRelabelItemsJson(), "[]"));
|
||||||
|
if (values.isArray() && !values.isEmpty()) {
|
||||||
|
var contents = json.createArrayNode();
|
||||||
|
for (var value : values) {
|
||||||
|
var content = contents.addObject();
|
||||||
|
String code = value.path("newSkuCode").asText();
|
||||||
|
content.put("skuCode", code.isBlank() ? value.path("skuCode").asText() : code);
|
||||||
|
content.put("quantity", value.path("quantity").asLong());
|
||||||
|
}
|
||||||
|
box.setSkuItemsJson(json.writeValueAsString(contents));
|
||||||
|
}
|
||||||
|
} catch (Exception invalid) { throw conflict("换标箱内 SKU 事实无法读取"); }
|
||||||
|
result.add(box);
|
||||||
|
}
|
||||||
|
return result.size() <= number(position.getHoldPackages()) ? result : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排除已转交换标处理的旧转运箱,不能同时宣称原托盘和新托盘都存有该箱。 */
|
||||||
|
public Set<Long> transferred(String customer) { return new HashSet<>(repository.transferredCartons(customer)); }
|
||||||
|
|
||||||
|
/** 保存废弃选箱账本前以最新锁定读认领实箱。 */
|
||||||
|
public int claim(String customer, Long item) { return repository.lockEligible(customer, item) ? 1 : 0; }
|
||||||
|
|
||||||
|
/** 与排车共用按业务号排序的原申请锁,之后再取得库存正式锁。 */
|
||||||
|
public void lock(String customer, List<Long> positions) {
|
||||||
|
var applications = repository.applications(customer, positions);
|
||||||
|
applications.stream().distinct().sorted().forEach(no -> {
|
||||||
|
if (repository.lock(no) == null) throw conflict("换标原申请不存在");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 先归还原换标冻结,紧接着由调用方以废弃周期重新冻结,同事务不暴露可用窗口。 */
|
||||||
|
public void handoff(String customer, String operator, CargoDiscardApplication app, CargoDiscardItem line,
|
||||||
|
List<CargoDiscardStockCartonRow> selected) {
|
||||||
|
for (var box : selected) if ("RELABEL".equals(box.getSourceType())) {
|
||||||
|
String no = requireNo(customer, box.getId());
|
||||||
|
commands.release(customer, operator, move(line, app, box.getId(), no, "HANDOFF"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 箱级占用已持久化后暂停原换标出库;同一混标箱多SKU行只需刷新一次。 */
|
||||||
|
public void synchronize(List<CargoDiscardCarton> links, String customer) {
|
||||||
|
var ids = links.stream().filter(link -> "RELABEL".equals(link.getSourceType()))
|
||||||
|
.map(CargoDiscardCarton::getCartonId).distinct().toList();
|
||||||
|
var no = new TreeSet<String>(); ids.forEach(id -> no.add(requireNo(customer, id)));
|
||||||
|
for (String relabelNo : no) if (repository.refresh(customer, relabelNo) != 1)
|
||||||
|
throw conflict("换标单已进入其他出库流程");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实际废弃或撤销记录保留箱级结果;整单仅依据剩余箱恢复资格。 */
|
||||||
|
public int finish(String customer, Long item, boolean discarded) {
|
||||||
|
String no = requireNo(customer, item);
|
||||||
|
int changed = repository.finish(customer, item, discarded ? "RELABEL_DISCARDED" : "RELABEL_RELEASED");
|
||||||
|
if (changed == 1 && repository.refresh(customer, no) != 1) throw conflict("换标废弃归属发生变化");
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销后恢复原换标 HOLD,不能把已换标待发箱释放为普通可用箱。 */
|
||||||
|
public void restore(String customer, CargoDiscardApplication app, CargoDiscardItem line, List<CargoDiscardCarton> links) {
|
||||||
|
for (var link : links) if (Objects.equals(line.getId(), link.getItemId()) && "RELABEL".equals(link.getSourceType()))
|
||||||
|
commands.hold(customer, app.getCreatedBy(), move(line, app, link.getCartonId(), requireNo(customer, link.getCartonId()), "RESTORE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每次废弃申请独立幂等,允许取消后重新申请而不复用旧释放结果。 */
|
||||||
|
private InventoryCommandRequests.Move move(CargoDiscardItem line, CargoDiscardApplication app, Long item, String no, String action) {
|
||||||
|
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), 1, no, null, "RELABEL:" + no,
|
||||||
|
"DISCARD_RELABEL:" + app.getId() + ":" + item + ":" + action, "换标库存废弃交接 " + action);
|
||||||
|
}
|
||||||
|
/** 原申请必须可追溯。 */
|
||||||
|
private String requireNo(String customer, Long item) {
|
||||||
|
String no = repository.relabelNo(customer, item);
|
||||||
|
if (no == null) throw conflict("换标箱原申请不存在");
|
||||||
|
return no;
|
||||||
|
}
|
||||||
|
private long number(Long value) { return value == null ? 0 : value; }
|
||||||
|
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||||
|
}
|
||||||
+13
-2
@@ -147,6 +147,8 @@ public class CargoDiscardService {
|
|||||||
CargoDiscardRequests.Create request) {
|
CargoDiscardRequests.Create request) {
|
||||||
String key = request.idempotencyKey().trim();
|
String key = request.idempotencyKey().trim();
|
||||||
String requestHash = hash(request);
|
String requestHash = hash(request);
|
||||||
|
if (request.requestedPackages() != null && !StringUtils.hasText(request.remark()))
|
||||||
|
throw badRequest("无箱唛数量废弃必须填写备注");
|
||||||
CargoDiscardApplication replay = applications.findKey(customerId, key);
|
CargoDiscardApplication replay = applications.findKey(customerId, key);
|
||||||
if (replay != null) {
|
if (replay != null) {
|
||||||
if (!requestHash.equals(replay.getRequestHash())) {
|
if (!requestHash.equals(replay.getRequestHash())) {
|
||||||
@@ -219,6 +221,13 @@ public class CargoDiscardService {
|
|||||||
}
|
}
|
||||||
total = Math.addExact(total, returnCartonCache.size());
|
total = Math.addExact(total, returnCartonCache.size());
|
||||||
total = Math.addExact(total, requestedBoxes.size());
|
total = Math.addExact(total, requestedBoxes.size());
|
||||||
|
// 客户只指定总箱数,托盘候选预留后由仓库分配实际数量。
|
||||||
|
if (request.requestedPackages() != null) {
|
||||||
|
if (request.requestedPackages() <= 0 || request.requestedPackages() > total
|
||||||
|
|| request.items().stream().anyMatch(i -> !stockService.quantitySelectionAllowed(customerId, i)))
|
||||||
|
throw badRequest("按数量废弃仅支持有托盘且无箱唛关联的库存,申请箱数不能超过可用库存");
|
||||||
|
total = request.requestedPackages();
|
||||||
|
}
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
||||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||||
@@ -289,7 +298,9 @@ public class CargoDiscardService {
|
|||||||
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
|
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
|
||||||
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
|
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
|
||||||
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
|
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
|
||||||
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus()))
|
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus(),
|
||||||
|
stockService.actualCartonMarks(value), value.getInventoryPositionId() == null ? null
|
||||||
|
: stockService.position(customerId, value.getInventoryPositionId()).getPalletNo()))
|
||||||
.toList();
|
.toList();
|
||||||
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
|
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
|
||||||
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
|
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
|
||||||
@@ -522,7 +533,7 @@ public class CargoDiscardService {
|
|||||||
int boxes = quantities.boxes(value);
|
int boxes = quantities.boxes(value);
|
||||||
var lines = items.listApplication(value.getCustomerId(), value.getId());
|
var lines = items.listApplication(value.getCustomerId(), value.getId());
|
||||||
var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList();
|
var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList();
|
||||||
int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false);
|
int requested = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(value, lines);
|
||||||
Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null)
|
Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null)
|
||||||
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
|
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
|
||||||
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
|
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ public class CargoDiscardStatisticsService {
|
|||||||
boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true;
|
boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true;
|
||||||
for (var application : applications) {
|
for (var application : applications) {
|
||||||
var lines = dimensions.items(application.getCustomerId(), application.getId());
|
var lines = dimensions.items(application.getCustomerId(), application.getId());
|
||||||
boxes += lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
|
boxes += CargoDiscardQuantities.requested(application, lines);
|
||||||
if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; }
|
if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; }
|
||||||
var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList();
|
var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList();
|
||||||
if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); }
|
if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); }
|
||||||
|
|||||||
+73
-14
@@ -20,14 +20,18 @@ public class CargoDiscardStockService {
|
|||||||
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
|
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
|
||||||
@Resource private InventoryCommandService commands;
|
@Resource private InventoryCommandService commands;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
@Resource private CargoDiscardRelabelStockService relabelStock;
|
||||||
|
|
||||||
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
|
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
|
||||||
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
|
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
|
||||||
return repository.candidates(customer, keyword).stream().map(position -> {
|
var positions = new LinkedHashMap<Long, InventoryPosition>();
|
||||||
|
repository.candidates(customer, keyword).forEach(p -> positions.put(p.getId(), p));
|
||||||
|
relabelStock.positions(customer, keyword).forEach(p -> positions.put(p.getId(), p));
|
||||||
|
return positions.values().stream().map(position -> {
|
||||||
var balance = repository.balance(position.getBalanceId());
|
var balance = repository.balance(position.getBalanceId());
|
||||||
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
|
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
|
||||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
long available = available(customer, position, balance);
|
||||||
var cartons = repository.cartons(customer, position.getId());
|
var cartons = cartons(customer, position);
|
||||||
boolean complete = completeCartons(cartons, available);
|
boolean complete = completeCartons(cartons, available);
|
||||||
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
|
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
|
||||||
contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
|
contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
|
||||||
@@ -40,6 +44,15 @@ public class CargoDiscardStockService {
|
|||||||
}).filter(c -> c.availablePackages() > 0).toList();
|
}).filter(c -> c.availablePackages() > 0).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按数量选择仅适用于有托盘且没有完整逐箱关联的普通库存。 */
|
||||||
|
public boolean quantitySelectionAllowed(String customer, CargoDiscardRequests.Item request) {
|
||||||
|
if (request.inventoryPositionId() == null || request.cartonIds() != null) return false;
|
||||||
|
var position = position(customer, request.inventoryPositionId());
|
||||||
|
var balance = validate(customer, request);
|
||||||
|
return position.getPalletNo() != null && !position.getPalletNo().isBlank()
|
||||||
|
&& !completeCartons(cartons(customer, position), available(customer, position, balance));
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */
|
/** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */
|
||||||
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
|
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
|
||||||
var position = position(customer, request.inventoryPositionId());
|
var position = position(customer, request.inventoryPositionId());
|
||||||
@@ -47,18 +60,19 @@ public class CargoDiscardStockService {
|
|||||||
if (balance == null || !customer.equals(balance.getCustomerId())
|
if (balance == null || !customer.equals(balance.getCustomerId())
|
||||||
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
|
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
|
||||||
throw conflict("库存不属于当前客户或仓库");
|
throw conflict("库存不属于当前客户或仓库");
|
||||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
long available = available(customer, position, balance);
|
||||||
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
|
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
|
||||||
if (request.cartonIds() != null) {
|
if (request.cartonIds() != null) {
|
||||||
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
|
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
|
||||||
throw conflict("废弃箱子数量与所选箱子不一致或重复");
|
throw conflict("废弃箱子数量与所选箱子不一致或重复");
|
||||||
var cartons = repository.cartons(customer, position.getId());
|
var cartons = cartons(customer, position);
|
||||||
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
|
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
|
||||||
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
|
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
|
||||||
if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
|
if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
|
||||||
} else {
|
} else {
|
||||||
|
if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子");
|
||||||
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
|
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
|
||||||
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
|
if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中");
|
||||||
}
|
}
|
||||||
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
|
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
|
||||||
return balance;
|
return balance;
|
||||||
@@ -66,14 +80,18 @@ public class CargoDiscardStockService {
|
|||||||
|
|
||||||
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
|
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
|
||||||
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
|
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
|
||||||
|
relabelStock.lock(customer, List.of(request.inventoryPositionId()));
|
||||||
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
|
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
|
||||||
var balance = validate(customer, request);
|
var balance = validate(customer, request);
|
||||||
reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>());
|
boolean relabelReserved = reserveValidated(customer, operator, application, request, at, balance,
|
||||||
|
cartons(customer, position(customer, request.inventoryPositionId())), new HashSet<>(), new HashSet<>());
|
||||||
|
if (relabelReserved) relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
|
/** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
|
||||||
public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) {
|
public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) {
|
||||||
var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList();
|
var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList();
|
||||||
|
relabelStock.lock(customer, stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().toList());
|
||||||
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
|
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
|
||||||
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
|
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
|
||||||
});
|
});
|
||||||
@@ -82,7 +100,7 @@ public class CargoDiscardStockService {
|
|||||||
var selectedSkus = new HashMap<String, Set<String>>();
|
var selectedSkus = new HashMap<String, Set<String>>();
|
||||||
for (var request : stocks) {
|
for (var request : stocks) {
|
||||||
balances.put(request.inventoryPositionId(), validate(customer, request));
|
balances.put(request.inventoryPositionId(), validate(customer, request));
|
||||||
var facts = repository.cartons(customer, request.inventoryPositionId());
|
var facts = cartons(customer, position(customer, request.inventoryPositionId()));
|
||||||
cartons.put(request.inventoryPositionId(), facts);
|
cartons.put(request.inventoryPositionId(), facts);
|
||||||
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
|
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
|
||||||
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
|
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
|
||||||
@@ -95,12 +113,17 @@ public class CargoDiscardStockService {
|
|||||||
}
|
}
|
||||||
var lockedBoxes = new HashSet<String>();
|
var lockedBoxes = new HashSet<String>();
|
||||||
var heldBoxes = new HashSet<String>();
|
var heldBoxes = new HashSet<String>();
|
||||||
for (var request : stocks) reserveValidated(customer, operator, application, request, at,
|
boolean relabelReserved = false;
|
||||||
balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes);
|
for (var request : stocks) {
|
||||||
|
if (reserveValidated(customer, operator, application, request, at,
|
||||||
|
balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes)) relabelReserved = true;
|
||||||
|
}
|
||||||
|
// 全部仓位认领和预留完成后统一暂停原换标出库,避免后续行被本事务提前改成不可选状态。
|
||||||
|
if (relabelReserved) relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 使用本事务变更箱状态前的完整事实,为每个 SKU 分别预留并保存相同箱唛快照。 */
|
/** 使用变更箱状态前的完整事实保存各 SKU 快照,返回本行是否新增换标箱预留。 */
|
||||||
private void reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at,
|
private boolean reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at,
|
||||||
InventoryBalance balance, List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> availableCartons, Set<String> lockedBoxes, Set<String> heldBoxes) {
|
InventoryBalance balance, List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> availableCartons, Set<String> lockedBoxes, Set<String> heldBoxes) {
|
||||||
var position = position(customer, request.inventoryPositionId());
|
var position = position(customer, request.inventoryPositionId());
|
||||||
var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId());
|
var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId());
|
||||||
@@ -124,9 +147,14 @@ public class CargoDiscardStockService {
|
|||||||
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
|
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
|
||||||
snapshotMarks(line, marks);
|
snapshotMarks(line, marks);
|
||||||
}
|
}
|
||||||
int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count();
|
var newlyHeld = selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).toList();
|
||||||
if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD"));
|
int physical = selected.isEmpty() ? request.discardPackages() : newlyHeld.size();
|
||||||
|
if (physical > 0) {
|
||||||
|
relabelStock.handoff(customer, operator, application, line, newlyHeld);
|
||||||
|
commands.hold(customer, operator, move(application, line, physical, "HOLD"));
|
||||||
|
}
|
||||||
repository.saveItem(line);
|
repository.saveItem(line);
|
||||||
|
return newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
|
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
|
||||||
@@ -165,14 +193,18 @@ public class CargoDiscardStockService {
|
|||||||
|
|
||||||
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
|
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
|
||||||
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
|
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
|
||||||
|
var links = repository.selectedCartons(customer, application.getId());
|
||||||
|
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
|
||||||
changeSelectedCartons(customer, application, line, "AVAILABLE");
|
changeSelectedCartons(customer, application, line, "AVAILABLE");
|
||||||
int physical = physicalPackages(customer, application, line);
|
int physical = physicalPackages(customer, application, line);
|
||||||
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
|
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
|
||||||
|
relabelStock.restore(customer, application, line, links);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
|
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
|
||||||
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||||
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
|
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
|
||||||
|
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
|
||||||
if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
|
if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
|
||||||
if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
|
if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
|
||||||
throw conflict("必须完成本次申请所选全部箱子,不能漏出");
|
throw conflict("必须完成本次申请所选全部箱子,不能漏出");
|
||||||
@@ -203,6 +235,7 @@ public class CargoDiscardStockService {
|
|||||||
|
|
||||||
/** 按箱来源执行带原状态条件的变更。 */
|
/** 按箱来源执行带原状态条件的变更。 */
|
||||||
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
|
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
|
||||||
|
if ("RELABEL".equals(source)) return "DISCARD_PENDING".equals(to) ? relabelStock.claim(customer, cartonId) : relabelStock.finish(customer, cartonId, "DISCARDED".equals(to));
|
||||||
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
|
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
|
||||||
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
|
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
|
||||||
return repository.changeInboundCarton(customer, cartonId, from, to);
|
return repository.changeInboundCarton(customer, cartonId, from, to);
|
||||||
@@ -237,8 +270,34 @@ public class CargoDiscardStockService {
|
|||||||
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
|
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
|
||||||
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
|
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
|
||||||
}
|
}
|
||||||
|
/** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */
|
||||||
|
public void saveActualCartonMarks(CargoDiscardItem line, List<String> marks) {
|
||||||
|
try { line.setActualCartonMarksJson(objectMapper.writeValueAsString(marks)); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛保存失败", e); }
|
||||||
|
}
|
||||||
|
/** 废弃详情读取真实提交的现场箱唛,历史未记录时返回空。 */
|
||||||
|
public List<String> actualCartonMarks(CargoDiscardItem line) {
|
||||||
|
if (line.getActualCartonMarksJson() == null) return List.of();
|
||||||
|
try { return objectMapper.readValue(line.getActualCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛读取失败", e); }
|
||||||
|
}
|
||||||
/** 无 SKU 来源保留未指定标记,不猜测商品。 */
|
/** 无 SKU 来源保留未指定标记,不猜测商品。 */
|
||||||
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
|
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
|
||||||
|
/** 当前可废弃数量包括已完成并确认的换标实箱,不包含其他业务冻结。 */
|
||||||
|
private long available(String customer, InventoryPosition position, InventoryBalance balance) {
|
||||||
|
long normal = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
||||||
|
long relabel = relabelStock.cartons(customer, position).size();
|
||||||
|
return normal + Math.min(relabel, value(balance.getHoldPackages()));
|
||||||
|
}
|
||||||
|
/** 原转运旧箱与当前换标实箱互斥,避免换托后重复展示。 */
|
||||||
|
private List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
|
||||||
|
var moved = relabelStock.transferred(customer);
|
||||||
|
var result = new ArrayList<>(repository.cartons(customer, position.getId()).stream()
|
||||||
|
.filter(c -> !"TRANSFER".equals(c.getSourceType()) || !moved.contains(c.getId())).toList());
|
||||||
|
result.addAll(relabelStock.cartons(customer, position));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** 空库存桶按零处理。 */
|
/** 空库存桶按零处理。 */
|
||||||
private static long value(Long value) { return value == null ? 0 : value; }
|
private static long value(Long value) { return value == null ? 0 : value; }
|
||||||
/** 返回可恢复的业务冲突。 */
|
/** 返回可恢复的业务冲突。 */
|
||||||
|
|||||||
+8
@@ -90,6 +90,14 @@ public class DispatchAggregateController {
|
|||||||
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
|
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||||
}
|
}
|
||||||
/** 派送异常登记不触发签收或退货入库。 */
|
/** 派送异常登记不触发签收或退货入库。 */
|
||||||
|
/** 标记异常时按本车真实派送来源选择退回货物。 */
|
||||||
|
@GetMapping("/{dispatchNo}/exception-return-options")
|
||||||
|
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||||
|
public ApiResponse<java.util.List<com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Candidate>> exceptionCandidates(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||||
|
return ApiResponse.success(service.exceptionCandidates(DispatchActorFactory.platform(user),dispatchNo));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{dispatchNo}/exceptions")
|
@PostMapping("/{dispatchNo}/exceptions")
|
||||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||||
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
|
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.dispatch.service.DispatchLooseboardExecutionService;
|
||||||
|
import com.baiyue.lastleg.dispatch.service.DispatchLooseboardExecutionService.*;
|
||||||
|
import com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase;
|
||||||
|
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/** 散板取货/送达照片复用排车list/edit权限,前端不能提交商业归属。 */
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
@RequestMapping("/api/platform/v1/last-leg/dispatches/{number}/looseboard-execution")
|
||||||
|
public class DispatchLooseboardExecutionController {
|
||||||
|
private final DispatchLooseboardExecutionService service;
|
||||||
|
/** 入口只映射认证主体和原车。 */
|
||||||
|
public DispatchLooseboardExecutionController(DispatchLooseboardExecutionService service){this.service=service;}
|
||||||
|
/** 返回逐票照片及服务端节点资格。 */
|
||||||
|
@GetMapping @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||||
|
public ApiResponse<Detail> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){return ApiResponse.success(service.get(DispatchActorFactory.platform(user),number));}
|
||||||
|
/** 上传不提前推进业务状态。 */
|
||||||
|
@PostMapping(value="/{itemId}/{phase}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||||
|
public ApiResponse<File> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@RequestPart("file") MultipartFile file){return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),number,itemId,phase,file));}
|
||||||
|
/** 照片保存与节点推进同事务,公共版本防止并发覆盖。 */
|
||||||
|
@PutMapping("/{itemId}/{phase}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||||
|
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@Valid @RequestBody Save request){return ApiResponse.success(service.save(DispatchActorFactory.platform(user),number,itemId,phase,request));}
|
||||||
|
/** 当前绑定照片按原车、明细、类别读取,无公共下载地址。 */
|
||||||
|
@GetMapping("/{itemId}/{phase}/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||||
|
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable @Positive long itemId,@PathVariable Phase phase,@PathVariable @Positive long fileId){
|
||||||
|
var file=service.content(DispatchActorFactory.platform(user),number,itemId,phase,fileId);
|
||||||
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(file.file().contentType())).header("X-Content-Type-Options","nosniff").body(file.bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.*;
|
||||||
|
import com.baiyue.lastleg.dispatch.service.LooseboardCargoService;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
/** 散板需求加货需同时具备需求池读取和排车维护权限。 */
|
||||||
|
@RestController @Validated
|
||||||
|
@RequestMapping("/api/platform/v1/last-leg/dispatches/capacity-demands/{dispatchNo}/cargo")
|
||||||
|
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage') and hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||||
|
public class LooseboardCargoController {
|
||||||
|
private final LooseboardCargoService service;
|
||||||
|
public LooseboardCargoController(LooseboardCargoService service) { this.service=service; }
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<DispatchPlanningModels.CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||||
|
@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(50) int size) {
|
||||||
|
return ApiResponse.success(service.candidates(DispatchActorFactory.platform(user),dispatchNo,page,size));
|
||||||
|
}
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:cargo-add','platform:fleet:capacity-offer:manage') and hasAuthority('platform:overseas-warehouse:dispatch:edit') and hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||||
|
public ApiResponse<DispatchContracts.CommandResult> add(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.AddCargo request) {
|
||||||
|
return ApiResponse.success(service.add(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+15
-3
@@ -25,6 +25,7 @@ import java.util.stream.Collectors;
|
|||||||
@Component
|
@Component
|
||||||
@Transactional(propagation=Propagation.MANDATORY)
|
@Transactional(propagation=Propagation.MANDATORY)
|
||||||
public class DispatchFleetAdapter implements FleetDispatchGateway {
|
public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts looseboardFacts;
|
||||||
private final DispatchAggregateRepository aggregates;
|
private final DispatchAggregateRepository aggregates;
|
||||||
private final OutboundOrderMapper outbounds;
|
private final OutboundOrderMapper outbounds;
|
||||||
private final DeliveryOrderMapper deliveries;
|
private final DeliveryOrderMapper deliveries;
|
||||||
@@ -79,7 +80,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
|||||||
}
|
}
|
||||||
@Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) {
|
@Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) {
|
||||||
var context=workflow.fleetContext(number,lock);var row=required(number,false);
|
var context=workflow.fleetContext(number,lock);var row=required(number,false);
|
||||||
var cargo=aggregates.cargo(row.getId()).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(),
|
var cargo=warehouseItems(row).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(),
|
||||||
destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList();
|
destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList();
|
||||||
String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null;
|
String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null;
|
||||||
var vehicle=context.resources().vehicle();var driver=context.resources().driver();
|
var vehicle=context.resources().vehicle();var driver=context.resources().driver();
|
||||||
@@ -90,10 +91,11 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
|||||||
var row=required(number,lock);
|
var row=required(number,lock);
|
||||||
var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList();
|
var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList();
|
||||||
var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList();
|
var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList();
|
||||||
var expected=aggregates.cargo(row.getId()).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount())));
|
var expected=warehouseItems(row).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount())));
|
||||||
var byCustomer=new HashMap<String,OutboundOrder>();
|
var byCustomer=new HashMap<String,OutboundOrder>();
|
||||||
for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致");
|
for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致");
|
||||||
if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物");
|
long remotePicked=looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);
|
||||||
|
if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()+remotePicked!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物");
|
||||||
var counts=new HashMap<String,Long>();var numbers=new HashSet<String>();
|
var counts=new HashMap<String,Long>();var numbers=new HashSet<String>();
|
||||||
for(var delivery:deliveryRows) {
|
for(var delivery:deliveryRows) {
|
||||||
var outbound=byCustomer.get(delivery.getCustomerId());
|
var outbound=byCustomer.get(delivery.getCustomerId());
|
||||||
@@ -107,12 +109,20 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
|||||||
@Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) {
|
@Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) {
|
||||||
var row=required(number,lock);
|
var row=required(number,lock);
|
||||||
if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成");
|
if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成");
|
||||||
|
if(looseboardFacts!=null && !looseboardFacts.cargo(row.getId()).isEmpty()) {
|
||||||
|
boolean ready=looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.PICKUP)
|
||||||
|
&& looseboardFacts.complete(row,com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.Phase.ARRIVAL);
|
||||||
|
if(looseboardFacts.direct(row))return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),looseboardFacts.outcomes(row),ready?null:"散板逐票取货和送达照片尚未齐全");
|
||||||
|
if(!ready)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"混排散板逐票取货和送达照片尚未齐全");
|
||||||
|
}
|
||||||
var facts=facts(number,lock);var outcomes=new ArrayList<FleetCompletionModels.Outcome>();
|
var facts=facts(number,lock);var outcomes=new ArrayList<FleetCompletionModels.Outcome>();
|
||||||
for(var delivery:facts.deliveries()) {
|
for(var delivery:facts.deliveries()) {
|
||||||
var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts);
|
var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts);
|
||||||
Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null;
|
Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null;
|
||||||
outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining()));
|
outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining()));
|
||||||
}
|
}
|
||||||
|
if(looseboardFacts!=null){var remote=looseboardFacts.remoteItemIds(row.getId());var orders=looseboardFacts.cargo(row.getId()).stream().filter(c->remote.contains(c.item().getId())).map(c->c.item().getCustomerId()+":"+c.orderNo()).collect(Collectors.toSet());
|
||||||
|
outcomes.addAll(looseboardFacts.outcomes(row).stream().filter(o->orders.contains(o.customerId()+":"+o.deliveryNo())).toList());}
|
||||||
String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null;
|
String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null;
|
||||||
return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason);
|
return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason);
|
||||||
}
|
}
|
||||||
@@ -140,6 +150,8 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
|||||||
if(deliveries.update(null,new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.now()))!=1)throw conflict("派送交接保存失败");
|
if(deliveries.update(null,new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.now()))!=1)throw conflict("派送交接保存失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 混排照片取货不产生客户出库单,历史实托和其他来源仍按原链核对。 */
|
||||||
|
private List<com.baiyue.lastleg.dispatch.entity.DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?aggregates.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());}
|
||||||
private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;}
|
private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;}
|
||||||
private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);}
|
private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);}
|
||||||
/** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */
|
/** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
|
|
||||||
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
|
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
|
||||||
public interface DispatchEvidenceGateway {
|
public interface DispatchEvidenceGateway {
|
||||||
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION }
|
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION, LOOSEBOARD_PICKUP, LOOSEBOARD_ARRIVAL }
|
||||||
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
|
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
|
||||||
/** 上传只写本车、本类证据。 */
|
/** 上传只写本车、本类证据。 */
|
||||||
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
|
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
|
|||||||
}
|
}
|
||||||
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
|
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
|
||||||
private static boolean supportedType(Kind kind,String contentType) {
|
private static boolean supportedType(Kind kind,String contentType) {
|
||||||
return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType));
|
return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION && kind!=Kind.LOOSEBOARD_PICKUP && kind!=Kind.LOOSEBOARD_ARRIVAL) || !"application/pdf".equals(contentType));
|
||||||
}
|
}
|
||||||
/** 无效证据不能作为已核实资料。 */
|
/** 无效证据不能作为已核实资料。 */
|
||||||
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
|
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
|
||||||
|
|||||||
+12
-2
@@ -26,6 +26,14 @@ public interface DispatchCandidateMapper {
|
|||||||
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
|
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
|
||||||
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
|
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
|
||||||
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
|
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
|
||||||
|
/** 散板审核后的同仓申报可预排;取消、未审和普通转运不取得此资格。 */
|
||||||
|
String APPROVED_LOOSEBOARD = "EXISTS (SELECT 1 FROM ll_local_looseboard_order lb JOIN ll_warehouse_transfer_order lt " +
|
||||||
|
"ON lt.transfer_no=lb.transfer_no AND lt.customer_id=lb.customer_id " +
|
||||||
|
"WHERE lb.lastleg_order_id=ip.source_order_id AND lb.customer_id=ip.customer_id " +
|
||||||
|
"AND lb.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED') " +
|
||||||
|
"AND lt.lastleg_order_id=ip.source_order_id AND CAST(lt.operating_warehouse_id AS DECIMAL(20,0))=ip.overseas_warehouse_id " +
|
||||||
|
"AND lt.status IN ('WAIT_PDA_RECEIVING','WAIT_CUSTOMER_CONFIRMATION','READY_FOR_DISPATCH') " +
|
||||||
|
"AND p.source_pallet_no LIKE 'LB-PLAN:%')";
|
||||||
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
||||||
String CONFIRMED_APPOINTMENT_AT =
|
String CONFIRMED_APPOINTMENT_AT =
|
||||||
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
|
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
|
||||||
@@ -180,7 +188,7 @@ public interface DispatchCandidateMapper {
|
|||||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + "(" + DEPARTED_PLAN + " OR " + APPROVED_LOOSEBOARD + "))) " +
|
||||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||||
@@ -285,7 +293,7 @@ public interface DispatchCandidateMapper {
|
|||||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + "(" + DEPARTED_PLAN + " OR " + APPROVED_LOOSEBOARD + "))) " +
|
||||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||||
@@ -328,6 +336,8 @@ public interface DispatchCandidateMapper {
|
|||||||
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
|
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
|
||||||
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
|
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
|
||||||
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
||||||
|
// 部分废弃后只统计仍在仓内的换标箱,保留原任务供追溯。
|
||||||
|
"AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=ri.customer_id AND dc.carton_id=ri.id AND dc.source_type='RELABEL_DISCARDED') " +
|
||||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||||
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 散板照片履约事实追加保存在排车历史,不生成库存或拣货流水。 */
|
||||||
|
@Mapper
|
||||||
|
public interface DispatchLooseboardEvidenceMapper {
|
||||||
|
/** 按原车读取不可变照片命令,版本顺序用于恢复和审计。 */
|
||||||
|
@Select("SELECT snapshot_json FROM ll_dispatch_history WHERE dispatch_id=#{id} AND action='LOOSEBOARD_PHOTO' ORDER BY record_version,id")
|
||||||
|
List<String> snapshots(long id);
|
||||||
|
/** 同客户原单逐排车明细去重汇总送达量,分车派送不能提前完成原单。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT COALESCE(SUM(done.packages),0) FROM (
|
||||||
|
SELECT i.id,MAX(i.package_count) packages FROM ll_dispatch_history h
|
||||||
|
JOIN ll_dispatch_order d ON d.id=h.dispatch_id AND d.status='ACTIVE'
|
||||||
|
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND CAST(i.id AS CHAR)=JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.itemId'))
|
||||||
|
WHERE h.action='LOOSEBOARD_PHOTO' AND i.customer_id=#{customer}
|
||||||
|
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.customerId'))=#{customer}
|
||||||
|
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.orderNo'))=#{orderNo}
|
||||||
|
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.phase'))='ARRIVAL'
|
||||||
|
AND JSON_LENGTH(JSON_EXTRACT(h.snapshot_json,'$.files'))>0
|
||||||
|
AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.cargoHash'))=d.cargo_hash
|
||||||
|
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.reviewRound')) AS UNSIGNED)=d.review_round
|
||||||
|
GROUP BY i.id
|
||||||
|
) done
|
||||||
|
""")
|
||||||
|
long deliveredPackages(@Param("customer") String customer,@Param("orderNo") String orderNo);
|
||||||
|
}
|
||||||
+8
@@ -8,6 +8,14 @@ import java.util.List;
|
|||||||
/** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */
|
/** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface DispatchReadMapper {
|
public interface DispatchReadMapper {
|
||||||
|
/** 普通计划一票一计划,换标和退回使用冻结履约订单,避免使用混托展示代表。 */
|
||||||
|
@org.apache.ibatis.annotations.Select("SELECT i.id AS itemId,i.customer_id AS customerId,"
|
||||||
|
+ "CASE WHEN i.source_business_type='RELABEL_ORDER' THEN i.order_id ELSE COALESCE((SELECT MIN(l.order_id) "
|
||||||
|
+ "FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id "
|
||||||
|
+ "WHERE a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id HAVING COUNT(DISTINCT l.order_id)=1),i.order_id) END AS orderId "
|
||||||
|
+ "FROM ll_dispatch_item i "
|
||||||
|
+ "WHERE i.dispatch_order_id=#{id} ORDER BY i.customer_id,i.id")
|
||||||
|
java.util.List<com.baiyue.lastleg.dispatch.port.DispatchOutboundPort.PlannedSource> outboundSources(@org.apache.ibatis.annotations.Param("id") long id);
|
||||||
/** 即使计数漂移也不得把已有真实扫码当成零扫码。 */
|
/** 即使计数漂移也不得把已有真实扫码当成零扫码。 */
|
||||||
@Select("SELECT COALESCE(SUM(p.packages),0) FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{id}")
|
@Select("SELECT COALESCE(SUM(p.packages),0) FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{id}")
|
||||||
long realPicked(@Param("id") long dispatchId);
|
long realPicked(@Param("id") long dispatchId);
|
||||||
|
|||||||
+6
-2
@@ -28,13 +28,17 @@ public interface DispatchSourceMapper {
|
|||||||
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
|
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
|
||||||
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
|
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
|
||||||
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage,
|
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage,
|
||||||
""" + DispatchCandidateMapper.RECEIVED_GROSS_WEIGHT + " AS grossWeightKg,"
|
""" + "COALESCE((SELECT CASE WHEN COUNT(*)=1 AND MAX(m.gross_weight_kg)>0 THEN MAX(m.gross_weight_kg) END "
|
||||||
|
+ "FROM ll_warehouse_transfer_pallet m JOIN ll_warehouse_transfer_order t ON t.id=m.transfer_order_id AND t.customer_id=m.customer_id "
|
||||||
|
+ "WHERE m.customer_id=p.customer_id AND m.warehouse_pallet_id=p.warehouse_pallet_id AND t.lastleg_order_id=ip.source_order_id "
|
||||||
|
+ "AND m.status='COMPLETED' AND m.completed_at IS NOT NULL AND m.carton_count=p.actual_package_count),"
|
||||||
|
+ DispatchCandidateMapper.RECEIVED_GROSS_WEIGHT + ") AS grossWeightKg,"
|
||||||
+ DispatchCandidateMapper.RECEIVED_VOLUME + " AS volumeCbm " + """
|
+ DispatchCandidateMapper.RECEIVED_VOLUME + " AS volumeCbm " + """
|
||||||
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL
|
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL
|
||||||
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,ip.source_order_id,p.delivery_address,p.actual_package_count
|
||||||
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
||||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||||
""" + "</script>")
|
""" + "</script>")
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 读取已审核散板的申报计划及实收来源,不制造已收货库存。 */
|
||||||
|
@Mapper
|
||||||
|
public interface LooseboardCargoMapper {
|
||||||
|
record Reference(String customerId,String fulfillmentNo,Long planPalletId) { }
|
||||||
|
record BusinessSource(String customerId,String fulfillmentNo,String orderNo,Long planPalletId,String deliveryMode) {
|
||||||
|
@AutomapConstructor public BusinessSource { }
|
||||||
|
/** 历史展示夹具不包含取货方式,不能据此放宽仓内作业。 */
|
||||||
|
public BusinessSource(String customerId,String fulfillmentNo,String orderNo,Long planPalletId){this(customerId,fulfillmentNo,orderNo,planPalletId,null);}
|
||||||
|
}
|
||||||
|
/** 仅按同客户的真实散板关联取原单号,不从编号或备注推断业务。 */
|
||||||
|
@Select("<script>" + """
|
||||||
|
SELECT o.customer_id AS customerId,t.transfer_no AS fulfillmentNo,o.order_no AS orderNo,pp.id AS planPalletId,o.delivery_mode AS deliveryMode
|
||||||
|
FROM ll_local_looseboard_order o
|
||||||
|
JOIN ll_warehouse_transfer_order t ON t.lastleg_order_id=o.lastleg_order_id
|
||||||
|
AND t.customer_id=o.customer_id AND t.transfer_no=o.transfer_no
|
||||||
|
LEFT JOIN ll_inbound_plan p ON p.source_order_id=o.lastleg_order_id AND p.customer_id=o.customer_id
|
||||||
|
LEFT JOIN ll_inbound_plan_pallet pp ON pp.plan_id=p.id AND pp.customer_id=p.customer_id
|
||||||
|
WHERE
|
||||||
|
""" + "<foreach collection='references' item='r' separator=' OR ' open='(' close=')'>"
|
||||||
|
+ "(o.customer_id=#{r.customerId} AND (t.transfer_no=#{r.fulfillmentNo} OR pp.id=#{r.planPalletId}))"
|
||||||
|
+ "</foreach></script>")
|
||||||
|
List<BusinessSource> businessSources(@Param("references") List<Reference> references);
|
||||||
|
@Select("""
|
||||||
|
SELECT p.id FROM ll_inbound_plan p
|
||||||
|
JOIN ll_local_looseboard_order o ON o.lastleg_order_id=p.source_order_id AND o.customer_id=p.customer_id
|
||||||
|
JOIN ll_warehouse_transfer_order t ON t.transfer_no=o.transfer_no AND t.customer_id=o.customer_id
|
||||||
|
WHERE t.status IN ('WAIT_PDA_RECEIVING','WAIT_CUSTOMER_CONFIRMATION','READY_FOR_DISPATCH') AND p.status IN ('PDA_PENDING','COMPLETED') AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED')
|
||||||
|
AND t.operating_warehouse_id=#{warehouseId} AND CAST(p.overseas_warehouse_id AS CHAR(128))=#{warehouseId} AND t.lastleg_order_id=p.source_order_id
|
||||||
|
ORDER BY p.id
|
||||||
|
""")
|
||||||
|
List<Long> readyPlans(String warehouseId);
|
||||||
|
@Select("""
|
||||||
|
SELECT pp.id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id
|
||||||
|
JOIN ll_local_looseboard_order o ON o.lastleg_order_id=p.source_order_id AND o.customer_id=p.customer_id
|
||||||
|
JOIN ll_warehouse_transfer_order t ON t.transfer_no=o.transfer_no AND t.customer_id=o.customer_id
|
||||||
|
WHERE t.status IN ('WAIT_PDA_RECEIVING','WAIT_CUSTOMER_CONFIRMATION','READY_FOR_DISPATCH') AND p.status IN ('PDA_PENDING','COMPLETED') AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED')
|
||||||
|
AND t.operating_warehouse_id=#{warehouseId} AND CAST(p.overseas_warehouse_id AS CHAR(128))=#{warehouseId} AND t.lastleg_order_id=p.source_order_id
|
||||||
|
""")
|
||||||
|
List<Long> readyPallets(String warehouseId);
|
||||||
|
}
|
||||||
+8
-3
@@ -24,8 +24,11 @@ public final class DispatchCommands {
|
|||||||
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
|
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
|
||||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
|
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
|
||||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
|
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
|
||||||
/** 仅智能排车建单时显式选择发布,未选择时不发布。 */
|
/** 计划发车时间是车次表必填字段;前端未填写时给验收/草稿单一个可编辑的默认时间。 */
|
||||||
public Create { publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand); }
|
public Create {
|
||||||
|
plannedDepartureAt = plannedDepartureAt == null ? Instant.now().plusSeconds(3600) : plannedDepartureAt;
|
||||||
|
publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
|
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
|
||||||
@@ -78,7 +81,9 @@ public final class DispatchCommands {
|
|||||||
|
|
||||||
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
|
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
|
||||||
public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
|
public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
|
||||||
ReturnDirection returnDirection, String description, List<Long> imageFileIds) {
|
ReturnDirection returnDirection, String description, List<Long> imageFileIds, List<DispatchExceptionPort.Selection> returnSelections) {
|
||||||
|
/** 兼容历史仅登记异常的调用方。 */
|
||||||
|
public MarkException(Long version,String key,ExceptionType type,ReturnDirection direction,String description,List<Long> images) { this(version,key,type,direction,description,images,List.of()); }
|
||||||
/** 必填版本用于阻止过期页面覆盖当前排车。 */
|
/** 必填版本用于阻止过期页面覆盖当前排车。 */
|
||||||
public MarkException { recordVersion = requireVersion(recordVersion); }
|
public MarkException { recordVersion = requireVersion(recordVersion); }
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.port;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 派送异常通过公开端口登记工单,客户与货物归属由提供者复核。 */
|
||||||
|
public interface DispatchExceptionPort {
|
||||||
|
record Source(Long outboundPalletItemId, Long inventoryPositionId, Long returnCartonId,
|
||||||
|
String palletNo, String skuCode, String cartonMark, long maxReturnPackages) {}
|
||||||
|
record Candidate(String customerId, String customerName, String deliveryNo, Long lastMileOrderId,
|
||||||
|
String lastMileOrderNo, List<Source> sources) {}
|
||||||
|
record Selection(String deliveryNo, Long lastMileOrderId, List<Item> items) {}
|
||||||
|
record Item(Long outboundPalletItemId, Long inventoryPositionId, Long returnCartonId, long packageCount) {}
|
||||||
|
/** 按本车真实派送及原拣货事实提供退回候选。 */
|
||||||
|
List<Candidate> candidates(long dispatchId);
|
||||||
|
/** 异常工单与排车登记同事务落地。 */
|
||||||
|
void register(long dispatchId, String operator, DispatchCommands.MarkException request);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.port;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||||
|
|
||||||
|
/** 审核建出库、真实拣货完结与零扫码退回的单据边界。 */
|
||||||
|
public interface DispatchOutboundPort {
|
||||||
|
/** 计划来源明确到客户和订单,不把混托代表订单当作全部来源。 */
|
||||||
|
record PlannedSource(Long itemId, String customerId, Long orderId) { }
|
||||||
|
/** 理货审核通过后,按客户生成待出库单与未拣货明细。 */
|
||||||
|
void prepare(Context context, Actor actor);
|
||||||
|
/** 真实拣货全部核平后,以实际归属替换计划明细并完成出库。 */
|
||||||
|
void complete(Context context);
|
||||||
|
/** 零扫码退回重审时撤销待出库单。 */
|
||||||
|
void revoke(long dispatchId);
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.port;
|
||||||
|
|
||||||
|
/** 排车照片证明的商业进度输出,由散板原单域实现;不写入库存状态。 */
|
||||||
|
public interface LooseboardTransportPort {
|
||||||
|
/** 分车送达按去重累计箱数完成,零箱表示仅已取货进入运输。 */
|
||||||
|
void progress(String customerId,String orderNo,String operatorId,long deliveredPackages);
|
||||||
|
}
|
||||||
+2
@@ -42,6 +42,8 @@ public class DispatchAggregateRepository {
|
|||||||
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
|
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
|
||||||
/** 按整车读取全部真实客户明细。 */
|
/** 按整车读取全部真实客户明细。 */
|
||||||
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
|
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
|
||||||
|
/** 建出库时读取逐明细真实计划归属。 */
|
||||||
|
public List<com.baiyue.lastleg.dispatch.port.DispatchOutboundPort.PlannedSource> outboundSources(long id) { return reads.outboundSources(id); }
|
||||||
/** 只读取本次确认入库的原计划关联排车。 */
|
/** 只读取本次确认入库的原计划关联排车。 */
|
||||||
public List<String> receiptDispatches(List<Long> planIds) {
|
public List<String> receiptDispatches(List<Long> planIds) {
|
||||||
return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList();
|
return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList();
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.repository;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.mapper.DispatchLooseboardEvidenceMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 排车照片履约事实只经本域Mapper读取。 */
|
||||||
|
@Repository
|
||||||
|
public class DispatchLooseboardEvidenceRepository {
|
||||||
|
private final DispatchLooseboardEvidenceMapper mapper;
|
||||||
|
/** 注入照片历史读取适配器。 */
|
||||||
|
public DispatchLooseboardEvidenceRepository(DispatchLooseboardEvidenceMapper mapper){this.mapper=mapper;}
|
||||||
|
/** 历史始终按公共版本递增返回。 */
|
||||||
|
public List<String> snapshots(long id){return mapper.snapshots(id);}
|
||||||
|
/** 返回同客户原单已经有送达照片的真实运输箱数。 */
|
||||||
|
public long deliveredPackages(String customer,String number){return mapper.deliveredPackages(customer,number);}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.repository;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 散板候选只读取同仓已确认的真实收货来源。 */
|
||||||
|
@Repository
|
||||||
|
public class LooseboardCargoRepository {
|
||||||
|
private final LooseboardCargoMapper mapper;
|
||||||
|
public LooseboardCargoRepository(LooseboardCargoMapper mapper) { this.mapper=mapper; }
|
||||||
|
public List<Long> readyPlans(String warehouseId) { return mapper.readyPlans(warehouseId); }
|
||||||
|
public List<Long> readyPallets(String warehouseId) { return mapper.readyPallets(warehouseId); }
|
||||||
|
/** 批量读取真实商业原单,保留履约来源身份供命令继续使用。 */
|
||||||
|
public List<LooseboardCargoMapper.BusinessSource> businessSources(List<LooseboardCargoMapper.Reference> references) {
|
||||||
|
return references.isEmpty()?List.of():mapper.businessSources(references);
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
-18
@@ -32,6 +32,10 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
|||||||
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
||||||
@Service
|
@Service
|
||||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
||||||
|
@jakarta.annotation.Resource private DispatchLooseboardFacts looseboardFacts;
|
||||||
|
@jakarta.annotation.Resource private DispatchLooseboardDisplayService looseboardDisplay;
|
||||||
|
@jakarta.annotation.Resource private DispatchOutboundPort outboundWorkflow;
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.port.DispatchExceptionPort exceptionWorkflow;
|
||||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||||
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
||||||
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
|
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
|
||||||
@@ -198,7 +202,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
|
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
|
||||||
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
|
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
|
||||||
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
|
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
|
||||||
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),
|
return new DispatchPlanningModels.CandidateList(looseboardDisplay.candidates(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),
|
||||||
normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(),
|
normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(),
|
||||||
(page.total()+normalized.size()-1)/normalized.size());
|
(page.total()+normalized.size()-1)/normalized.size());
|
||||||
}
|
}
|
||||||
@@ -209,13 +213,16 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
requirePlatform(actor);var row=required(actor,number,false);
|
requirePlatform(actor);var row=required(actor,number,false);
|
||||||
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
||||||
var gate=actions(actor,row).get(Action.REMOVE_CARGO);var result=new ArrayList<DispatchWorkModels.CargoItem>();
|
var gate=actions(actor,row).get(Action.REMOVE_CARGO);var result=new ArrayList<DispatchWorkModels.CargoItem>();
|
||||||
for(var item:repository.cargo(row.getId())) {
|
var cargo=repository.cargo(row.getId());
|
||||||
|
var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
|
||||||
|
for(var item:cargo) {
|
||||||
|
var business=looseboardDisplay.find(businessSources,item.getCustomerId(),item.getOrderNo(),item.getPlanPalletId());
|
||||||
boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType());
|
boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType());
|
||||||
var remove=relabel?new Gate(false,"RELABEL_WHOLE_SOURCE","换标按整单排车,请取消整单后重排"):gate;
|
var remove=relabel?new Gate(false,"RELABEL_WHOLE_SOURCE","换标按整单排车,请取消整单后重排"):gate;
|
||||||
if(picked.getOrDefault(item.getId(),0L)>0)remove=new Gate(false,"ALREADY_PICKED","本来源已经真实扫码");
|
if(picked.getOrDefault(item.getId(),0L)>0)remove=new Gate(false,"ALREADY_PICKED","本来源已经真实扫码");
|
||||||
result.add(new DispatchWorkModels.CargoItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getSourceVersion(),
|
result.add(new DispatchWorkModels.CargoItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getSourceVersion(),
|
||||||
relabel?SourceType.RELABEL:item.getReturnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN,item.getPlanPalletId(),item.getReturnReceiptId(),
|
relabel?SourceType.RELABEL:item.getReturnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN,item.getPlanPalletId(),item.getReturnReceiptId(),
|
||||||
relabel?item.getSourceBusinessNo():null,item.getWarehousePalletId(),item.getPalletNo(),item.getOrderNo(),item.getInboundCode(),destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),remove));
|
relabel?item.getSourceBusinessNo():null,item.getWarehousePalletId(),item.getPalletNo(),business==null?item.getOrderNo():business.orderNo(),business==null?item.getInboundCode():null,destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),remove));
|
||||||
}
|
}
|
||||||
return new DispatchWorkModels.Items(number,row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),List.copyOf(result));
|
return new DispatchWorkModels.Items(number,row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),List.copyOf(result));
|
||||||
}
|
}
|
||||||
@@ -376,6 +383,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
if("LOCAL_TRUCK".equals(row.getTaskType()) && !"BEFORE_LOADING".equals(row.getStage()))throw conflict("装车中及后续阶段不能取消排车");
|
if("LOCAL_TRUCK".equals(row.getTaskType()) && !"BEFORE_LOADING".equals(row.getStage()))throw conflict("装车中及后续阶段不能取消排车");
|
||||||
if(trim(request.reason())==null)throw bad("取消原因不能为空");
|
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("本车已有真实执行或关联出库,不能取消");
|
if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消");
|
||||||
|
if(Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType())) {
|
||||||
|
// 运输任务沿原车次取消并同步释放原单;公开排车版本与车次版本独立,不能套用仓库派送关联。
|
||||||
|
var trip=transportTasks.transport(number,true);
|
||||||
|
transportTasks.cancel(number,actor.operatorId(),new com.baiyue.lastleg.fleet.trip.FleetTripModels.Cancel(trip.getRecordVersion(),request.reason()));
|
||||||
|
// 同事务运输投影已经保存主单版本和历史,仅补记公开命令结果,避免重复递增或覆盖投影。
|
||||||
|
row=required(actor,number,true);
|
||||||
|
var result=result(row,identity,new DispatchResults.Saved(number,List.of()));
|
||||||
|
commands.append(row.getId(),actor,identity,result);return result;
|
||||||
|
}
|
||||||
// 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。
|
// 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。
|
||||||
var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
|
var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
|
||||||
releaseSources(row,actor,items,now());
|
releaseSources(row,actor,items,now());
|
||||||
@@ -470,8 +486,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
requireEditing(row,request.recordVersion());
|
requireEditing(row,request.recordVersion());
|
||||||
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||||
verifyReviewResources(row);
|
verifyReviewResources(row);
|
||||||
// 本土卡车货物来自原卡车订单,不建立 dispatch_item;按排车摘要件数即可进入两级审核。
|
// 本土卡车货物来自原订单,不建立 dispatch_item;托数与箱数独立,有托无箱不能误判为空车。
|
||||||
if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && !"LOCAL_TRUCK".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核");
|
boolean hasQuantity=n(row.getPackageCount())>0 || ("LOCAL_TRUCK".equals(row.getTaskType()) && n(row.getPalletCount())>0);
|
||||||
|
if(!hasQuantity || (!"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);
|
long round=row.getReviewRound()+1;String basis=basis(row);
|
||||||
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
|
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)));
|
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null)));
|
||||||
@@ -509,7 +526,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
|
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
|
||||||
if(request.decision()==Decision.APPROVED) {
|
if(request.decision()==Decision.APPROVED) {
|
||||||
row.setStage(Stage.LOADING.name());
|
row.setStage(Stage.LOADING.name());
|
||||||
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(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));
|
||||||
|
if(outboundWorkflow!=null && !photoOnly(row)) outboundWorkflow.prepare(plannedOutboundContext(actor,row),actor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
|
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
|
||||||
@@ -532,6 +552,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
if(row.getRecordVersion().longValue()!=request.recordVersion() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()))throw conflict("仅当前版本装车中的有效单可退回重审");
|
if(row.getRecordVersion().longValue()!=request.recordVersion() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()))throw conflict("仅当前版本装车中的有效单可退回重审");
|
||||||
if(trim(request.reason())==null)throw bad("退回原因不能为空");
|
if(trim(request.reason())==null)throw bad("退回原因不能为空");
|
||||||
if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前");
|
if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前");
|
||||||
|
if(outboundWorkflow!=null) outboundWorkflow.revoke(row.getId());
|
||||||
row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason());
|
row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason());
|
||||||
return saveCommand(row,actor,identity,request.reason());
|
return saveCommand(row,actor,identity,request.reason());
|
||||||
}
|
}
|
||||||
@@ -587,7 +608,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
/** 两端读取同一实载清单;已知实测自动带出,建议值不能填入实际字段。 */
|
/** 两端读取同一实载清单;已知实测自动带出,建议值不能填入实际字段。 */
|
||||||
@Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
@Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
public DispatchPlanningModels.LoadingPreview loading(Actor actor,String number) {
|
public DispatchPlanningModels.LoadingPreview loading(Actor actor,String number) {
|
||||||
var row=required(actor,number,false);var context=context(actor,row);var items=repository.cargo(row.getId());
|
var row=required(actor,number,false);var context=context(actor,row);var items=warehouseItems(row);
|
||||||
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
||||||
var measured=executionReads.actualMeasurements(row.getId()).stream().collect(Collectors.groupingBy(p->p.palletId()));
|
var measured=executionReads.actualMeasurements(row.getId()).stream().collect(Collectors.groupingBy(p->p.palletId()));
|
||||||
var stops=fleet.dispatchStops(number);var pallets=new ArrayList<DispatchPlanningModels.LoadingPallet>();var missing=new ArrayList<String>();
|
var stops=fleet.dispatchStops(number);var pallets=new ArrayList<DispatchPlanningModels.LoadingPallet>();var missing=new ArrayList<String>();
|
||||||
@@ -623,19 +644,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
|
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
|
||||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||||
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
|
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
|
||||||
var items=repository.cargo(row.getId());
|
var items=warehouseItems(row);
|
||||||
// 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。
|
// 仓内来源必须实收;异地散板取货不进入本次仓库出库。
|
||||||
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
|
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
|
||||||
lockExecutionSources(row,items);
|
lockExecutionSources(row,items);
|
||||||
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
|
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
|
||||||
String appointmentBlock=reviewAppointmentBlock(row);
|
String appointmentBlock=reviewAppointmentBlock(row);
|
||||||
if(appointmentBlock==null)appointmentBlock=appointments.departureBlockReason(number);
|
if(appointmentBlock==null)appointmentBlock=appointments.departureBlockReason(number,remoteItemIds(row));
|
||||||
if(appointmentBlock!=null)throw conflict(appointmentBlock);
|
if(appointmentBlock!=null)throw conflict(appointmentBlock);
|
||||||
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
||||||
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
|
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
|
||||||
// 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。
|
// 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。
|
||||||
var measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,
|
var measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,
|
||||||
p.weightKg(),p.volumeCbm(),p.deliveryIndex())).sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
|
p.weightKg(),p.volumeCbm(),p.deliveryIndex())).sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
|
||||||
|
requireRemoteCapacity(row,measurements);
|
||||||
verifyReviewResources(row);var context=context(actor,row);
|
verifyReviewResources(row);var context=context(actor,row);
|
||||||
var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList();
|
var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList();
|
||||||
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
|
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
|
||||||
@@ -656,9 +678,27 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
|
if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
|
||||||
return context(null,row);
|
return context(null,row);
|
||||||
}
|
}
|
||||||
|
/** 审核建单使用逐计划来源;该上下文不作为真实拣货或装车核平证明。 */
|
||||||
|
private Context plannedOutboundContext(Actor actor,DispatchAggregate row) {
|
||||||
|
var sources=repository.outboundSources(row.getId()).stream().collect(Collectors.toMap(DispatchOutboundPort.PlannedSource::itemId,s->s));
|
||||||
|
var cargo=new ArrayList<CargoLine>();
|
||||||
|
for(var item:warehouseItems(row)) {
|
||||||
|
var source=sources.get(item.getId());
|
||||||
|
if(source==null || source.orderId()==null || !Objects.equals(source.customerId(),item.getCustomerId()))throw conflict("排车货物缺少真实来源订单");
|
||||||
|
cargo.add(new CargoLine(item.getId(),item.getCustomerId(),item.getCustomerName(),source.orderId(),item.getOrderNo(),"ORDER:"+source.orderId(),item.getSourceStage(),
|
||||||
|
null,null,item.getInboundCode(),item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),null,null,null,null,item.getReturnReceiptId(),null,
|
||||||
|
item.getCartonMarks(),"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),n(item.getPackageCount()),0,
|
||||||
|
item.getGrossWeightKg(),item.getVolumeCbm(),List.of(),null));
|
||||||
|
}
|
||||||
|
return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),null,row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),
|
||||||
|
Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),null,null,null,
|
||||||
|
read(row.getResourcesJson(),Resources.class),warehouseCounts(row),reviews(row),false,List.copyOf(cargo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行上下文仅包含真实扫码,不以审核计划补齐缺失拣货。 */
|
||||||
private Context context(Actor actor,DispatchAggregate row) {
|
private Context context(Actor actor,DispatchAggregate row) {
|
||||||
var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化");
|
var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化");
|
||||||
var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i));
|
var items=warehouseItems(row);var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i));
|
||||||
var batches=repository.batches(row.getId());var cargo=new ArrayList<CargoLine>();var actualSources=executionReads.sources(row.getId());
|
var batches=repository.batches(row.getId());var cargo=new ArrayList<CargoLine>();var actualSources=executionReads.sources(row.getId());
|
||||||
var origins=actualSources.stream().anyMatch(p->p.dispatchItemSourceId()!=null)?sourceOrigins.all(row.getId()).stream().collect(Collectors.toMap(s->s.getId(),s->s)):Map.<Long,com.baiyue.lastleg.dispatch.entity.DispatchItemSource>of();
|
var origins=actualSources.stream().anyMatch(p->p.dispatchItemSourceId()!=null)?sourceOrigins.all(row.getId()).stream().collect(Collectors.toMap(s->s.getId(),s->s)):Map.<Long,com.baiyue.lastleg.dispatch.entity.DispatchItemSource>of();
|
||||||
var remaining=new LinkedHashMap<com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow,Long>();batches.forEach(b->remaining.put(b,(long)b.packageCount()));
|
var remaining=new LinkedHashMap<com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow,Long>();batches.forEach(b->remaining.put(b,(long)b.packageCount()));
|
||||||
@@ -678,10 +718,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
}
|
}
|
||||||
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
||||||
boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount()))
|
boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount()))
|
||||||
&& executionReads.inconsistentPicks(row.getId())==0 && cargo.stream().mapToLong(CargoLine::packages).sum()==n(row.getPackageCount());
|
&& executionReads.inconsistentPicks(row.getId())==0 && cargo.stream().mapToLong(CargoLine::packages).sum()==warehouseCounts(row).packageCount();
|
||||||
return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),identity.tripNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),
|
return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),identity.tripNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),
|
||||||
Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),row.getLoadingCompletionHash(),instant(row.getLoadingCompletedAt()),instant(row.getDepartedAt()),
|
Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),row.getLoadingCompletionHash(),instant(row.getLoadingCompletedAt()),instant(row.getDepartedAt()),
|
||||||
read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),complete,List.copyOf(cargo));
|
read(row.getResourcesJson(),Resources.class),warehouseCounts(row),reviews(row),complete,List.copyOf(cargo));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 普通换标出库沿本次冻结贡献的新箱标,不从退货箱表或原箱唛生成快照。 */
|
/** 普通换标出库沿本次冻结贡献的新箱标,不从退货箱表或原箱唛生成快照。 */
|
||||||
@@ -828,6 +868,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
String reason=reviewAppointmentBlock(row);
|
String reason=reviewAppointmentBlock(row);
|
||||||
if(reason!=null)throw conflict(reason);
|
if(reason!=null)throw conflict(reason);
|
||||||
}
|
}
|
||||||
|
/** 纯散板照片发运仍校验真实预约资格,不要求虚构仓库出库。 */
|
||||||
|
void requirePhotoDeparture(DispatchAggregate row) {
|
||||||
|
requireReviewAppointment(row);
|
||||||
|
}
|
||||||
|
/** 已产生实际扫码的历史车继续沿原出库链,不抹除真实执行。 */
|
||||||
|
boolean photoOnly(DispatchAggregate row){return looseboardFacts!=null && looseboardFacts.direct(row);}
|
||||||
private String reviewAppointmentBlock(DispatchAggregate row) {
|
private String reviewAppointmentBlock(DispatchAggregate row) {
|
||||||
String reason=DispatchDestinationAppointments.blockReason(reviewAppointments(row),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()),appointments.reviewEvidence(row.getDispatchNo()));
|
String reason=DispatchDestinationAppointments.blockReason(reviewAppointments(row),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()),appointments.reviewEvidence(row.getDispatchNo()));
|
||||||
if(reason==null)reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
|
if(reason==null)reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
|
||||||
@@ -844,6 +890,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
/** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */
|
/** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */
|
||||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||||
public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) {
|
public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) {
|
||||||
|
var initial=required(actor,number,false);
|
||||||
|
if ("LOCAL_TRUCK".equals(initial.getTaskType()) || "PICKUP".equals(initial.getTaskType()))throw conflict("仅派送中的仓库派送排车可以标记异常");
|
||||||
var row=lockPlatform(actor,number);
|
var row=lockPlatform(actor,number);
|
||||||
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
|
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
|
||||||
var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request);
|
var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request);
|
||||||
@@ -858,10 +906,19 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id);
|
for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id);
|
||||||
var records=new ArrayList<>(exceptionRecords(row));
|
var records=new ArrayList<>(exceptionRecords(row));
|
||||||
records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now()));
|
records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now()));
|
||||||
|
exceptionWorkflow.register(row.getId(),actor.operatorId(),request);
|
||||||
row.setExceptionsJson(write(records));
|
row.setExceptionsJson(write(records));
|
||||||
return saveCommand(row,actor,identity,description);
|
return saveCommand(row,actor,identity,description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 退回候选与标记异常共用平台和阶段门禁。 */
|
||||||
|
@Transactional(readOnly=true)
|
||||||
|
public java.util.List<com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Candidate> exceptionCandidates(Actor actor,String number) {
|
||||||
|
var row=required(actor,number,false);
|
||||||
|
requireExceptionAllowed(actor,row,row.getRecordVersion());
|
||||||
|
return exceptionWorkflow.candidates(row.getId());
|
||||||
|
}
|
||||||
|
|
||||||
/** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */
|
/** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */
|
||||||
private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) {
|
private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) {
|
||||||
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
|
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
|
||||||
@@ -962,7 +1019,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
}
|
}
|
||||||
var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet());
|
var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet());
|
||||||
var resolved=current.isEmpty()?sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections):sourceRules.lockAdditionalSources(row.getOverseasWarehouseId(),selections,current);
|
var resolved=current.isEmpty()?sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections):sourceRules.lockAdditionalSources(row.getOverseasWarehouseId(),selections,current);
|
||||||
if(!current.isEmpty())lockExecutionSources(row,current);
|
// 计划阶段追加未到仓货物只核对原订单和计划归属,不要求尚不存在的实托库存。
|
||||||
|
if(!current.isEmpty()) {
|
||||||
|
if(current.stream().allMatch(i->i.getWarehousePalletId()==null && i.getPlanPalletId()!=null))
|
||||||
|
sourceRules.lockAppointmentSources(row.getOverseasWarehouseId(),current);
|
||||||
|
else lockExecutionSources(row,current);
|
||||||
|
}
|
||||||
var at=now();var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null);
|
var at=now();var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null);
|
||||||
var physical=physicalClaims.reserve(owner,java.util.stream.Stream.concat(current.stream().map(DispatchItem::getWarehousePalletId),resolved.stream().map(s->s.candidate().warehousePalletId())).filter(Objects::nonNull).toList(),at);
|
var physical=physicalClaims.reserve(owner,java.util.stream.Stream.concat(current.stream().map(DispatchItem::getWarehousePalletId),resolved.stream().map(s->s.candidate().warehousePalletId())).filter(Objects::nonNull).toList(),at);
|
||||||
var saved=new ArrayList<DispatchItem>();
|
var saved=new ArrayList<DispatchItem>();
|
||||||
@@ -1093,10 +1155,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock));
|
gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock));
|
||||||
}
|
}
|
||||||
if(action==Action.LOADING_COMPLETE && state && permitted) {
|
if(action==Action.LOADING_COMPLETE && state && permitted) {
|
||||||
String reason=appointments.departureBlockReason(row.getDispatchNo());
|
String reason=appointments.departureBlockReason(row.getDispatchNo(),remoteItemIds(row));
|
||||||
if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason));
|
if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if(photoOnly(row)) {
|
||||||
|
gates.put(Action.PICK,new Gate(false,"LOOSEBOARD_PHOTOS","纯散板无需PDA拣货,请在管理资料上传取货照片"));
|
||||||
|
gates.put(Action.LOADING_COMPLETE,new Gate(false,"LOOSEBOARD_PHOTOS","纯散板上传取货照片后直接进入派送中"));
|
||||||
|
}
|
||||||
return Collections.unmodifiableMap(gates);
|
return Collections.unmodifiableMap(gates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,12 +1221,16 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
|
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
|
||||||
private List<DispatchViews.OrderDetail> orderDetails(List<DispatchItem> cargo) {
|
private List<DispatchViews.OrderDetail> orderDetails(List<DispatchItem> cargo) {
|
||||||
var result=new ArrayList<DispatchViews.OrderDetail>();
|
var result=new ArrayList<DispatchViews.OrderDetail>();
|
||||||
|
var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
|
||||||
for(var item:cargo) {
|
for(var item:cargo) {
|
||||||
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
|
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
|
||||||
var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList();
|
var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList();
|
||||||
if(orders.isEmpty()) orders=Collections.singletonList(null);
|
if(orders.isEmpty()) orders=Collections.singletonList(null);
|
||||||
for(var order:orders) result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),order,item.getInboundCode(),orders.size()==1?item.getGrossWeightKg():null,
|
for(var order:orders) {
|
||||||
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo()));
|
var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId());
|
||||||
|
result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null,
|
||||||
|
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return List.copyOf(result);
|
return List.copyOf(result);
|
||||||
}
|
}
|
||||||
@@ -1216,7 +1286,36 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
/** 整车真实统计。 */
|
/** 整车真实统计。 */
|
||||||
private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); }
|
private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); }
|
||||||
/** 空单永远不能核平为装车完成。 */
|
/** 空单永远不能核平为装车完成。 */
|
||||||
private static boolean reconciled(DispatchAggregate row) { return n(row.getPackageCount())>0 && Objects.equals(row.getPackageCount(),row.getPickedPackageCount()); }
|
/** 混排仅核平仓内应拣数量,不用异地散板申报量补造扫码。 */
|
||||||
|
private boolean reconciled(DispatchAggregate row) { long expected=warehouseCounts(row).packageCount();return expected>0 && expected==n(row.getPickedPackageCount()); }
|
||||||
|
/** 异地散板身份只从本车权威关联读取,不由请求提供。 */
|
||||||
|
public Set<Long> remoteItemIds(DispatchAggregate row){return looseboardFacts==null?Set.of():looseboardFacts.remoteItemIds(row.getId());}
|
||||||
|
/** 已保存取货照片的异地货量与仓库实发分开核对,正式发运不得覆盖前者。 */
|
||||||
|
public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);}
|
||||||
|
/** 仓内实物作业范围,普通未收货计划仍保留并按原规则阻断。 */
|
||||||
|
public List<DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?repository.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());}
|
||||||
|
/** 异地取货仍占用本趟容量;使用仓内实测加散板申报,不补造实托或实测。 */
|
||||||
|
public void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){
|
||||||
|
var ids=remoteItemIds(row);if(ids.isEmpty())return;
|
||||||
|
var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList();
|
||||||
|
var capacity=read(row.getResourcesJson(),Resources.class).vehicle().capacity();
|
||||||
|
if(remote.stream().anyMatch(i->i.getGrossWeightKg()==null || i.getVolumeCbm()==null) || measured.stream().anyMatch(m->m.weightKg()==null || m.volumeCbm()==null))throw conflict("混排实测与异地取货申报货量未明确");
|
||||||
|
var weight=remote.stream().map(DispatchItem::getGrossWeightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||||
|
var volume=remote.stream().map(DispatchItem::getVolumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||||
|
if(remote.size()+measured.stream().mapToInt(LoadingMeasurement::palletSpaces).sum()>capacity.palletSpaces() || weight.compareTo(capacity.maxLoadKg())>0 || volume.compareTo(capacity.maxVolumeCbm())>0)throw conflict("仓内装载与异地取货合计超过本车容量");
|
||||||
|
}
|
||||||
|
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
|
||||||
|
public void requireRemotePickupCapacity(DispatchAggregate row){
|
||||||
|
if(row.getLoadingConfirmationJson()==null)throw conflict("混排尚未确认仓内装载");
|
||||||
|
requireRemoteCapacity(row,read(row.getLoadingConfirmationJson(),ConfirmedLoad.class).pallets());
|
||||||
|
}
|
||||||
|
/** 派送端口仅承接仓库真实出库;整车页面总量仍使用原主单统计。 */
|
||||||
|
private Counts warehouseCounts(DispatchAggregate row){
|
||||||
|
if(remoteItemIds(row).isEmpty())return counts(row);
|
||||||
|
var items=warehouseItems(row);long packages=items.stream().mapToLong(i->n(i.getPackageCount())).sum();
|
||||||
|
return new Counts(items.size(),items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().count(),packages,
|
||||||
|
n(row.getPickedPackageCount()),row.getDepartedAt()==null?0:packages);
|
||||||
|
}
|
||||||
/** 目的地业务标签仅来源于真实目的仓或实际地址。 */
|
/** 目的地业务标签仅来源于真实目的仓或实际地址。 */
|
||||||
private String destinationLabel(DispatchItem i) { return "PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():trim(i.getDestinationWarehouseCode())==null?i.getFbaWarehouseCode():i.getDestinationWarehouseCode(); }
|
private String destinationLabel(DispatchItem i) { return "PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():trim(i.getDestinationWarehouseCode())==null?i.getFbaWarehouseCode():i.getDestinationWarehouseCode(); }
|
||||||
/** 目的地稳定业务键复用既有地址规范,不信任用户指定路线。 */
|
/** 目的地稳定业务键复用既有地址规范,不信任用户指定路线。 */
|
||||||
@@ -1240,3 +1339,4 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
/** 明确版本/状态/事实竞争失败。 */
|
/** 明确版本/状态/事实竞争失败。 */
|
||||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
|
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -19,6 +19,8 @@ public class DispatchAppointmentAllocations {
|
|||||||
|
|
||||||
/** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */
|
/** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */
|
||||||
public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);}
|
public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);}
|
||||||
|
/** 异地取货只用原计划事实,仓内出库继续逐票核对实拣。 */
|
||||||
|
public String departureBlockReason(String dispatchNo,Set<Long> remote){return remote.isEmpty()?provider.departureBlockReason(dispatchNo):provider.mixedDepartureBlockReason(dispatchNo,remote);}
|
||||||
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
|
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
|
||||||
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
|
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
|
||||||
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
|
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
|
||||||
|
|||||||
+12
-3
@@ -19,6 +19,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
|||||||
/** 提交后的正常签收汇总;父CAS、唯一完成历史和指定信号确认必须共同提交。 */
|
/** 提交后的正常签收汇总;父CAS、唯一完成历史和指定信号确认必须共同提交。 */
|
||||||
@Service
|
@Service
|
||||||
public class DispatchCompletionService implements DispatchCompletionPort {
|
public class DispatchCompletionService implements DispatchCompletionPort {
|
||||||
|
@jakarta.annotation.Resource private DispatchLooseboardFacts looseboardFacts;
|
||||||
private static final Actor SYSTEM=new Actor("SYSTEM_DELIVERY_RECONCILE",ActorMode.PLATFORM,null);
|
private static final Actor SYSTEM=new Actor("SYSTEM_DELIVERY_RECONCILE",ActorMode.PLATFORM,null);
|
||||||
private final DispatchAggregateRepository repository;
|
private final DispatchAggregateRepository repository;
|
||||||
private final DispatchExecutionRepository reads;
|
private final DispatchExecutionRepository reads;
|
||||||
@@ -27,10 +28,13 @@ public class DispatchCompletionService implements DispatchCompletionPort {
|
|||||||
private final DispatchDeliveryPort delivery;
|
private final DispatchDeliveryPort delivery;
|
||||||
private final DispatchSignalPort signals;
|
private final DispatchSignalPort signals;
|
||||||
private final ObjectMapper json;
|
private final ObjectMapper json;
|
||||||
|
private final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion;
|
||||||
/** 仅依赖共同端口,禁止持签收子锁回调父流程。 */
|
/** 仅依赖共同端口,禁止持签收子锁回调父流程。 */
|
||||||
public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet,
|
public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet,
|
||||||
DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json) {
|
DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json,
|
||||||
|
com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion) {
|
||||||
this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json;
|
this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json;
|
||||||
|
this.completion=completion;
|
||||||
}
|
}
|
||||||
/** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */
|
/** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */
|
||||||
@Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
|
@Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
|
||||||
@@ -42,7 +46,10 @@ public class DispatchCompletionService implements DispatchCompletionPort {
|
|||||||
if(facts==null || !Objects.equals(facts.dispatchNo(),row.getDispatchNo()) || facts.expectedDeliveryNos()==null || !facts.expectedDeliveryNos().contains(signal.deliveryNo()))throw new IllegalStateException("签收信号不属于本车已安排派送");
|
if(facts==null || !Objects.equals(facts.dispatchNo(),row.getDispatchNo()) || facts.expectedDeliveryNos()==null || !facts.expectedDeliveryNos().contains(signal.deliveryNo()))throw new IllegalStateException("签收信号不属于本车已安排派送");
|
||||||
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Set.of(Stage.DELIVERING.name(),Stage.DELIVERED.name()).contains(row.getStage()))throw new IllegalStateException("本车尚无合法在途运输事实");
|
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Set.of(Stage.DELIVERING.name(),Stage.DELIVERED.name()).contains(row.getStage()))throw new IllegalStateException("本车尚无合法在途运输事实");
|
||||||
boolean changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
|
boolean changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
|
||||||
if(Stage.DELIVERING.name().equals(row.getStage()) && facts.allNormallySigned()) {
|
boolean photosReady=looseboardFacts==null || looseboardFacts.cargo(row.getId()).isEmpty()
|
||||||
|
|| looseboardFacts.complete(row,DispatchLooseboardFacts.Phase.PICKUP) && looseboardFacts.complete(row,DispatchLooseboardFacts.Phase.ARRIVAL);
|
||||||
|
boolean complete=facts.allNormallySigned() && photosReady;
|
||||||
|
if(Stage.DELIVERING.name().equals(row.getStage()) && complete) {
|
||||||
if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据");
|
if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据");
|
||||||
long version=row.getRecordVersion();row.setStage(Stage.DELIVERED.name());row.setDeliveryCompletedAt(LocalDateTime.ofInstant(facts.completedAt(),ZoneOffset.UTC));
|
long version=row.getRecordVersion();row.setStage(Stage.DELIVERED.name());row.setDeliveryCompletedAt(LocalDateTime.ofInstant(facts.completedAt(),ZoneOffset.UTC));
|
||||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(now);
|
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(now);
|
||||||
@@ -50,9 +57,11 @@ public class DispatchCompletionService implements DispatchCompletionPort {
|
|||||||
if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存");
|
if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存");
|
||||||
changed=true;
|
changed=true;
|
||||||
}
|
}
|
||||||
|
// 全部正常签收后直接结案释放,不再等待人工二次确认。
|
||||||
|
if (complete) completion.completeDelivered(identity.tripNo(), SYSTEM.operatorId());
|
||||||
// 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。
|
// 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。
|
||||||
signals.acknowledge(signal,Instant.now());
|
signals.acknowledge(signal,Instant.now());
|
||||||
return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(),
|
return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,complete,
|
||||||
row.getDeliveryCompletedAt()==null?null:row.getDeliveryCompletedAt().toInstant(ZoneOffset.UTC));
|
row.getDeliveryCompletedAt()==null?null:row.getDeliveryCompletedAt().toInstant(ZoneOffset.UTC));
|
||||||
}
|
}
|
||||||
/** 普通读候选不持子锁跨入汇总或重建事务。 */
|
/** 普通读候选不持子锁跨入汇总或重建事务。 */
|
||||||
|
|||||||
+5
-4
@@ -41,16 +41,17 @@ public class DispatchDepartureService {
|
|||||||
if(facts==null || facts.depart()==null || !facts.depart().allowed())throw conflict(facts==null || facts.depart()==null?"本车派送安排尚未核齐":facts.depart().blockReason());
|
if(facts==null || facts.depart()==null || !facts.depart().allowed())throw conflict(facts==null || facts.depart()==null?"本车派送安排尚未核齐":facts.depart().blockReason());
|
||||||
if(!Objects.equals(facts.arrangementKey(),row.getArrangementKey()) || !Objects.equals(facts.arrangementBusinessHash(),row.getArrangementBusinessHash())
|
if(!Objects.equals(facts.arrangementKey(),row.getArrangementKey()) || !Objects.equals(facts.arrangementBusinessHash(),row.getArrangementBusinessHash())
|
||||||
|| !Objects.equals(facts.cargoHash(),context.cargoHash()) || !Objects.equals(facts.loadingCompletionHash(),context.loadingCompletionHash()))throw conflict("本车实际货物或派送安排已变化");
|
|| !Objects.equals(facts.cargoHash(),context.cargoHash()) || !Objects.equals(facts.loadingCompletionHash(),context.loadingCompletionHash()))throw conflict("本车实际货物或派送安排已变化");
|
||||||
fleet.requireDispatchDeparture(number,confirmed.manifestHash());
|
var remote=workflow.remoteItemIds(row);
|
||||||
workflow.lockExecutionSources(row,repository.cargo(row.getId()));
|
if(remote.isEmpty())fleet.requireDispatchDeparture(number,confirmed.manifestHash());else fleet.requireDispatchDeparture(number,confirmed.manifestHash(),remote);
|
||||||
|
workflow.lockExecutionSources(row,workflow.warehouseItems(row));
|
||||||
var orderOwners=new TreeMap<Long,String>();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId());
|
var orderOwners=new TreeMap<Long,String>();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId());
|
||||||
for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车");
|
for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车");
|
||||||
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey());
|
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey());
|
||||||
if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平");
|
if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平");
|
||||||
sourceWorkflow.departWholeSources(context,actor,departed,at);
|
sourceWorkflow.departWholeSources(context,actor,departed,at);
|
||||||
workflow.releaseSources(row,actor,repository.cargo(row.getId()),LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
workflow.releaseSources(row,actor,workflow.warehouseItems(row),LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||||
fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion();
|
fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion();
|
||||||
row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(departed.shippedPackages()));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(Math.addExact(departed.shippedPackages(),workflow.pickedRemotePackages(row))));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||||
if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败");
|
if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败");
|
||||||
if(repository.history(IdWorker.getId(),row,Action.DEPART.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"departure",departed)),row.getUpdatedAt())!=1)throw conflict("发车历史未保存");
|
if(repository.history(IdWorker.getId(),row,Action.DEPART.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"departure",departed)),row.getUpdatedAt())!=1)throw conflict("发车历史未保存");
|
||||||
var result=new CommandResult(number,Action.DEPART,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.DELIVERING,at,Effect.APPLIED,
|
var result=new CommandResult(number,Action.DEPART,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.DELIVERING,at,Effect.APPLIED,
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference;
|
||||||
|
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.BusinessSource;
|
||||||
|
import com.baiyue.lastleg.dispatch.repository.LooseboardCargoRepository;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** 排车展示商业原单,履约编号不冒充订单号、入仓码或集装箱号。 */
|
||||||
|
@Service
|
||||||
|
public class DispatchLooseboardDisplayService {
|
||||||
|
private final LooseboardCargoRepository repository;
|
||||||
|
/** 展示关联只读取同客户的权威业务原单。 */
|
||||||
|
public DispatchLooseboardDisplayService(LooseboardCargoRepository repository) { this.repository=repository; }
|
||||||
|
/** 一批读取已有散板来源,历史排车也能显示原单,无需改写运输事实。 */
|
||||||
|
public List<BusinessSource> sources(List<Reference> references) { return repository.businessSources(references.stream().distinct().toList()); }
|
||||||
|
/** 计划ID和履约单号均限定同客户,不按编号前缀猜测身份。 */
|
||||||
|
public BusinessSource find(List<BusinessSource> rows,String customer,String number,Long pallet) {
|
||||||
|
return rows.stream().filter(r->Objects.equals(customer,r.customerId()) &&
|
||||||
|
((number!=null && Objects.equals(number,r.fulfillmentNo())) || (pallet!=null && Objects.equals(pallet,r.planPalletId())))).findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
/** 只替换候选展示字段,原selection、版本、占用和来源键原样保留。 */
|
||||||
|
public List<Candidate> candidates(List<Candidate> rows) {
|
||||||
|
var references=new ArrayList<Reference>();
|
||||||
|
for(var row:rows) {
|
||||||
|
references.add(new Reference(row.customerId(),row.orderNo(),row.selection().planPalletId()));
|
||||||
|
if(row.selectionGroup()!=null)for(var member:row.selectionGroup().sources())
|
||||||
|
references.add(new Reference(member.customerId(),null,member.selection().planPalletId()));
|
||||||
|
}
|
||||||
|
var identities=sources(references);var result=new ArrayList<Candidate>();
|
||||||
|
for(var row:rows) {
|
||||||
|
var business=find(identities,row.customerId(),row.orderNo(),row.selection().planPalletId());
|
||||||
|
var group=row.selectionGroup();
|
||||||
|
if(group!=null)group=new SelectionGroup(group.groupKey(),group.groupVersion(),group.sources().stream().map(member->{
|
||||||
|
var source=find(identities,member.customerId(),null,member.selection().planPalletId());
|
||||||
|
return source==null?member:new SelectionGroupSource(member.selection(),member.sourceKey(),member.customerId(),member.customerName(),"LOCAL_LOOSEBOARD",source.orderNo(),member.packages());
|
||||||
|
}).toList(),group.pallets(),group.complete(),group.blockReasons());
|
||||||
|
result.add(new Candidate(row.selection(),row.sourceKey(),row.sourceVersion(),row.customerId(),row.customerName(),row.warehouseId(),row.warehouseCode(),row.warehouseName(),
|
||||||
|
business==null?row.orderNo():business.orderNo(),business==null?row.inboundCode():null,row.planNo(),row.palletNo(),business==null?row.containerNo():null,row.destination(),row.availablePackages(),
|
||||||
|
row.suggestedWeightKg(),row.suggestedVolumeCbm(),row.measurementSource(),row.appointmentBatches(),row.blockReasons(),group));
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||||
|
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
||||||
|
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.*;
|
||||||
|
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||||
|
import com.baiyue.lastleg.fleet.trip.*;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import java.time.*;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
|
import static com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.*;
|
||||||
|
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||||
|
|
||||||
|
/** 纯散板直接以取货和送达照片履约;混排只补散板证据,不代替其他货物出库签收。 */
|
||||||
|
@Service
|
||||||
|
public class DispatchLooseboardExecutionService {
|
||||||
|
public record Save(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||||
|
@NotNull @Size(max=20) List<@NotNull @Positive Long> fileIds) {}
|
||||||
|
public record Item(String itemId,String customerId,String orderNo,String destination,int packages,List<File> pickupPhotos,
|
||||||
|
List<File> arrivalPhotos,boolean canPickup,boolean canArrive) {}
|
||||||
|
public record Detail(String dispatchNo,long recordVersion,String stage,boolean direct,List<Item> items) {}
|
||||||
|
public record Download(File file,byte[] bytes) {}
|
||||||
|
private final DispatchAggregateService workflow;
|
||||||
|
private final DispatchAggregateRepository repository;
|
||||||
|
private final DispatchLooseboardFacts facts;
|
||||||
|
private final DispatchEvidenceGateway files;
|
||||||
|
private final DispatchOutboundPort outbounds;
|
||||||
|
private final FleetTripExecutionService fleet;
|
||||||
|
private final FleetTripCompletionService completion;
|
||||||
|
private final DispatchDeliveryPort deliveries;
|
||||||
|
private final LooseboardTransportPort orders;
|
||||||
|
private final DispatchCommandHasher hashes;
|
||||||
|
private final ObjectMapper json;
|
||||||
|
/** 只调用各域正式入口,照片不会生成库存、实托或拣货事实。 */
|
||||||
|
public DispatchLooseboardExecutionService(DispatchAggregateService workflow,DispatchAggregateRepository repository,DispatchLooseboardFacts facts,
|
||||||
|
DispatchEvidenceGateway files,DispatchOutboundPort outbounds,FleetTripExecutionService fleet,FleetTripCompletionService completion,
|
||||||
|
DispatchDeliveryPort deliveries,LooseboardTransportPort orders,DispatchCommandHasher hashes,ObjectMapper json){
|
||||||
|
this.workflow=workflow;this.repository=repository;this.facts=facts;this.files=files;this.outbounds=outbounds;this.fleet=fleet;
|
||||||
|
this.completion=completion;this.deliveries=deliveries;this.orders=orders;this.hashes=hashes;this.json=json;
|
||||||
|
}
|
||||||
|
/** 当前客户明细由原车读取,前端不传客户或原单号。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public Detail get(Actor actor,String number){workflow.preview(actor,number);return detail(required(number));}
|
||||||
|
/** 上传不等于保存或运输完成,只接受本车对应节点图片。 */
|
||||||
|
public File upload(Actor actor,String number,long itemId,Phase phase,MultipartFile file){
|
||||||
|
var value=get(actor,number);var item=value.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow(()->conflict("散板明细不属于本车"));
|
||||||
|
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
|
||||||
|
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
|
||||||
|
}
|
||||||
|
/** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */
|
||||||
|
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||||
|
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
|
||||||
|
workflow.lockForArrangement(actor,number);var row=required(number);
|
||||||
|
String hash=hashes.source(Arrays.asList(number,itemId,phase,request));
|
||||||
|
var history=facts.history(row.getId());var replay=history.stream().filter(e->e.operatorId().equals(actor.operatorId()) && e.idempotencyKey().equals(request.idempotencyKey())).findFirst();
|
||||||
|
if(replay.isPresent()){
|
||||||
|
if(!replay.get().requestHash().equals(hash))throw conflict("相同命令标识不能修改散板照片内容");
|
||||||
|
return detail(row);
|
||||||
|
}
|
||||||
|
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试");
|
||||||
|
var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车"));
|
||||||
|
var current=detail(row);var item=current.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow();
|
||||||
|
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片");
|
||||||
|
if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
|
||||||
|
var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
|
||||||
|
if(request.fileIds().isEmpty() && previous.isEmpty())throw conflict("请先选择照片");
|
||||||
|
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && !item.arrivalPhotos().isEmpty()))throw conflict("已送达或已有送达照片的节点至少保留一张凭证,可先追加新照片再删除旧照片");
|
||||||
|
var old=facts.history(row.getId());
|
||||||
|
boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
|
||||||
|
boolean arrivalRecorded=old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
|
||||||
|
if(old.stream().filter(e->!matches(e,cargo) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
|
||||||
|
var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList();
|
||||||
|
if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
|
||||||
|
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||||
|
var evidence=new Evidence(Long.toString(itemId),cargo.item().getCustomerId(),cargo.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
|
||||||
|
long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||||
|
if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败");
|
||||||
|
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
|
||||||
|
if(current.direct() && "LOADING".equals(row.getStage()) && phase==Phase.PICKUP && facts.complete(row,Phase.PICKUP))depart(actor,row,at);
|
||||||
|
if(!current.direct() && phase==Phase.PICKUP && !pickupRecorded && !photos.isEmpty() && facts.remoteItemIds(row.getId()).contains(itemId)){
|
||||||
|
workflow.requireRemotePickupCapacity(row);
|
||||||
|
row.setShippedPackageCount(Math.addExact(row.getShippedPackageCount(),cargo.item().getPackageCount()));
|
||||||
|
orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),0);
|
||||||
|
workflow.releaseSources(row,actor,List.of(cargo.item()),row.getUpdatedAt());
|
||||||
|
transition(row,current.stage(),at,actor,"混排异地散板实际取货照片已保存");
|
||||||
|
}
|
||||||
|
if(phase==Phase.ARRIVAL && !photos.isEmpty()){
|
||||||
|
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo()));
|
||||||
|
if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
|
||||||
|
transition(row,"DELIVERED",at,actor,"散板全部送达照片已保存");
|
||||||
|
workflow.releaseSources(row,actor,repository.cargo(row.getId()),row.getUpdatedAt());
|
||||||
|
completion.completeDelivered(fleet.dispatchIdentity(number).tripNo(),actor.operatorId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 已持整车父锁,可普通读已提交签收事实;先签收后补照片也能完成,不伪造新派送版本信号。
|
||||||
|
if(!current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
|
||||||
|
var normal=deliveries.normalDeliveryFacts(workflow.preview(actor,number));
|
||||||
|
if(normal.allNormallySigned()){
|
||||||
|
transition(row,"DELIVERED",at,actor,"混排全部货物签收且散板取货、送达照片齐全");
|
||||||
|
completion.completeDelivered(fleet.dispatchIdentity(number).tripNo(),actor.operatorId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detail(row);
|
||||||
|
}
|
||||||
|
/** 下载只允许当前明细已保存的节点照片,未保存上传不授权读取。 */
|
||||||
|
public Download content(Actor actor,String number,long itemId,Phase phase,long fileId){
|
||||||
|
var value=get(actor,number);var item=value.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow(()->conflict("散板明细不存在"));
|
||||||
|
var bound=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
|
||||||
|
if(bound.stream().noneMatch(f->f.fileId()==fileId))throw conflict("照片未绑定本明细节点");
|
||||||
|
return new Download(bound.stream().filter(f->f.fileId()==fileId).findFirst().orElseThrow(),files.download(required(number).getId(),kind(phase),fileId));
|
||||||
|
}
|
||||||
|
/** 纯散板保持审核/司机/车辆和预约校验,撤销旧版本预生成但从未执行的出库单。 */
|
||||||
|
private void depart(Actor actor,DispatchAggregate row,Instant at){
|
||||||
|
if(!"LOADING".equals(row.getStage()) || !"APPROVED".equals(row.getReviewState()))throw conflict("审核通过后才能完成取货");
|
||||||
|
workflow.verifyReviewResources(row);workflow.requirePhotoDeparture(row);
|
||||||
|
var cargo=facts.cargo(row.getId());var resources=workflow.preview(actor,row.getDispatchNo()).resources();
|
||||||
|
if(row.getPalletCount()>resources.vehicle().capacity().palletSpaces())throw conflict("散板申报托数超过本车托位");
|
||||||
|
var weight=cargo.stream().map(c->c.item().getGrossWeightKg()).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add);
|
||||||
|
var volume=cargo.stream().map(c->c.item().getVolumeCbm()).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add);
|
||||||
|
if(weight.compareTo(resources.vehicle().capacity().maxLoadKg())>0 || volume.compareTo(resources.vehicle().capacity().maxVolumeCbm())>0)throw conflict("散板申报货量超过本车载重或容积");
|
||||||
|
outbounds.revoke(row.getId());
|
||||||
|
fleet.departPhotoDispatchShell(row.getDispatchNo(),actor.operatorId(),at,"纯散板取货照片齐全,直接派送");
|
||||||
|
row.setShippedPackageCount(row.getPackageCount());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||||
|
transition(row,"DELIVERING",at,actor,"纯散板取货完成,无仓内拣货出库");
|
||||||
|
for(var c:cargo)orders.progress(c.item().getCustomerId(),c.orderNo(),actor.operatorId(),0);
|
||||||
|
}
|
||||||
|
/** 照片履约阶段独立记历史,不伪造装车、扫码或库存扣减事实。 */
|
||||||
|
private void transition(DispatchAggregate row,String stage,Instant at,Actor actor,String reason){
|
||||||
|
long old=row.getRecordVersion();row.setStage(stage);if("DELIVERED".equals(stage))row.setDeliveryCompletedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||||
|
row.setRecordVersion(old+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||||
|
if(repository.saveState(row,old)!=1 || repository.history(IdWorker.getId(),row,"LOOSEBOARD_"+stage,actor.operatorId(),reason,write(row),row.getUpdatedAt())!=1)throw conflict("散板运输状态保存失败");
|
||||||
|
}
|
||||||
|
/** 混排装车确认后即可逐票取送;照片不代替仓内装车资料齐全后的正式发运。 */
|
||||||
|
private Detail detail(DispatchAggregate row){
|
||||||
|
boolean direct=facts.direct(row);var evidence=facts.current(row);boolean active="ACTIVE".equals(row.getStatus());
|
||||||
|
var items=facts.cargo(row.getId()).stream().map(c->{
|
||||||
|
var pickup=evidence.stream().filter(e->e.phase()==Phase.PICKUP && matches(e,c)).flatMap(e->e.files().stream()).toList();
|
||||||
|
var arrival=evidence.stream().filter(e->e.phase()==Phase.ARRIVAL && matches(e,c)).flatMap(e->e.files().stream()).toList();
|
||||||
|
boolean mixedPhotoStage=!direct && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage());
|
||||||
|
boolean canPickup=active && (Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):mixedPhotoStage));
|
||||||
|
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && !pickup.isEmpty();
|
||||||
|
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive);
|
||||||
|
}).toList();return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items);
|
||||||
|
}
|
||||||
|
/** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */
|
||||||
|
private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;}
|
||||||
|
/** 节点文件严格使用独立业务类别。 */
|
||||||
|
private static DispatchEvidenceGateway.Kind kind(Phase phase){return phase==Phase.PICKUP?DispatchEvidenceGateway.Kind.LOOSEBOARD_PICKUP:DispatchEvidenceGateway.Kind.LOOSEBOARD_ARRIVAL;}
|
||||||
|
/** 序列化失败随业务状态回滚。 */
|
||||||
|
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("散板履约快照保存失败",e);}}
|
||||||
|
/** 并发与业务条件不满足统一拒绝。 */
|
||||||
|
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.entity.*;
|
||||||
|
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference;
|
||||||
|
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File;
|
||||||
|
import com.baiyue.lastleg.dispatch.repository.*;
|
||||||
|
import com.baiyue.lastleg.fleet.trip.FleetCompletionModels;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** 独立散板按真实关联识别;纯散板照片履约与混排仓内履约分别核对。 */
|
||||||
|
@Service
|
||||||
|
public class DispatchLooseboardFacts {
|
||||||
|
public enum Phase { PICKUP, ARRIVAL }
|
||||||
|
public record Cargo(DispatchItem item,String orderNo) {}
|
||||||
|
public record Evidence(String itemId,String customerId,String orderNo,Phase phase,String cargoHash,long reviewRound,
|
||||||
|
List<File> files,Instant occurredAt,String operatorId,String idempotencyKey,String requestHash) {}
|
||||||
|
private final DispatchAggregateRepository aggregates;
|
||||||
|
private final DispatchLooseboardDisplayService display;
|
||||||
|
private final DispatchLooseboardEvidenceRepository repository;
|
||||||
|
private final ObjectMapper json;
|
||||||
|
/** 事实读取不依赖执行服务,避免车队完成回调形成循环。 */
|
||||||
|
public DispatchLooseboardFacts(DispatchAggregateRepository aggregates,DispatchLooseboardDisplayService display,
|
||||||
|
DispatchLooseboardEvidenceRepository repository,ObjectMapper json){this.aggregates=aggregates;this.display=display;this.repository=repository;this.json=json;}
|
||||||
|
/** 仅精确关联的本土散板进入照片流程,其他货物不按编号前缀判断。 */
|
||||||
|
public List<Cargo> cargo(long id){
|
||||||
|
var items=aggregates.cargo(id);if(items.isEmpty())return List.of();
|
||||||
|
var identities=display.sources(items.stream().map(i->new Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
|
||||||
|
var result=new ArrayList<Cargo>();
|
||||||
|
for(var item:items){var source=display.find(identities,item.getCustomerId(),item.getOrderNo(),item.getPlanPalletId());if(source!=null)result.add(new Cargo(item,source.orderNo()));}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
/** 全车确为散板且没有真实扫码才可跳过仓库出库;历史已出库事实不能抹除。 */
|
||||||
|
public boolean direct(DispatchAggregate row){
|
||||||
|
var cargo=cargo(row.getId());return !cargo.isEmpty() && cargo.size()==aggregates.cargo(row.getId()).size() && cargo.stream().allMatch(c->c.item().getWarehousePalletId()==null)
|
||||||
|
&& Objects.requireNonNullElse(row.getPickedPackageCount(),0)==0 && aggregates.realPicked(row.getId())==0;
|
||||||
|
}
|
||||||
|
/** 尚无实托的本土散板在异地取货,不进入起运仓PDA和仓库出库数量。 */
|
||||||
|
public Set<Long> remoteItemIds(long dispatchId){
|
||||||
|
var items=aggregates.cargo(dispatchId);var sources=display.sources(items.stream().map(i->new Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
|
||||||
|
return items.stream().filter(i->i.getWarehousePalletId()==null).filter(i->{var source=display.find(sources,i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId());return source!=null && "WAREHOUSE_PICKUP".equals(source.deliveryMode());})
|
||||||
|
.map(DispatchItem::getId).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
/** 仓内作业保留全部普通来源及已存在真实实托的历史散板。 */
|
||||||
|
public List<DispatchItem> warehouseItems(long dispatchId){var remote=remoteItemIds(dispatchId);return aggregates.cargo(dispatchId).stream().filter(i->!remote.contains(i.getId())).toList();}
|
||||||
|
/** 混排实际发运量只在保存本票取货照片后包含异地货物,不提前登记已装车。 */
|
||||||
|
public long pickedRemotePackages(DispatchAggregate row){var remote=remoteItemIds(row.getId());var evidence=history(row.getId()).stream().filter(e->Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound()).toList();return cargo(row.getId()).stream()
|
||||||
|
.filter(c->remote.contains(c.item().getId()) && evidence.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,c) && !e.files().isEmpty()))
|
||||||
|
.mapToLong(c->c.item().getPackageCount()).sum();}
|
||||||
|
/** 命令历史只读,JSON损坏明确失败,不静默当作照片缺失。 */
|
||||||
|
public List<Evidence> history(long id){return repository.snapshots(id).stream().map(value->{
|
||||||
|
try{return json.readValue(value,Evidence.class);}catch(Exception error){throw new IllegalStateException("散板照片履约历史读取失败",error);}
|
||||||
|
}).toList();}
|
||||||
|
/** 退回重审或换货后,旧照片仍在历史但不再代替本轮执行证据。 */
|
||||||
|
public List<Evidence> current(DispatchAggregate row){
|
||||||
|
var latest=new LinkedHashMap<String,Evidence>();
|
||||||
|
history(row.getId()).stream().filter(e->Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound())
|
||||||
|
.forEach(e->latest.put(e.itemId()+":"+e.phase(),e));
|
||||||
|
return List.copyOf(latest.values());
|
||||||
|
}
|
||||||
|
/** 每一票都须有自己的有效照片,不能用全车总数代替。 */
|
||||||
|
public boolean complete(DispatchAggregate row,Phase phase){
|
||||||
|
var cargo=cargo(row.getId());var evidence=current(row);
|
||||||
|
return !cargo.isEmpty() && cargo.stream().allMatch(c->evidence.stream().anyMatch(e->e.phase()==phase && matches(e,c) && !e.files().isEmpty()));
|
||||||
|
}
|
||||||
|
/** 精确校验客户、明细和商业原单三者,不允许跨票复用照片。 */
|
||||||
|
public static boolean matches(Evidence e,Cargo c){return e.itemId().equals(c.item().getId().toString()) && e.customerId().equals(c.item().getCustomerId()) && e.orderNo().equals(c.orderNo());}
|
||||||
|
/** 车队结案使用散板送达照片事实,无需制造客户出库或派送子单。 */
|
||||||
|
public List<FleetCompletionModels.Outcome> outcomes(DispatchAggregate row){
|
||||||
|
var evidence=current(row);return cargo(row.getId()).stream().map(c->{
|
||||||
|
var at=evidence.stream().filter(e->e.phase()==Phase.ARRIVAL && matches(e,c) && !e.files().isEmpty()).map(Evidence::occurredAt).max(Instant::compareTo).orElse(null);
|
||||||
|
int packages=c.item().getPackageCount();
|
||||||
|
return new FleetCompletionModels.Outcome(row.getDispatchNo(),c.orderNo(),c.item().getCustomerId(),
|
||||||
|
Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),at==null?"DELIVERING":"DELIVERED",packages,at,at==null?0:packages,0,at==null?packages:0);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
/** 分车运输按每条有照片的明细去重累计。 */
|
||||||
|
public long deliveredPackages(String customer,String order){return repository.deliveredPackages(customer,order);}
|
||||||
|
}
|
||||||
+4
-3
@@ -41,7 +41,7 @@ public class DispatchPdaQueryService {
|
|||||||
var byItem=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId));
|
var byItem=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId));
|
||||||
var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of();
|
var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of();
|
||||||
for(var root:roots) {
|
for(var root:roots) {
|
||||||
var items=cargo.getOrDefault(root.getId(),List.of());var detail=new ArrayList<DispatchViews.PdaItem>();
|
var remote=workflow.remoteItemIds(root);var items=cargo.getOrDefault(root.getId(),List.of()).stream().filter(i->!remote.contains(i.getId())).toList();var detail=new ArrayList<DispatchViews.PdaItem>();
|
||||||
boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
|
boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
|
||||||
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
|
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
|
||||||
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used;
|
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used;
|
||||||
@@ -77,13 +77,14 @@ public class DispatchPdaQueryService {
|
|||||||
.map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList();
|
.map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList();
|
||||||
detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons,item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo()));
|
detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons,item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo()));
|
||||||
}
|
}
|
||||||
boolean complete=sourceValid && n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0
|
long warehousePackages=items.stream().mapToLong(i->n(i.getPackageCount())).sum();
|
||||||
|
boolean complete=sourceValid && warehousePackages>0 && n(root.getPickedPackageCount())==warehousePackages && invalid.getOrDefault(root.getId(),0L)==0
|
||||||
&& !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount()));
|
&& !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount()));
|
||||||
var actions=new EnumMap<Action,Gate>(workflow.actions(actor,root));
|
var actions=new EnumMap<Action,Gate>(workflow.actions(actor,root));
|
||||||
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
|
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
|
||||||
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
|
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
|
||||||
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
||||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
var counts=new Counts(items.size(),items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().count(),warehousePackages,n(root.getPickedPackageCount()),root.getDepartedAt()==null?0:warehousePackages);
|
||||||
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
|
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
|
||||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions),
|
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions),
|
||||||
root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),
|
root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),
|
||||||
|
|||||||
+6
-2
@@ -23,6 +23,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
|||||||
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
|
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
|
||||||
@Service
|
@Service
|
||||||
public class DispatchPickingService {
|
public class DispatchPickingService {
|
||||||
|
@jakarta.annotation.Resource private DispatchOutboundPort outboundWorkflow;
|
||||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||||
@@ -43,6 +44,7 @@ public class DispatchPickingService {
|
|||||||
var context=workflow.lockForArrangement(actor,number);var row=repository.lock(number);
|
var context=workflow.lockForArrangement(actor,number);var row=repository.lock(number);
|
||||||
var identity=new CommandIdentity(Action.PICK,request.idempotencyKey(),request.recordVersion(),hashes.command(actor,Action.PICK,number,request.recordVersion(),Arrays.asList(itemId,request)));
|
var identity=new CommandIdentity(Action.PICK,request.idempotencyKey(),request.recordVersion(),hashes.command(actor,Action.PICK,number,request.recordVersion(),Arrays.asList(itemId,request)));
|
||||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||||
|
if(workflow.photoOnly(row))throw conflict("纯散板无需PDA拣货,请在排车管理资料上传取货照片");
|
||||||
if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING
|
if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING
|
||||||
|| context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码");
|
|| context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码");
|
||||||
workflow.requireReviewAppointment(row);
|
workflow.requireReviewAppointment(row);
|
||||||
@@ -52,7 +54,8 @@ public class DispatchPickingService {
|
|||||||
var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
|
var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
|
||||||
if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓");
|
if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓");
|
||||||
if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货");
|
if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货");
|
||||||
relabelSources.validateFrozen(row,currentItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),currentItems));
|
var warehouseItems=workflow.warehouseItems(row);
|
||||||
|
relabelSources.validateFrozen(row,warehouseItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),warehouseItems));
|
||||||
var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC);
|
var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC);
|
||||||
if(task==null) {
|
if(task==null) {
|
||||||
task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId());
|
task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId());
|
||||||
@@ -100,7 +103,8 @@ public class DispatchPickingService {
|
|||||||
row.setPalletCount(Math.toIntExact(currentItems.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
row.setPalletCount(Math.toIntExact(currentItems.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||||
if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败");
|
if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败");
|
||||||
boolean complete=workflow.preview(actor,number).pickingReconciled();
|
var actual=workflow.preview(actor,number);boolean complete=actual.pickingReconciled();
|
||||||
|
if(complete && outboundWorkflow!=null)outboundWorkflow.complete(actual);
|
||||||
if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存");
|
if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存");
|
||||||
var result=new CommandResult(number,Action.PICK,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.LOADING,at.toInstant(ZoneOffset.UTC),Effect.APPLIED,
|
var result=new CommandResult(number,Action.PICK,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.LOADING,at.toInstant(ZoneOffset.UTC),Effect.APPLIED,
|
||||||
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
|
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
|
||||||
|
|||||||
+1
-1
@@ -400,7 +400,7 @@ public class DispatchService {
|
|||||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
|
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
|
||||||
var orders=executionOrders(warehouse,items,pallets);
|
var orders=executionOrders(warehouse,items,pallets);
|
||||||
orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");});
|
orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");});
|
||||||
palletSerial.lock(List.copyOf(pallets));
|
if(!pallets.isEmpty())palletSerial.lock(List.copyOf(pallets));
|
||||||
if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对");
|
if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对");
|
||||||
for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList())
|
for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList())
|
||||||
if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化");
|
if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化");
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.port.*;
|
||||||
|
import com.baiyue.lastleg.dispatch.repository.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
|
||||||
|
@Service
|
||||||
|
public class LooseboardCargoService {
|
||||||
|
private final LooseboardCargoRepository cargo;
|
||||||
|
private final DispatchAggregateRepository dispatches;
|
||||||
|
private final DispatchAggregateService service;
|
||||||
|
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) {
|
||||||
|
this.cargo=cargo; this.dispatches=dispatches; this.service=service;
|
||||||
|
}
|
||||||
|
private String warehouse(String number) {
|
||||||
|
var row=dispatches.find(number);
|
||||||
|
if(row==null || !Boolean.TRUE.equals(row.getPublishLooseboardDemand()) || !"DELIVERY".equals(row.getTaskType()) || !"ACTIVE".equals(row.getStatus()) || row.getDepartedAt()!=null)
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
|
||||||
|
return row.getOverseasWarehouseId();
|
||||||
|
}
|
||||||
|
@Transactional(readOnly=true)
|
||||||
|
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
|
||||||
|
String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse);
|
||||||
|
if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0);
|
||||||
|
if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理");
|
||||||
|
return service.candidates(actor,new DispatchPlanningModels.CandidateQuery(warehouse,plans,null,null,null,null,page,size,"ALL"));
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) {
|
||||||
|
String warehouse=warehouse(number); Set<Long> allowed=Set.copyOf(cargo.readyPallets(warehouse));
|
||||||
|
if(request.cargo()==null || request.cargo().isEmpty() || request.cargo().stream().anyMatch(s->s==null || s.sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN || s.planPalletId()==null))
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择已审核散板的申报或实收货物");
|
||||||
|
// 正式来源解析再次校验计划、客户、库存、整托闭包和来源版本。
|
||||||
|
if(request.cargo().stream().anyMatch(s->!allowed.contains(s.planPalletId())))throw new ResponseStatusException(HttpStatus.CONFLICT,"散板审核、收货或占用状态已变化,请重新选货");
|
||||||
|
return service.addCargo(actor,number,request);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -16,11 +16,14 @@ public final class TransportTaskModels {
|
|||||||
@NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
@NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||||
public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||||
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
|
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
|
||||||
|
public record Cargo(int palletCount,int packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm,
|
||||||
|
String origin,String originAddress,String originContactName,String originContactPhone,
|
||||||
|
String destination,String destinationAddress,String destinationContactName,String destinationContactPhone) { }
|
||||||
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
|
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
|
||||||
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
|
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
|
||||||
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> departureEvidence,
|
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> departureEvidence,
|
||||||
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
|
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
|
||||||
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { }
|
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources,Cargo cargo) { }
|
||||||
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
|
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
|
||||||
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
|
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
|
||||||
@Min(0) Long dispatchRecordVersion,
|
@Min(0) Long dispatchRecordVersion,
|
||||||
|
|||||||
+5
-1
@@ -101,12 +101,16 @@ public class TransportTaskService {
|
|||||||
var departureIds=commandEvidence.isEmpty()?List.<Long>of():evidenceJson(commandEvidence.get(0));
|
var departureIds=commandEvidence.isEmpty()?List.<Long>of():evidenceJson(commandEvidence.get(0));
|
||||||
var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList()
|
var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList()
|
||||||
: "COMPLETED".equals(trip.getStatus()) ? departureIds : List.<Long>of();
|
: "COMPLETED".equals(trip.getStatus()) ? departureIds : List.<Long>of();
|
||||||
|
var cargo=link==null?null:trucks.findNo(link.customerId(),link.orderNo());
|
||||||
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
|
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(),
|
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
|
||||||
"LOCAL_TRUCK".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) ? metadata(trip.getId(),departureIds) :
|
"LOCAL_TRUCK".equals(row.getTaskType()) && "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.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),
|
"LOCAL_TRUCK".equals(row.getTaskType()) && "COMPLETED".equals(trip.getStatus()) && commandEvidence.size()>1 ? metadata(trip.getId(),departureIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),
|
||||||
"LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),row.getRecordVersion(),
|
"LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),row.getRecordVersion(),
|
||||||
reviewPhotos.resources(row));
|
reviewPhotos.resources(row),cargo==null?null:new TransportTaskModels.Cargo(
|
||||||
|
cargo.getPalletCount()==null?0:cargo.getPalletCount(),cargo.getPackageCount()==null?0:cargo.getPackageCount(),cargo.getTotalWeightKg(),cargo.getTotalVolumeCbm(),
|
||||||
|
cargo.getOrigin(),cargo.getOriginAddress(),cargo.getOriginContactName(),cargo.getOriginContactPhone(),
|
||||||
|
cargo.getDestination(),cargo.getDestinationAddress(),cargo.getDestinationContactName(),cargo.getDestinationContactPhone()));
|
||||||
}
|
}
|
||||||
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
|
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
|
||||||
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
|
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);}
|
||||||
|
|||||||
+32
@@ -23,6 +23,19 @@ import java.util.List;
|
|||||||
@RequestMapping("/api/platform/v1/last-leg")
|
@RequestMapping("/api/platform/v1/last-leg")
|
||||||
public class ExceptionOrderController {
|
public class ExceptionOrderController {
|
||||||
@Resource private ExceptionOrderService service;
|
@Resource private ExceptionOrderService service;
|
||||||
|
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionReturnInboundService returns;
|
||||||
|
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionReturnPlanService returnPlans;
|
||||||
|
|
||||||
|
/** 异常退回无来源柜,按本次到仓时间建立关联尾程的独立计划。 */
|
||||||
|
@PostMapping("/exception-orders/return-inbound-plans")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
|
||||||
|
public ApiResponse<com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse> createReturnPlan(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody com.baiyue.lastleg.exceptionorder.dto.ExceptionReturnPlanRequest request) {
|
||||||
|
requiredScope(user,request.plan().customerId());
|
||||||
|
if(user.tenantId()!=0L||!"PLATFORM".equals(user.appCode()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台账号可创建异常入库计划");
|
||||||
|
return ApiResponse.success(returnPlans.create(request,key,operatorId(user)));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/exception-orders")
|
@GetMapping("/exception-orders")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')")
|
||||||
@@ -63,6 +76,16 @@ public class ExceptionOrderController {
|
|||||||
return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file));
|
return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record GeneratedReturn(String orderNo,String status,String sourceOrderNo) {}
|
||||||
|
/** 生成退回尾程单后由PDA退货入库列表接收,建单不恢复库存。 */
|
||||||
|
@PostMapping("/exception-orders/{exceptionNo}/generate-return-inbound")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
|
||||||
|
public ApiResponse<?> generateReturn(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String customerId,
|
||||||
|
@PathVariable String exceptionNo,@Valid @RequestBody ExceptionOrderRequests.StartProcessing request) {
|
||||||
|
var tail=returns.generate(requiredScope(user,customerId),operatorId(user),exceptionNo,request);
|
||||||
|
return ApiResponse.success(new GeneratedReturn(tail.getOrderNo(),tail.getCurrentStatus(),tail.getSourceOrderNo()));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/exception-orders/{exceptionNo}/start-processing")
|
@PostMapping("/exception-orders/{exceptionNo}/start-processing")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
|
||||||
public ApiResponse<ExceptionOrderResponses.CommandResult> startProcessing(
|
public ApiResponse<ExceptionOrderResponses.CommandResult> startProcessing(
|
||||||
@@ -79,6 +102,15 @@ public class ExceptionOrderController {
|
|||||||
return ApiResponse.success(service.complete(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
|
return ApiResponse.success(service.complete(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 选择其他并填写原因后人工完结,仅处理异常工单状态。 */
|
||||||
|
@PostMapping("/exception-orders/{exceptionNo}/force-close")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
|
||||||
|
public ApiResponse<ExceptionOrderResponses.CommandResult> forceClose(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.ForceClose request) {
|
||||||
|
return ApiResponse.success(service.forceClose(requiredScope(user, customerId), operatorId(user), exceptionNo, request));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/exception-orders/{exceptionNo}/cancel")
|
@PostMapping("/exception-orders/{exceptionNo}/cancel")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
|
||||||
public ApiResponse<ExceptionOrderResponses.CommandResult> cancel(
|
public ApiResponse<ExceptionOrderResponses.CommandResult> cancel(
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.baiyue.lastleg.exceptionorder.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.service.ExceptionReturnInboundService;
|
||||||
|
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/** PDA异常退货沿退货入库路由开放,现场只点选当前仓任务。 */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/pda/v1/last-leg/return-receipts/exceptions")
|
||||||
|
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||||
|
public class PdaExceptionReturnController {
|
||||||
|
private final ExceptionReturnInboundService service;
|
||||||
|
/** 装配异常验收业务入口。 */
|
||||||
|
public PdaExceptionReturnController(ExceptionReturnInboundService service) { this.service=service; }
|
||||||
|
/** 当前仓异常号、车牌和剩余数量列表。 */
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<?> tasks(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.tasks(PdaWarehouseIdentity.require(user))); }
|
||||||
|
/** 点击开始作业后加载冻结货物和最新版本。 */
|
||||||
|
@PostMapping("/{number}/start")
|
||||||
|
public ApiResponse<?> start(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number) { return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user),number)); }
|
||||||
|
/** 上传本次实收证据。 */
|
||||||
|
@PostMapping(value="/{number}/evidence",consumes="multipart/form-data")
|
||||||
|
public ApiResponse<?> evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@RequestPart("file") MultipartFile file) { return ApiResponse.success(service.upload(PdaWarehouseIdentity.require(user),number,file)); }
|
||||||
|
/** 分批验收,原来源数量和仓库资格由服务端复核。 */
|
||||||
|
@PostMapping("/{number}/receive")
|
||||||
|
public ApiResponse<?> receive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody ExceptionReturnInboundService.Receipt request) { return ApiResponse.success(service.receive(PdaWarehouseIdentity.require(user),number,request)); }
|
||||||
|
}
|
||||||
+5
@@ -59,4 +59,9 @@ public final class ExceptionOrderRequests {
|
|||||||
public record Cancel(@Min(0) long expectedVersion,
|
public record Cancel(@Min(0) long expectedVersion,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
@NotBlank @Size(max = 1000) String reason) {}
|
@NotBlank @Size(max = 1000) String reason) {}
|
||||||
|
|
||||||
|
/** 其他处理方式必须保留人工完结原因,不提交库存实收或文件。 */
|
||||||
|
public record ForceClose(@Min(0) long expectedVersion,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@NotBlank @Size(max = 1000) String reason) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -28,7 +28,10 @@ public final class ExceptionOrderResponses {
|
|||||||
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId,
|
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId,
|
||||||
String palletNo, String skuCode, long maxReturnPackages,
|
String palletNo, String skuCode, long maxReturnPackages,
|
||||||
Long returnCartonId, String cartonMark) {}
|
Long returnCartonId, String cartonMark) {}
|
||||||
public record Detail(Row order, List<ReturnSource> returnSources, List<ReturnReceipt> returnReceipts) {}
|
public record Detail(Row order, List<ReturnSource> returnSources, List<ReturnReceipt> returnReceipts, String returnOrderNo) {
|
||||||
|
/** 历史调用方没有退回尾程关系时保持空值。 */
|
||||||
|
public Detail(Row order,List<ReturnSource> sources,List<ReturnReceipt> receipts) { this(order,sources,receipts,null); }
|
||||||
|
}
|
||||||
public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt,
|
public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt,
|
||||||
List<Redispatch> redispatches) {}
|
List<Redispatch> redispatches) {}
|
||||||
public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages,
|
public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages,
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.baiyue.lastleg.exceptionorder.dto;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 异常退回按本单填写到仓时间,不继承原头程柜的日期。 */
|
||||||
|
public record ExceptionReturnPlanRequest(@NotNull @Valid ManualInboundPlanRequest plan,
|
||||||
|
@NotNull LocalDateTime expectedArrivalAt) { }
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.lastleg.exceptionorder.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 异常退回任务仅查询本服务权威单据,不接受客户端客户归属。 */
|
||||||
|
@Mapper
|
||||||
|
public interface ExceptionWorkflowMapper {
|
||||||
|
record Delivery(String customerId, String deliveryNo) {}
|
||||||
|
record Task(String exceptionNo, String vehiclePlate, String customerName, String status,
|
||||||
|
long packageCount, long receivedPackages, String returnOrderNo) {}
|
||||||
|
/** 本车可登记的派送单,客户跟随真实派送记录。 */
|
||||||
|
@Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id")
|
||||||
|
List<Delivery> deliveries(long id);
|
||||||
|
/** 以异常号和冻结仓锁定现场任务。 */
|
||||||
|
@Select("SELECT * FROM ll_exception_order WHERE exception_no=#{number} AND overseas_warehouse_id=#{warehouse} LIMIT 1 FOR UPDATE")
|
||||||
|
ExceptionOrder lockWarehouse(@Param("warehouse") String warehouse, @Param("number") String number);
|
||||||
|
/** 唯一来源键复用尾程单已有建单幂等约束。 */
|
||||||
|
@Select("SELECT * FROM ll_order WHERE customer_id=#{customer} AND source_region='EXCEPTION_RETURN' AND source_inbound_id=#{source} LIMIT 1")
|
||||||
|
LastlegOrder returnOrder(@Param("customer") String customer, @Param("source") String source);
|
||||||
|
/** 当前仓全部待验收异常,车牌从原派送排车获取。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT e.exception_no,p.vehicle_plate,e.customer_name,e.status,e.package_count,
|
||||||
|
COALESCE((SELECT SUM(i.received_packages) FROM ll_exception_return_item i
|
||||||
|
WHERE i.exception_order_id=e.id AND i.customer_id=e.customer_id),0) received_packages,
|
||||||
|
r.order_no return_order_no
|
||||||
|
FROM ll_exception_order e JOIN ll_order r ON r.customer_id=e.customer_id
|
||||||
|
AND r.source_region='EXCEPTION_RETURN' AND r.source_inbound_id=CAST(e.id AS CHAR)
|
||||||
|
JOIN ll_delivery_order d ON d.id=e.delivery_order_id AND d.customer_id=e.customer_id
|
||||||
|
LEFT JOIN ll_dispatch_order p ON p.id=d.dispatch_order_id
|
||||||
|
WHERE e.overseas_warehouse_id=#{warehouse} AND e.status='PROCESSING'
|
||||||
|
AND e.exception_type='RETURN' AND e.return_direction='OVERSEAS_WAREHOUSE'
|
||||||
|
ORDER BY e.exception_at,e.id LIMIT 200
|
||||||
|
""")
|
||||||
|
List<Task> tasks(String warehouse);
|
||||||
|
|
||||||
|
/** 退回计划只跟随原异常真实实收结果,不触发另一笔库存入账。 */
|
||||||
|
@Update("""
|
||||||
|
UPDATE ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id
|
||||||
|
SET p.status=CASE WHEN o.current_status='INBOUND_COMPLETED' THEN 'COMPLETED' ELSE 'PARTIAL_INBOUND' END,
|
||||||
|
p.updated_at=o.updated_at,p.version=p.version+1
|
||||||
|
WHERE o.customer_id=#{customer} AND o.id=#{tail} AND p.creation_mode='EXCEPTION_RETURN'
|
||||||
|
AND o.received_package_count>0
|
||||||
|
""")
|
||||||
|
void syncReturnPlan(@Param("customer") String customer,@Param("tail") long tail);
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.baiyue.lastleg.exceptionorder.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/** 异常退回计划与尾程关联在同一服务库、同一事务保存。 */
|
||||||
|
@Repository
|
||||||
|
public class ExceptionReturnPlanRepository {
|
||||||
|
@jakarta.annotation.Resource private LastlegOrderMapper orders;
|
||||||
|
@jakarta.annotation.Resource private LastlegOrderCommandRecordMapper commands;
|
||||||
|
@jakarta.annotation.Resource private InboundPlanMapper plans;
|
||||||
|
@jakarta.annotation.Resource private InboundPlanLineMapper lines;
|
||||||
|
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.lastleg.repository.DeliveryReturnCargoRepository cargo;
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper histories;
|
||||||
|
|
||||||
|
/** 读取真实退回货物供计划汇总体积,未知体积不猜测。 */
|
||||||
|
public java.util.List<com.baiyue.lastleg.entity.LastlegOrderDetail> cargo(String customer,long order) { return cargo.cargo(customer,order); }
|
||||||
|
/** 保存生成计划的状态轨迹,便于尾程详情追溯。 */
|
||||||
|
public void history(com.baiyue.lastleg.entity.LastlegOrderStatusHistory history) { histories.insert(history); }
|
||||||
|
/** 先读取异常身份,写入时按异常再尾程的统一锁顺序重新核验。 */
|
||||||
|
public LastlegOrder find(String customer,long id) {
|
||||||
|
return orders.selectOne(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId,customer).eq(LastlegOrder::getId,id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在客户范围锁定退回单,防止并发重复计划和跨客户访问。 */
|
||||||
|
public LastlegOrder lock(String customer, long id) {
|
||||||
|
return orders.selectOne(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId,customer)
|
||||||
|
.eq(LastlegOrder::getId,id).last("FOR UPDATE"));
|
||||||
|
}
|
||||||
|
/** 原键只允许恢复原请求结果。 */
|
||||||
|
public LastlegOrderCommandRecord command(String customer,long order,String operator,String key) {
|
||||||
|
return commands.find(customer,order,"EXCEPTION_RETURN_PLAN",operator,key);
|
||||||
|
}
|
||||||
|
/** 判断当前退回单是否已有独立计划。 */
|
||||||
|
public InboundPlan existing(String customer,long order) { return plans.findBySourceOrder(customer,order); }
|
||||||
|
/** 保存计划主单及尾程关联行,不在建计划时增加库存。 */
|
||||||
|
public void insert(InboundPlan plan,InboundPlanLine line) { plans.insert(plan);lines.insert(line); }
|
||||||
|
/** 保存到仓时间和入库单号,保留原尾程来源身份。 */
|
||||||
|
public void update(LastlegOrder order) { orders.updateById(order); }
|
||||||
|
/** 保存命令请求摘要以处理重复点击。 */
|
||||||
|
public void insert(LastlegOrderCommandRecord command) { commands.insert(command); }
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package com.baiyue.lastleg.exceptionorder.repository;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 异常退回尾程关系及仓库任务的数据访问边界。 */
|
||||||
|
@Repository
|
||||||
|
public class ExceptionWorkflowRepository {
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.lastleg.repository.DeliveryReturnCargoRepository cargo;
|
||||||
|
@jakarta.annotation.Resource private ExceptionOrderCommandMapper commands;
|
||||||
|
private final ExceptionWorkflowMapper workflow;
|
||||||
|
private final ExceptionOrderMapper exceptions;
|
||||||
|
private final LastlegOrderMapper orders;
|
||||||
|
/** 装配本服务权威单据映射器。 */
|
||||||
|
public ExceptionWorkflowRepository(ExceptionWorkflowMapper workflow, ExceptionOrderMapper exceptions, LastlegOrderMapper orders) {
|
||||||
|
this.workflow=workflow; this.exceptions=exceptions; this.orders=orders;
|
||||||
|
}
|
||||||
|
/** 查询本车派送归属。 */
|
||||||
|
public List<ExceptionWorkflowMapper.Delivery> deliveries(long id) { return workflow.deliveries(id); }
|
||||||
|
/** 在客户范围锁定异常。 */
|
||||||
|
public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); }
|
||||||
|
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
|
||||||
|
public void updateException(ExceptionOrder order) { exceptions.updateById(order); }
|
||||||
|
/** 在客户及动作范围读取人工完结的幂等结果。 */
|
||||||
|
public ExceptionOrderCommand closeCommand(String customer, String key) {
|
||||||
|
return commands.findByKey(customer, "FORCE_CLOSE", key);
|
||||||
|
}
|
||||||
|
/** 同事务保存人工完结原因与原操作结果。 */
|
||||||
|
public void insertCommand(ExceptionOrderCommand command) { commands.insert(command); }
|
||||||
|
/** 在冻结仓范围锁定异常。 */
|
||||||
|
public ExceptionOrder lockWarehouse(String warehouse, String number) { return workflow.lockWarehouse(warehouse,number); }
|
||||||
|
/** 查询原尾程单以冻结来源关系。 */
|
||||||
|
public LastlegOrder original(long id) { return orders.selectById(id); }
|
||||||
|
/** 恢复异常唯一对应的退回尾程单。 */
|
||||||
|
public LastlegOrder returnOrder(ExceptionOrder order) { return workflow.returnOrder(order.getCustomerId(),String.valueOf(order.getId())); }
|
||||||
|
/** 保存新退回尾程单。 */
|
||||||
|
public void insert(LastlegOrder order) { orders.insert(order); }
|
||||||
|
/** 将异常冻结明细投影到退回尾程单,不改变原单与库存。 */
|
||||||
|
public void copyDetails(LastlegOrder tail,ExceptionOrder exception) { cargo.copy(exception.getCustomerId(),tail.getId()); }
|
||||||
|
/** 更新退回单的真实实收进度。 */
|
||||||
|
public void update(LastlegOrder order) {
|
||||||
|
orders.updateById(order);
|
||||||
|
if("DELIVERY_RETURN".equals(order.getOrderType()))workflow.syncReturnPlan(order.getCustomerId(),order.getId());
|
||||||
|
}
|
||||||
|
/** 读取当前绑定仓待验收任务。 */
|
||||||
|
public List<ExceptionWorkflowMapper.Task> tasks(String warehouse) { return workflow.tasks(warehouse); }
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.baiyue.lastleg.exceptionorder.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchExceptionPort;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** 将排车异常原子转为客服工单,来源候选不信任客户端客户字段。 */
|
||||||
|
@Component
|
||||||
|
public class DispatchExceptionAdapter implements DispatchExceptionPort {
|
||||||
|
private final ExceptionWorkflowRepository repository;
|
||||||
|
private final ExceptionOrderService service;
|
||||||
|
/** 复用已有异常退回冻结与数量门禁。 */
|
||||||
|
public DispatchExceptionAdapter(ExceptionWorkflowRepository repository, ExceptionOrderService service) {
|
||||||
|
this.repository=repository; this.service=service;
|
||||||
|
}
|
||||||
|
/** 仅返回本车、当前仍可退回的真实来源。 */
|
||||||
|
public List<Candidate> candidates(long dispatchId) {
|
||||||
|
var result=new ArrayList<Candidate>();
|
||||||
|
for(var delivery:repository.deliveries(dispatchId)) for(var relation:service.returnCandidates(delivery.customerId(),delivery.deliveryNo()))
|
||||||
|
result.add(new Candidate(relation.customerId(),relation.customerName(),delivery.deliveryNo(),relation.lastMileOrderId(),relation.lastMileOrderNo(),
|
||||||
|
relation.returnSources().stream().map(s->new Source(s.outboundPalletItemId(),s.inventoryPositionId(),s.returnCartonId(),s.palletNo(),s.skuCode(),s.cartonMark(),s.maxReturnPackages())).toList()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/** 退回逐来源冻结;其他异常同样进入异常列表。 */
|
||||||
|
public void register(long dispatchId,String operator,DispatchCommands.MarkException request) {
|
||||||
|
var candidates=candidates(dispatchId);
|
||||||
|
var selected=request.returnSelections()==null?List.<Selection>of():request.returnSelections();
|
||||||
|
boolean warehouse=request.returnDirection()==com.baiyue.lastleg.dispatch.port.DispatchTypes.ReturnDirection.OVERSEAS_WAREHOUSE;
|
||||||
|
String reason=request.description()==null || request.description().isBlank()?"派送异常:"+request.exceptionType():request.description().trim();
|
||||||
|
if(warehouse && selected.isEmpty())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择来源订单、退回货物和数量");
|
||||||
|
if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源");
|
||||||
|
if(warehouse) {
|
||||||
|
var seen=new HashSet<String>();
|
||||||
|
for(int index=0;index<selected.size();index++) {
|
||||||
|
var selection=selected.get(index);
|
||||||
|
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 || !seen.add(selection.deliveryNo()))
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物");
|
||||||
|
var source=candidates.stream().filter(c->Objects.equals(c.deliveryNo(),selection.deliveryNo()) && Objects.equals(c.lastMileOrderId(),selection.lastMileOrderId())).findFirst()
|
||||||
|
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"所选来源不属于当前排车"));
|
||||||
|
if(selection.items().stream().anyMatch(i->i==null || i.outboundPalletItemId()==null || i.packageCount()<1))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回明细无效");
|
||||||
|
service.markReturn(source.customerId(),operator,operator,source.deliveryNo(),new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null,
|
||||||
|
selection.items().stream().map(i->new ExceptionOrderRequests.ReturnItem(i.outboundPalletItemId(),i.inventoryPositionId(),i.packageCount(),i.returnCartonId())).toList(),
|
||||||
|
"dispatch:"+dispatchId+":"+java.util.UUID.nameUUIDFromBytes(request.idempotencyKey().getBytes(java.nio.charset.StandardCharsets.UTF_8))+":"+index));
|
||||||
|
}
|
||||||
|
} else for(var source:candidates) service.create(source.customerId(),operator,operator,
|
||||||
|
new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null));
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-6
@@ -65,6 +65,7 @@ import java.util.Set;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ExceptionOrderService {
|
public class ExceptionOrderService {
|
||||||
|
@Resource private com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository workflow;
|
||||||
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
|
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
|
||||||
@Resource private ExceptionOrderMapper exceptionOrders;
|
@Resource private ExceptionOrderMapper exceptionOrders;
|
||||||
@Resource private LastlegOrderMapper lastMileOrders;
|
@Resource private LastlegOrderMapper lastMileOrders;
|
||||||
@@ -118,7 +119,7 @@ public class ExceptionOrderService {
|
|||||||
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
|
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
|
||||||
if (order == null) throw notFound("异常订单不存在");
|
if (order == null) throw notFound("异常订单不存在");
|
||||||
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order),
|
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order),
|
||||||
returnHistory(order));
|
returnHistory(order), workflow.returnOrder(order)==null?null:workflow.returnOrder(order).getOrderNo());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 普通仓位与原箱回仓都只展示实际入账凭据。 */
|
/** 普通仓位与原箱回仓都只展示实际入账凭据。 */
|
||||||
@@ -235,6 +236,9 @@ public class ExceptionOrderService {
|
|||||||
List<ExceptionOrderRequests.ReturnItem> requested = request.items();
|
List<ExceptionOrderRequests.ReturnItem> requested = request.items();
|
||||||
validateReturnItems(available, requested);
|
validateReturnItems(available, requested);
|
||||||
int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum());
|
int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum());
|
||||||
|
// 部分签收只能退回未签收余量,不能把已交付货物再次计入本趟退回。
|
||||||
|
long signed=Objects.requireNonNullElse(delivery.getSignedPackages(),0);
|
||||||
|
if(signed<0 || delivery.getPackageCount()==null || packages > delivery.getPackageCount()-signed)throw conflict("退回箱数超过派送未签收数量");
|
||||||
Set<String> skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item)))
|
Set<String> skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item)))
|
||||||
.map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
|
.map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
|
||||||
String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED";
|
String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED";
|
||||||
@@ -326,6 +330,35 @@ public class ExceptionOrderService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 其他处理方式只完结异常工单,不伪造退回、签收、库存或运输完成事实。 */
|
||||||
|
@Transactional
|
||||||
|
public ExceptionOrderResponses.CommandResult forceClose(String customerId, String operatorId,
|
||||||
|
String exceptionNo, ExceptionOrderRequests.ForceClose request) {
|
||||||
|
String reason = trim(request.reason());
|
||||||
|
if (reason == null || reason.length() > 1000) throw badRequest("请选择其他处理方式并填写完结原因(最多1000字)");
|
||||||
|
ExceptionOrder order = workflow.lock(customerId, exceptionNo);
|
||||||
|
if (order == null) throw notFound("异常单不存在");
|
||||||
|
String requestHash = hash(new ExceptionOrderRequests.ForceClose(request.expectedVersion(), request.idempotencyKey().trim(), reason));
|
||||||
|
// 同一异常父锁内先恢复已提交结果,避免重复点击推进版本或重复写入审计命令。
|
||||||
|
ExceptionOrderCommand replay = workflow.closeCommand(customerId, request.idempotencyKey().trim());
|
||||||
|
if (replay != null) {
|
||||||
|
if (!Objects.equals(replay.getExceptionOrderId(), order.getId())) throw conflict("幂等键已用于其他异常单");
|
||||||
|
return replayResult(replay, requestHash);
|
||||||
|
}
|
||||||
|
if (!java.util.Set.of("PENDING", "PROCESSING").contains(Objects.requireNonNullElse(order.getStatus(), ""))
|
||||||
|
|| version(order) != request.expectedVersion()) throw conflict("异常状态或版本已变化,请刷新");
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
order.setStatus("COMPLETED"); order.setHandlingAction("其他"); order.setHandlingResult(reason);
|
||||||
|
order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
|
||||||
|
workflow.updateException(order);
|
||||||
|
var result = commandResult(order, 0, false);
|
||||||
|
var command = new ExceptionOrderCommand();
|
||||||
|
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setExceptionOrderId(order.getId());
|
||||||
|
command.setAction("FORCE_CLOSE"); command.setIdempotencyKey(request.idempotencyKey().trim()); command.setRequestHash(requestHash);
|
||||||
|
command.setResultJson(write(result)); command.setCreatedAt(now); workflow.insertCommand(command);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** 一次实收的普通库存、原箱账本及完成状态同事务提交,失败全部回滚。 */
|
/** 一次实收的普通库存、原箱账本及完成状态同事务提交,失败全部回滚。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId,
|
public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId,
|
||||||
@@ -378,6 +411,12 @@ public class ExceptionOrderService {
|
|||||||
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
|
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
|
||||||
order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
|
order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
|
||||||
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
|
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
|
||||||
|
var returnTail=workflow.returnOrder(order);
|
||||||
|
if(returnTail!=null) {
|
||||||
|
returnTail.setReceivedPackageCount(Math.toIntExact(stored.stream().mapToLong(ReturnReceiptPolicy::received).sum()));
|
||||||
|
returnTail.setCurrentStatus(completed?"INBOUND_COMPLETED":(returnTail.getInboundOrderNo()==null?"PENDING_INBOUND":"PARTIAL_INBOUND"));
|
||||||
|
returnTail.setVersion(Objects.requireNonNullElse(returnTail.getVersion(),0)+1); returnTail.setUpdatedAt(now); workflow.update(returnTail);
|
||||||
|
}
|
||||||
closeReturnedDelivery(order, operatorId, now);
|
closeReturnedDelivery(order, operatorId, now);
|
||||||
long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored;
|
long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored;
|
||||||
ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
|
ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
|
||||||
@@ -635,7 +674,9 @@ public class ExceptionOrderService {
|
|||||||
}
|
}
|
||||||
long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId());
|
long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId());
|
||||||
if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致");
|
if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致");
|
||||||
if (received < delivery.getPackageCount()) return;
|
long signed=Objects.requireNonNullElse(delivery.getSignedPackages(),0);
|
||||||
|
if(signed<0 || received+signed>delivery.getPackageCount())throw conflict("实签与实退数量超过原实发数量");
|
||||||
|
if (received+signed < delivery.getPackageCount()) return;
|
||||||
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
|
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
|
||||||
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
|
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
|
||||||
deliveryOrders.updateById(delivery);
|
deliveryOrders.updateById(delivery);
|
||||||
@@ -676,12 +717,17 @@ public class ExceptionOrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 所有未结束异常均可选择其他方式,原退回及正常完成动作继续按资格开放。 */
|
||||||
private List<String> allowedActions(ExceptionOrder order) {
|
private List<String> allowedActions(ExceptionOrder order) {
|
||||||
return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
|
List<String> actions = new ArrayList<>(switch (Objects.requireNonNullElse(order.getStatus(), "")) {
|
||||||
case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION");
|
case "PENDING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())
|
||||||
case "PROCESSING" -> List.of("COMPLETE_EXCEPTION");
|
? List.of("GENERATE_RETURN_INBOUND","CANCEL_EXCEPTION") : List.of("START_PROCESSING", "CANCEL_EXCEPTION");
|
||||||
|
case "PROCESSING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()) && workflow.returnOrder(order)==null
|
||||||
|
? List.of("GENERATE_RETURN_INBOUND") : List.of("COMPLETE_EXCEPTION");
|
||||||
default -> List.of();
|
default -> List.of();
|
||||||
};
|
});
|
||||||
|
if (java.util.Set.of("PENDING", "PROCESSING").contains(Objects.requireNonNullElse(order.getStatus(), ""))) actions.add("FORCE_CLOSE");
|
||||||
|
return List.copyOf(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ExceptionOrderResponses.CommandResult commandResult(ExceptionOrder order, long restoredPackages,
|
private ExceptionOrderResponses.CommandResult commandResult(ExceptionOrder order, long restoredPackages,
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.baiyue.lastleg.exceptionorder.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository;
|
||||||
|
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||||
|
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||||
|
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** 异常退回建单不入账,PDA按原出库来源实收后才恢复库存。 */
|
||||||
|
@Service
|
||||||
|
public class ExceptionReturnInboundService {
|
||||||
|
private final ExceptionWorkflowRepository repository;
|
||||||
|
private final ExceptionOrderService exceptions;
|
||||||
|
private final LastlegOrderNoGenerator numbers;
|
||||||
|
private final PalletPhysicalGateway pallets;
|
||||||
|
private final WarehouseResourceGateway resources;
|
||||||
|
public record Receipt(@NotNull @Min(0) Long expectedVersion,@NotBlank @Size(max=128) String idempotencyKey,
|
||||||
|
@NotBlank String palletCode,@NotBlank String locationCode,
|
||||||
|
@NotEmpty @Size(max=200) List<@jakarta.validation.Valid Item> items,
|
||||||
|
@NotEmpty @Size(max=20) List<@NotNull @Min(1) Long> evidenceFileIds) {}
|
||||||
|
public record Item(@NotNull @Min(1) Long outboundPalletItemId,Long inventoryPositionId,Long returnCartonId,@Min(1) long packages) {}
|
||||||
|
/** 装配原异常业务与仓储权威资源入口。 */
|
||||||
|
public ExceptionReturnInboundService(ExceptionWorkflowRepository repository,ExceptionOrderService exceptions,
|
||||||
|
LastlegOrderNoGenerator numbers,PalletPhysicalGateway pallets,WarehouseResourceGateway resources) {
|
||||||
|
this.repository=repository; this.exceptions=exceptions; this.numbers=numbers; this.pallets=pallets; this.resources=resources;
|
||||||
|
}
|
||||||
|
/** 锁定异常并恢复既有结果,重复生成不增加尾程单或库存。 */
|
||||||
|
@Transactional
|
||||||
|
public LastlegOrder generate(String customer,String operator,String number,ExceptionOrderRequests.StartProcessing request) {
|
||||||
|
var order=repository.lock(customer,number);
|
||||||
|
if(order==null)throw conflict("异常单不存在");
|
||||||
|
var existing=repository.returnOrder(order);
|
||||||
|
if(existing!=null)return existing;
|
||||||
|
if(!"RETURN".equals(order.getExceptionType()) || !"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()))throw conflict("仅退回海外仓异常可生成退回入库单");
|
||||||
|
if(!Set.of("PENDING","PROCESSING").contains(order.getStatus()) || !Objects.equals(order.getVersion(),request.expectedVersion()))throw conflict("异常状态或版本已变化,请刷新");
|
||||||
|
var original=repository.original(order.getLastMileOrderId());
|
||||||
|
if(original==null || !customer.equals(original.getCustomerId()))throw conflict("原尾程订单归属无效");
|
||||||
|
var sources=exceptions.detail(customer,number).returnSources();
|
||||||
|
if(sources.isEmpty())throw conflict("没有剩余待收货物");
|
||||||
|
if("PENDING".equals(order.getStatus()))exceptions.startProcessing(customer,operator,number,request);
|
||||||
|
var tail=new LastlegOrder(); var now=LocalDateTime.now();
|
||||||
|
tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("LASTLEG")); tail.setCustomerId(customer);
|
||||||
|
tail.setCustomerName(order.getCustomerName()); tail.setOrderType("DELIVERY_RETURN"); tail.setDestinationType("OVERSEAS");
|
||||||
|
tail.setSourceRegion("EXCEPTION_RETURN"); tail.setSourceInboundId(String.valueOf(order.getId()));
|
||||||
|
tail.setSourceOrderId(String.valueOf(original.getId())); tail.setSourceOrderNo(original.getOrderNo()); tail.setRootOrderId(original.getRootOrderId());
|
||||||
|
// 原入仓码由异常冻结来源保留,新尾程单不重复占用原入仓关系。
|
||||||
|
tail.setInboundCode(tail.getOrderNo()); tail.setRootInboundId(original.getRootInboundId());
|
||||||
|
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setOverseasWarehouseName(order.getOverseasWarehouseName()); tail.setOverseasWarehouseCode(original.getOverseasWarehouseCode());
|
||||||
|
tail.setCurrentStatus("PENDING_INBOUND"); tail.setSubOrderStatus("EXCEPTION:"+number);
|
||||||
|
tail.setPackageCount(Math.toIntExact(sources.stream().mapToLong(s->s.maxReturnPackages()).sum())); tail.setReceivedPackageCount(0);
|
||||||
|
tail.setAbnormal(false); tail.setDetained(false); tail.setVersion(0); tail.setPalletCount(0); tail.setPickedPackageCount(0); tail.setShippedPackageCount(0);
|
||||||
|
tail.setSourceEventId(number); tail.setSourceLoadingPlanId(number); tail.setSourcePayloadHash("EXCEPTION:"+order.getId());
|
||||||
|
tail.setCreatedAt(now); tail.setUpdatedAt(now); repository.insert(tail); repository.copyDetails(tail,order);
|
||||||
|
return tail;
|
||||||
|
}
|
||||||
|
/** 列表只展示已生成退回尾程单且属于当前冻结仓的异常任务。 */
|
||||||
|
public List<ExceptionWorkflowMapper.Task> tasks(PdaWarehouseIdentity identity) { return repository.tasks(String.valueOf(identity.overseasWarehouseId())); }
|
||||||
|
/** 现场点选任务时重新检查仓库与状态,不接受客户端客户归属。 */
|
||||||
|
@Transactional
|
||||||
|
public ExceptionOrderResponses.Detail start(PdaWarehouseIdentity identity,String number) {
|
||||||
|
var order=required(identity,number);
|
||||||
|
return exceptions.detail(order.getCustomerId(),number);
|
||||||
|
}
|
||||||
|
/** 证据仍按异常单和客户绑定,不能借用客户退货照片。 */
|
||||||
|
@Transactional
|
||||||
|
public ExceptionOrderResponses.UploadedEvidence upload(PdaWarehouseIdentity identity,String number,org.springframework.web.multipart.MultipartFile file) {
|
||||||
|
var order=required(identity,number);
|
||||||
|
return exceptions.uploadEvidence(order.getCustomerId(),identity.operatorKey(),number,file);
|
||||||
|
}
|
||||||
|
/** 每批实收、库存恢复、异常进度与退回尾程单进度在同一事务提交。 */
|
||||||
|
@Transactional
|
||||||
|
public ExceptionOrderResponses.CommandResult receive(PdaWarehouseIdentity identity,String number,Receipt request) {
|
||||||
|
var order=repository.lockWarehouse(String.valueOf(identity.overseasWarehouseId()),number);
|
||||||
|
if(order==null || repository.returnOrder(order)==null)throw conflict("当前仓没有该异常退回任务");
|
||||||
|
// 幂等恢复由原异常完成命令先判断;已收齐的同键重放仍可恢复成功结果。
|
||||||
|
var pallet=pallets.resolve(request.palletCode().trim());
|
||||||
|
var location=resources.resolveLocation(identity.overseasWarehouseId(),request.locationCode().trim());
|
||||||
|
if(pallet==null || pallet.warehouseId()!=identity.overseasWarehouseId() || location==null
|
||||||
|
|| !Objects.equals(pallet.locationId(),location.locationId()) || !"ACTIVE".equals(location.status()))throw conflict("目标托盘与库位必须属于当前仓且实际绑定一致");
|
||||||
|
var receipts=request.items().stream().map(i->new ExceptionOrderRequests.ReturnReceipt(i.outboundPalletItemId(),i.inventoryPositionId(),i.packages(),
|
||||||
|
pallet.palletId(),pallet.palletCode(),location.locationId(),location.locationCode(),i.returnCartonId())).toList();
|
||||||
|
return exceptions.complete(order.getCustomerId(),identity.operatorKey(),number,new ExceptionOrderRequests.Complete(
|
||||||
|
request.expectedVersion(),request.idempotencyKey(),"PDA异常退货验收",request.evidenceFileIds(),receipts));
|
||||||
|
}
|
||||||
|
/** 任务资格始终重新读取权威状态与尾程关系。 */
|
||||||
|
private ExceptionOrder required(PdaWarehouseIdentity identity,String number) {
|
||||||
|
var order=repository.lockWarehouse(String.valueOf(identity.overseasWarehouseId()),number);
|
||||||
|
if(order==null || !"PROCESSING".equals(order.getStatus()) || !"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()) || repository.returnOrder(order)==null)throw conflict("当前仓没有该待验收异常任务");
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
/** 业务冲突返回统一可识别的错误。 */
|
||||||
|
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.baiyue.lastleg.exceptionorder.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.dto.ExceptionReturnPlanRequest;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.repository.ExceptionReturnPlanRepository;
|
||||||
|
import com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository;
|
||||||
|
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.*;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||||
|
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||||
|
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** 派送退回单独建计划,沿用异常验收恢复原库存,不能转为普通头程再次入账。 */
|
||||||
|
@Service
|
||||||
|
public class ExceptionReturnPlanService {
|
||||||
|
private final ExceptionReturnPlanRepository repository;
|
||||||
|
private final ExceptionWorkflowRepository exceptions;
|
||||||
|
private final WarehouseResourceGateway warehouse;
|
||||||
|
private final InboundPlanService plans;
|
||||||
|
private final LastlegOrderNoGenerator numbers;
|
||||||
|
private final ObjectMapper json;
|
||||||
|
/** 装配计划、异常冻结来源及仓库目录边界。 */
|
||||||
|
public ExceptionReturnPlanService(ExceptionReturnPlanRepository repository,ExceptionWorkflowRepository exceptions,
|
||||||
|
WarehouseResourceGateway warehouse,InboundPlanService plans,LastlegOrderNoGenerator numbers,ObjectMapper json) {
|
||||||
|
this.repository=repository;this.exceptions=exceptions;this.warehouse=warehouse;this.plans=plans;this.numbers=numbers;this.json=json;
|
||||||
|
}
|
||||||
|
/** 同事务锁定本单和异常,校验日期、版本与原键后生成独立入库计划。 */
|
||||||
|
@Transactional
|
||||||
|
public InboundPlanResponse create(ExceptionReturnPlanRequest request,String key,String operator) {
|
||||||
|
var input=request.plan();
|
||||||
|
if(key==null||key.isBlank()||key.length()>128||request.expectedArrivalAt()==null)
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请填写预计到仓时间和有效幂等键");
|
||||||
|
var order=repository.find(input.customerId(),input.orderId());
|
||||||
|
if(order==null||!"DELIVERY_RETURN".equals(order.getOrderType())||!"EXCEPTION_RETURN".equals(order.getSourceRegion()))
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND,"异常退回尾程订单不存在");
|
||||||
|
String hash=hash(request);
|
||||||
|
var replay=repository.command(input.customerId(),input.orderId(),operator,key);
|
||||||
|
if(replay!=null) {
|
||||||
|
if(!hash.equals(replay.getRequestHash()))throw conflict("同一幂等键不能用于不同请求");
|
||||||
|
try { return json.readValue(replay.getResultJson(),InboundPlanResponse.class); }
|
||||||
|
catch(Exception error) { throw new IllegalStateException("原计划响应读取失败",error); }
|
||||||
|
}
|
||||||
|
var exception=exceptions.lock(input.customerId(),order.getSourceEventId());
|
||||||
|
order=repository.lock(input.customerId(),input.orderId());
|
||||||
|
if(order==null)throw conflict("退回尾程单已变化");
|
||||||
|
if(exception==null||!"PROCESSING".equals(exception.getStatus())||!"OVERSEAS_WAREHOUSE".equals(exception.getReturnDirection())
|
||||||
|
||!String.valueOf(exception.getId()).equals(order.getSourceInboundId())
|
||||||
|
||!Objects.equals(order.getOverseasWarehouseId(),exception.getOverseasWarehouseId()))throw conflict("原异常退回状态或目标仓已变化");
|
||||||
|
if(!"PENDING_INBOUND".equals(order.getCurrentStatus())||!Objects.equals(order.getVersion(),input.expectedOrderVersion())
|
||||||
|
||order.getInboundOrderNo()!=null||repository.existing(input.customerId(),input.orderId())!=null)
|
||||||
|
throw conflict("订单已生成入库计划或版本已变化,请刷新");
|
||||||
|
if(order.getPackageCount()==null||order.getPackageCount()<=0)throw conflict("异常退回货物数量无效");
|
||||||
|
var receiving=warehouse.requireActiveSharedWarehouse(Long.parseLong(order.getOverseasWarehouseId()));
|
||||||
|
if(receiving==null||receiving.warehouseId()!=Long.parseLong(order.getOverseasWarehouseId())||!"ACTIVE".equals(receiving.status()))throw conflict("退回海外仓不可用");
|
||||||
|
var cargo=repository.cargo(input.customerId(),input.orderId());
|
||||||
|
if(!cargo.isEmpty()&&cargo.stream().allMatch(item->item.getVolumeCbm()!=null))
|
||||||
|
order.setVolumeCbm(cargo.stream().map(com.baiyue.lastleg.entity.LastlegOrderDetail::getVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||||
|
var now=LocalDateTime.now();var plan=new InboundPlan();
|
||||||
|
plan.setId(IdWorker.getId());plan.setPlanNo(numbers.next("INBOUND_PLAN"));plan.setCustomerId(input.customerId());
|
||||||
|
plan.setContainerNo("");
|
||||||
|
plan.setSourceOrderId(order.getId());plan.setOverseasWarehouseId(receiving.warehouseId());
|
||||||
|
plan.setPlannedArrivalDate(request.expectedArrivalAt().toLocalDate());plan.setPalletType(input.palletType());
|
||||||
|
plan.setAssignedFlag(input.assigned());plan.setMergeRequired(input.mergeRequired());plan.setUrgentFlag(input.urgent());
|
||||||
|
plan.setCreationMode("EXCEPTION_RETURN");plan.setStatus("PENDING_RECEIVING");plan.setLineCount(1);
|
||||||
|
plan.setPalletCount(input.palletCount());plan.setTotalVolumeCbm(Objects.requireNonNullElse(order.getVolumeCbm(),BigDecimal.ZERO));plan.setPlanRemark(input.remark());
|
||||||
|
plan.setVersion(0);plan.setCreatedAt(now);plan.setUpdatedAt(now);
|
||||||
|
// 新退回入仓码使用本次尾程单号,原入仓码只保留在冻结退回来源中。
|
||||||
|
var line=new InboundPlanLine();line.setId(IdWorker.getId());line.setCustomerId(input.customerId());line.setPlanId(plan.getId());
|
||||||
|
line.setOrderId(order.getId());line.setInboundCode(order.getOrderNo());line.setDestinationType("OVERSEAS");
|
||||||
|
line.setDestinationWarehouseId(order.getOverseasWarehouseId());line.setDestinationWarehouseCode(receiving.warehouseCode());
|
||||||
|
line.setDestinationWarehouseName(receiving.warehouseName());line.setRequiredPackageCount(order.getPackageCount());
|
||||||
|
line.setPlannedPalletCount(input.palletCount());line.setVolumeCbm(order.getVolumeCbm());repository.insert(plan,line);
|
||||||
|
// 这里只登记计划托数;真实托盘和库位在异常验收时选择并验证,不生成占位托盘码。
|
||||||
|
order.setInboundCode(order.getOrderNo());order.setInboundOrderNo(plan.getPlanNo());order.setEstimatedWarehouseAt(request.expectedArrivalAt());
|
||||||
|
order.setCurrentStatus("INBOUND_PLANNED");order.setPalletCount(input.palletCount());order.setVersion(order.getVersion()+1);order.setUpdatedAt(now);repository.update(order);
|
||||||
|
var history=new com.baiyue.lastleg.entity.LastlegOrderStatusHistory();
|
||||||
|
history.setCustomerId(order.getCustomerId());history.setOrderId(order.getId());history.setFromStatus("PENDING_INBOUND");
|
||||||
|
history.setToStatus("INBOUND_PLANNED");history.setEventId(plan.getPlanNo());history.setRemark("异常退回生成入库计划:"+plan.getPlanNo());history.setCreatedAt(now);repository.history(history);
|
||||||
|
var command=new LastlegOrderCommandRecord();command.setId(IdWorker.getId());command.setCustomerId(input.customerId());command.setOrderId(order.getId());
|
||||||
|
command.setAction("EXCEPTION_RETURN_PLAN");command.setOperatorId(operator);command.setIdempotencyKey(key);command.setRequestHash(hash);
|
||||||
|
var result=plans.detailByPlanNo(input.customerId(),plan.getPlanNo());
|
||||||
|
try { command.setResultJson(json.writeValueAsString(result)); }
|
||||||
|
catch(Exception error) { throw new IllegalStateException("计划响应保存失败",error); }
|
||||||
|
command.setResultRef(plan.getPlanNo());command.setResultVersion(order.getVersion());command.setCreatedAt(now);repository.insert(command);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/** 对完整请求计算稳定摘要,日期也参与原键一致性检查。 */
|
||||||
|
private String hash(Object value) {
|
||||||
|
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8)));}
|
||||||
|
catch(Exception error){throw new IllegalStateException("异常入库计划请求摘要失败",error);}
|
||||||
|
}
|
||||||
|
/** 状态变化按业务冲突返回,调用方保留表单后刷新。 */
|
||||||
|
private ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||||
|
}
|
||||||
+2
-2
@@ -88,7 +88,7 @@ public class PlatformOverseasFeeSourceController {
|
|||||||
requirePlatform(user); return ApiResponse.success(manual.review(sourceFeeId, operator(user), operatorName(user), request));
|
requirePlatform(user); return ApiResponse.success(manual.review(sourceFeeId, operator(user), operatorName(user), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提交账单费用的调整版本。 */
|
/** 调整审核前的人工费用,释放原冻结并以独立新费用重新送审。 */
|
||||||
@PostMapping("/{sourceFeeId}/adjust")
|
@PostMapping("/{sourceFeeId}/adjust")
|
||||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||||
public ApiResponse<OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@@ -96,7 +96,7 @@ public class PlatformOverseasFeeSourceController {
|
|||||||
requirePlatform(user); return ApiResponse.success(manual.adjust(sourceFeeId, operator(user), request));
|
requirePlatform(user); return ApiResponse.success(manual.adjust(sourceFeeId, operator(user), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 申请作废已推送账单费用。 */
|
/** 作废审核前的人工费用,存在冻结时申请释放。 */
|
||||||
@PostMapping("/{sourceFeeId}/void")
|
@PostMapping("/{sourceFeeId}/void")
|
||||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
|
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
|
||||||
public ApiResponse<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
|||||||
+47
-19
@@ -332,7 +332,7 @@ public class OverseasManualFeeService implements WalletResultHandler {
|
|||||||
case "FREEZE" -> {
|
case "FREEZE" -> {
|
||||||
if (!"FROZEN".equals(result.holdStatus())) throw conflict("冻结回执状态无效");
|
if (!"FROZEN".equals(result.holdStatus())) throw conflict("冻结回执状态无效");
|
||||||
state.setWalletStatus("FROZEN");
|
state.setWalletStatus("FROZEN");
|
||||||
if ("VOIDED".equals(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus())) send(fee, state, "RELEASE");
|
if (Set.of("VOIDED", "SUPERSEDED").contains(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus())) send(fee, state, "RELEASE");
|
||||||
}
|
}
|
||||||
case "SETTLE" -> {
|
case "SETTLE" -> {
|
||||||
if (!"SETTLED".equals(result.holdStatus()) || text(result.walletLedgerId()) == null)
|
if (!"SETTLED".equals(result.holdStatus()) || text(result.walletLedgerId()) == null)
|
||||||
@@ -352,36 +352,66 @@ public class OverseasManualFeeService implements WalletResultHandler {
|
|||||||
state.setUpdatedAt(now()); repository.updateFunding(state);
|
state.setUpdatedAt(now()); repository.updateFunding(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已扣款及自动仓租不允许从通用调价入口篡改。 */
|
/** 审核前以独立费用替换原记录,原冻结释放,新费用重新冻结并等待审核。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public OverseasFeeSource adjust(long id, String operator, AdjustRequest request) {
|
public OverseasFeeSource adjust(long id, String operator, AdjustRequest request) {
|
||||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||||
requireManual(fee);
|
requireManual(fee);
|
||||||
requireNotRejected(fee);
|
|
||||||
OverseasFeeFunding state = repository.lockFunding(id);
|
OverseasFeeFunding state = repository.lockFunding(id);
|
||||||
if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode()))
|
boolean replay = replayed(operator, "MANUAL_ADJUST", request.idempotencyKey(), id, request);
|
||||||
throw conflict("余额费用请取消重建;已扣费用请通过退款处理");
|
String createKey = "manual-adjust:" + hash(List.of(id, request.idempotencyKey().trim()));
|
||||||
if (!bill(fee)) throw conflict("历史海外费用请先完成原账单作废,再重新创建余额费用");
|
if (replay)
|
||||||
|
return create(operator, createKey, request.fee());
|
||||||
|
requireBeforeReview(fee, state, request.expectedVersion());
|
||||||
requireManualItem(request.fee().feeItemCode());
|
requireManualItem(request.fee().feeItemCode());
|
||||||
catalog.requireActive(request.fee().feeItemCode(), request.fee().feeItemName(), request.fee().direction());
|
catalog.requireActive(request.fee().feeItemCode(), request.fee().feeItemName(), request.fee().direction());
|
||||||
if (!Objects.equals(fee.getCustomerId(), request.fee().customerId()) || !Objects.equals(fee.getBusinessType(), request.fee().businessType())
|
if (!Objects.equals(fee.getCustomerId(), request.fee().customerId()) || !Objects.equals(fee.getBusinessType(), request.fee().businessType())
|
||||||
|| !Objects.equals(fee.getBusinessId(), request.fee().businessId()) || !Objects.equals(fee.getDirection(), request.fee().direction()))
|
|| !Objects.equals(fee.getBusinessId(), request.fee().businessId()) || !Objects.equals(fee.getDirection(), request.fee().direction())
|
||||||
|
|| !Objects.equals(fee.getSourceType(), request.fee().sourceType()) || !Objects.equals(fee.getBusinessNo(), request.fee().businessNo()))
|
||||||
throw conflict("调整不能更换客户、业务订单或收支方向");
|
throw conflict("调整不能更换客户、业务订单或收支方向");
|
||||||
requireLegacyCommandOwner(operator, "ADJUST", request.idempotencyKey(), id);
|
closeBeforeReview(fee, state, "SUPERSEDED", operator);
|
||||||
return described(sources.adjust(id, operator, request), state);
|
OverseasFeeSource next = create(operator, createKey, request.fee());
|
||||||
|
record(operator, "MANUAL_ADJUST", request.idempotencyKey(), id, request, fee.getSourceVersion());
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已扣费用不可作废,账单费用仍等待 Finance 确认作废。 */
|
/** 仅审核前允许作废;尚未发布的费用不向 Finance 发送账单作废事件。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public OverseasFeeSource voidSource(long id, String operator, VoidRequest request) {
|
public OverseasFeeSource voidSource(long id, String operator, VoidRequest request) {
|
||||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||||
requireManual(fee);
|
requireManual(fee);
|
||||||
requireNotRejected(fee);
|
|
||||||
OverseasFeeFunding state = repository.lockFunding(id);
|
OverseasFeeFunding state = repository.lockFunding(id);
|
||||||
if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode()))
|
if (replayed(operator, "MANUAL_VOID", request.idempotencyKey(), id, request)) return described(fee, state);
|
||||||
throw conflict("已扣费用请通过退款处理,未扣费用请取消");
|
requireBeforeReview(fee, state, request.expectedVersion());
|
||||||
requireLegacyCommandOwner(operator, "VOID", request.idempotencyKey(), id);
|
if (text(request.reason()) == null) throw bad("请填写作废原因");
|
||||||
return described(sources.voidSource(id, operator, request), state);
|
fee.setReviewReason(request.reason().trim());
|
||||||
|
closeBeforeReview(fee, state, "VOIDED", operator);
|
||||||
|
record(operator, "MANUAL_VOID", request.idempotencyKey(), id, request, fee.getSourceVersion());
|
||||||
|
return described(fee, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入和动作投影共用审核前资格,阻止已审核、已收款及过期版本变更。 */
|
||||||
|
private void requireBeforeReview(OverseasFeeSource fee, OverseasFeeFunding state, int version) {
|
||||||
|
if (!beforeReview(fee, state) || fee.getSourceVersion() != version)
|
||||||
|
throw conflict("只有当前审核前的有效费用可以调整或作废");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冻结在途或失败仍属审核前;扣款和资金释放在途不得更换金额快照。 */
|
||||||
|
private boolean beforeReview(OverseasFeeSource fee, OverseasFeeFunding state) {
|
||||||
|
return "ACTIVE".equals(fee.getSourceStatus()) && fee.getWalletSettlement() == null
|
||||||
|
&& Set.of("DRAFT", "PENDING").contains(Objects.toString(fee.getReviewStatus(), ""))
|
||||||
|
&& "UNPUBLISHED".equals(fee.getPublicationStatus()) && !procurement(fee)
|
||||||
|
&& !Set.of("PENDING_FINANCE", "FINANCE_ATTENTION").contains(Objects.toString(fee.getVoidStatus(), ""))
|
||||||
|
&& !StorageRentFeeWorkflow.isRent(fee)
|
||||||
|
&& (state == null || Set.of("NOT_REQUIRED", "FREEZE_PENDING", "FROZEN", "FREEZE_FAILED", "RELEASED")
|
||||||
|
.contains(Objects.toString(state.getWalletStatus(), "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭原费用,已冻结立即申请释放,冻结在途由成功回执继续释放。 */
|
||||||
|
private void closeBeforeReview(OverseasFeeSource fee, OverseasFeeFunding state, String status, String operator) {
|
||||||
|
fee.setSourceStatus(status);
|
||||||
|
touch(fee, operator);
|
||||||
|
if (state != null && "FROZEN".equals(state.getWalletStatus())) send(fee, state, "RELEASE");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 资金指令与费用变更同事务写 Outbox,冻结与业务操作互不抢用身份。 */
|
/** 资金指令与费用变更同事务写 Outbox,冻结与业务操作互不抢用身份。 */
|
||||||
@@ -420,9 +450,7 @@ public class OverseasManualFeeService implements WalletResultHandler {
|
|||||||
if (state != null && "SETTLE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_SETTLE");
|
if (state != null && "SETTLE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_SETTLE");
|
||||||
if ("DRAFT".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode())
|
if ("DRAFT".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode())
|
||||||
|| Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus()))) actions.add("SUBMIT");
|
|| Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus()))) actions.add("SUBMIT");
|
||||||
if ("APPROVED".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode()))) {
|
if (beforeReview(fee, state)) actions.addAll(List.of("ADJUST", "VOID"));
|
||||||
if (bill(fee)) actions.add("ADJUST"); actions.add("VOID");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (state != null && "RELEASE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_RELEASE");
|
if (state != null && "RELEASE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_RELEASE");
|
||||||
// 驳回不能重开收费,但资金释放失败仍须向操作端提供补偿入口。
|
// 驳回不能重开收费,但资金释放失败仍须向操作端提供补偿入口。
|
||||||
@@ -495,7 +523,7 @@ public class OverseasManualFeeService implements WalletResultHandler {
|
|||||||
|
|
||||||
/** 为单条变更结果补充与列表一致的操作资格。 */
|
/** 为单条变更结果补充与列表一致的操作资格。 */
|
||||||
private OverseasFeeSource described(OverseasFeeSource fee, OverseasFeeFunding state) { applyDescription(fee, state); return fee; }
|
private OverseasFeeSource described(OverseasFeeSource fee, OverseasFeeFunding state) { applyDescription(fee, state); return fee; }
|
||||||
/** 兼容旧载荷哈希,同时禁止跨费用复用旧审核、调整或作废操作标识。 */
|
/** 兼容旧载荷哈希,同时禁止跨费用复用旧审核操作标识。 */
|
||||||
private void requireLegacyCommandOwner(String operator, String action, String key, long id) {
|
private void requireLegacyCommandOwner(String operator, String action, String key, long id) {
|
||||||
if (text(key) == null || key.length() > 128) throw bad("操作标识无效");
|
if (text(key) == null || key.length() > 128) throw bad("操作标识无效");
|
||||||
var existing = repository.command(operator, action, key.trim());
|
var existing = repository.command(operator, action, key.trim());
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ public final class DriverModels {
|
|||||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||||
@Size(max = 128) String keyword, Boolean active, String reviewStatus,
|
@Size(max = 128) String keyword, Boolean active, String reviewStatus,
|
||||||
String ownership, String region, String licenseState,
|
String ownership, String region, String licenseState,
|
||||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) {
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd,
|
||||||
|
@Size(max = 64) String dispatchNo) {
|
||||||
|
/** 保留唯一规范构造器供 MVC 绑定,缺省分页使用第一页、每页二十条。 */
|
||||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class DriverService {
|
|||||||
private final ObjectMapper json;
|
private final ObjectMapper json;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final FleetWarehouseDirectory warehouses;
|
private final FleetWarehouseDirectory warehouses;
|
||||||
|
@Autowired private com.baiyue.lastleg.fleet.port.DriverAvailability availability;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) {
|
public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) {
|
||||||
@@ -55,6 +56,7 @@ public class DriverService {
|
|||||||
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary);
|
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 先排除其他车次占用,再分页统计,避免空页或错误总数。 */
|
||||||
private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) {
|
private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) {
|
||||||
String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态");
|
String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态");
|
||||||
String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属");
|
String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属");
|
||||||
@@ -65,6 +67,10 @@ public class DriverService {
|
|||||||
.eq(review != null, DriverEntity::getReviewStatus, review)
|
.eq(review != null, DriverEntity::getReviewStatus, review)
|
||||||
.eq(ownership != null, DriverEntity::getOwnership, ownership)
|
.eq(ownership != null, DriverEntity::getOwnership, ownership)
|
||||||
.eq(region != null, DriverEntity::getRegion, region);
|
.eq(region != null, DriverEntity::getRegion, region);
|
||||||
|
if (query.dispatchNo() != null && !query.dispatchNo().isBlank()) {
|
||||||
|
var unavailable = availability.unavailable(query.dispatchNo());
|
||||||
|
if (!unavailable.isEmpty()) filter.notIn(DriverEntity::getId, unavailable);
|
||||||
|
}
|
||||||
if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword)
|
if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword)
|
||||||
.or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword));
|
.or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword));
|
||||||
String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期");
|
String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user