Compare commits
25
Commits
c20107c278
..
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b95281bacb | ||
|
|
5d6b2812cf | ||
|
|
1506576c67 | ||
|
|
7b5ef9ba64 | ||
|
|
21a9ce7d27 | ||
|
|
035e7e06af | ||
|
|
5825739b92 | ||
|
|
edd4b1cd38 | ||
|
|
51a93a69af | ||
|
|
872215367b | ||
|
|
ac013c265b | ||
|
|
e1bfcfc8f5 | ||
|
|
0bd25de906 | ||
|
|
849f8dc5f2 | ||
|
|
1b27d7e6c2 | ||
|
|
353c86f4cf | ||
|
|
71a1026e21 | ||
|
|
f3d57869f2 | ||
|
|
7086713fdd | ||
|
|
66b0935b11 | ||
|
|
4f2d90b490 | ||
|
|
96ffc0925a | ||
|
|
ca61f79b6d | ||
|
|
b2e4f0d246 | ||
|
|
0b13c2cfb7 |
@@ -55,7 +55,7 @@
|
||||
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
|
||||
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
|
||||
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
|
||||
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` |
|
||||
| 海外业务费用来源跨区入池、人工费用与自动仓租 | `lastleg-service/`(`financefee/`、`storagerent/`)、`common-event-contract/`、`finance/` |
|
||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
|
||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
|
||||
| 供应商、国内整柜库存与出入库 | `warehouse/` |
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
|
||||
|
||||
散板收货闭环:`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-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-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-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 旧采购迁移仅供追溯,退役后不得重放。
|
||||
@@ -16,6 +26,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 或部署。
|
||||
|
||||
船节点日志在 `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-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
|
||||
@@ -218,3 +230,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
||||
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
|
||||
|
||||
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
|
||||
|
||||
|
||||
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
|
||||
|
||||
@@ -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,83 @@
|
||||
-- 海外费用菜单定向修复,MySQL 8.0+,目标只能 baiyue_iam。
|
||||
-- 将“海外费用管理”改为“海外费用明细”,并挂到“财务管理”下。
|
||||
-- 权限码、路由和角色授权保持不变;可重复执行。
|
||||
USE baiyue_iam;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS repair_overseas_fee_menu_parent;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE repair_overseas_fee_menu_parent()
|
||||
BEGIN
|
||||
DECLARE v_menu_changed INT DEFAULT 0;
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
RESIGNAL;
|
||||
END;
|
||||
|
||||
IF (SELECT COUNT(*) FROM iam_permission
|
||||
WHERE id=5105000 AND tenant_id=0 AND app_code='PLATFORM'
|
||||
AND permission_type='MENU' AND permission_code='platform:finance'
|
||||
AND status='ACTIVE' AND deleted=0)<>1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT='Finance parent menu platform:finance is missing or inactive';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM iam_permission
|
||||
WHERE (id=5117200 AND (permission_code<>'platform:overseas-fee:list'
|
||||
OR tenant_id<>0 OR app_code<>'PLATFORM' OR permission_type<>'MENU'))
|
||||
OR (permission_code='platform:overseas-fee:list'
|
||||
AND (id<>5117200 OR tenant_id<>0 OR app_code<>'PLATFORM'
|
||||
OR permission_type<>'MENU'))) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT='Overseas fee menu permission identity conflict';
|
||||
END IF;
|
||||
|
||||
IF (SELECT COUNT(*) FROM iam_permission
|
||||
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
|
||||
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list')<>1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT='Overseas fee menu platform:overseas-fee:list is missing';
|
||||
END IF;
|
||||
|
||||
START TRANSACTION;
|
||||
UPDATE iam_permission
|
||||
SET parent_id=5105000,
|
||||
permission_name='海外费用明细',
|
||||
route_path='/overseas-business/fees',
|
||||
http_method='GET',
|
||||
sort_no=60,
|
||||
status='ACTIVE',
|
||||
deleted=0,
|
||||
updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
|
||||
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list'
|
||||
AND (parent_id<>5105000 OR permission_name<>'海外费用明细'
|
||||
OR route_path<>'/overseas-business/fees' OR http_method<>'GET'
|
||||
OR sort_no<>60 OR status<>'ACTIVE' OR deleted<>0);
|
||||
SET v_menu_changed=ROW_COUNT();
|
||||
|
||||
-- 菜单实际变化时递增已获该菜单权限用户的安全版本,刷新旧授权快照。
|
||||
IF v_menu_changed>0 THEN
|
||||
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
|
||||
SELECT DISTINCT u.auth_user_id,1
|
||||
FROM iam_user u
|
||||
JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
|
||||
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0
|
||||
JOIN iam_role r ON r.id=mr.role_id AND r.tenant_id=0
|
||||
JOIN iam_role_permission rp ON rp.role_id=r.id
|
||||
AND rp.permission_id=5117200 AND rp.tenant_id=0
|
||||
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
|
||||
AND m.status='ACTIVE' AND m.deleted=0
|
||||
AND r.app_code='PLATFORM' AND r.status='ACTIVE' AND r.deleted=0
|
||||
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
|
||||
END IF;
|
||||
|
||||
COMMIT;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL repair_overseas_fee_menu_parent();
|
||||
DROP PROCEDURE repair_overseas_fee_menu_parent;
|
||||
|
||||
SELECT id,parent_id,permission_code,permission_name,route_path,sort_no
|
||||
FROM iam_permission WHERE id IN (5105000,5117200) ORDER BY id;
|
||||
@@ -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='海外客户下单预估资金冻结投影';
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 在 Lastleg 所属库执行;只补充整托计租截止日,不改写既有费用、库存、冻结或资金流水。
|
||||
-- 发布新服务前暂停仓租出库,取消未消费预占;旧比例费用的处理需按业务单核对后单独办理。
|
||||
USE baiyue_lastleg;
|
||||
SET @return_rent_ddl = (
|
||||
SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'll_storage_rent_lot' AND column_name = 'charged_days'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_storage_rent_lot ADD COLUMN charged_days BIGINT NOT NULL DEFAULT 0 COMMENT ''退货原托已生成仓租的截止库龄天数,同一天不重复计费'' AFTER original_units')
|
||||
);
|
||||
PREPARE return_rent_statement FROM @return_rent_ddl;
|
||||
EXECUTE return_rent_statement;
|
||||
DEALLOCATE PREPARE return_rent_statement;
|
||||
@@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
|
||||
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
|
||||
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
|
||||
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 '事实入库时间',
|
||||
PRIMARY KEY (id),
|
||||
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',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
||||
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),
|
||||
CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)),
|
||||
CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')),
|
||||
@@ -8462,6 +8465,27 @@ CREATE TABLE wh_document_sequence (
|
||||
|
||||
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
|
||||
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',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'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));
|
||||
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);
|
||||
|
||||
@@ -28,6 +28,7 @@ CREATE TABLE ll_cargo_discard_application (
|
||||
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
|
||||
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希',
|
||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
||||
created_by_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '申请人姓名快照',
|
||||
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
||||
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
|
||||
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
|
||||
@@ -917,7 +918,7 @@ CREATE TABLE ll_return_carton_stock_receipt (
|
||||
CREATE TABLE ll_finance_fee_command (
|
||||
id bigint 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 '操作幂等键',
|
||||
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256',
|
||||
source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID',
|
||||
@@ -1599,6 +1600,15 @@ CREATE TABLE ll_local_looseboard_order (
|
||||
id bigint NOT NULL COMMENT '主键ID',
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT 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 '本土散板订单号',
|
||||
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
|
||||
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
|
||||
@@ -2105,6 +2115,8 @@ CREATE TABLE ll_overseas_procurement_order (
|
||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
|
||||
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
||||
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
|
||||
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
|
||||
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
|
||||
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
|
||||
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
|
||||
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
|
||||
@@ -4783,6 +4795,7 @@ CREATE TABLE ll_storage_rent_lot (
|
||||
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
|
||||
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
|
||||
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
|
||||
charged_days bigint NOT NULL DEFAULT 0 COMMENT '退货原托已生成仓租的截止库龄天数,同一天不重复计费',
|
||||
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
|
||||
created_at datetime(3) NOT NULL COMMENT '创建时间',
|
||||
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||
@@ -4897,4 +4910,25 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
|
||||
PRIMARY KEY (order_id,command_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables;
|
||||
CREATE TABLE ll_order_budget_reservation (
|
||||
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
|
||||
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
|
||||
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
|
||||
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
|
||||
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
|
||||
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
|
||||
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
|
||||
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
|
||||
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
|
||||
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
|
||||
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
|
||||
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
|
||||
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
|
||||
KEY idx_order_budget_release(status,release_requested,created_at),
|
||||
KEY idx_order_budget_order(customer_id,kind,order_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 223 AS initialized_tables;
|
||||
|
||||
@@ -14,6 +14,8 @@ enum BusinessFileCategory {
|
||||
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_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_LOADING_PHOTO(false, FormatPolicy.PDF_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) {
|
||||
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) {
|
||||
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");
|
||||
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
|
||||
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", "采购应收必须引用成功实扣资金,不能进入待收费池");
|
||||
}
|
||||
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
|
||||
@@ -279,8 +282,7 @@ public class BusinessFeeSourceEventService {
|
||||
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
|
||||
throw error("FIN-EVT-017", "资金业务引用不完整");
|
||||
if ("OVERSEAS_MANUAL_FEE".equals(type)) {
|
||||
if ("FEE_TYPE_077".equals(source.feeItemCode())
|
||||
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
|
||||
if (!no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
|
||||
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
|
||||
} else if ("STORAGE_RENT".equals(type)) {
|
||||
if (!"FEE_TYPE_077".equals(source.feeItemCode()))
|
||||
|
||||
@@ -225,7 +225,8 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
||||
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),
|
||||
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
|
||||
|
||||
@@ -328,6 +328,53 @@ class BusinessFeeSourceEventServiceTest {
|
||||
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
|
||||
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
|
||||
EventEnvelope event = procurementEvent(true);
|
||||
@@ -375,6 +422,12 @@ class BusinessFeeSourceEventServiceTest {
|
||||
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
|
||||
}
|
||||
|
||||
/** 手动仓租保留人工费用资金身份,仍校验原冻结与实扣来源。 */
|
||||
@Test
|
||||
void manualStorageRentConsumptionUsesManualFundingIdentity() {
|
||||
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_077");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
|
||||
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/** 已被占用的费用不能完成第二张有效对账单的生成,事务必须拒绝提交。 */
|
||||
@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
|
||||
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
|
||||
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));
|
||||
}
|
||||
|
||||
/** 作废单的两笔原费用有任一已进入新单时,旧单不得重开为另一张有效单。 */
|
||||
@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
|
||||
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
|
||||
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
|
||||
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.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/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
|
||||
| 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” |
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
||||
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
|
||||
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
|
||||
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
|
||||
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
|
||||
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认 `ACTUAL` 不调整尺寸,可启用的调整算法为 `ADD/MULTIPLY` |
|
||||
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;`POST /fees/preview` 与正式计价共用算法,只读返回当前选择值的计费量、参考单价和合计,不生成费用版本或改变履约、财务状态 |
|
||||
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
|
||||
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
|
||||
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
|
||||
@@ -76,7 +76,7 @@
|
||||
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
|
||||
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
|
||||
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
|
||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ACTUAL` 不调整尺寸,仓库修正值替换对应实测边;`ADD` 以修正后的实测尺寸为基准对三个维度分别增加命中箱规的尺寸值;`MULTIPLY` 使用选定的启用系数配置,未指定配置时取当前客户唯一默认配置,由服务端按规格档位选择系数并分别作用于长宽高,体积按系数三次方变化。设备与人工录入共用计算入口,行明确选择的算法优先,否则仅使用客户默认算法;预置或保存系数配置不等于启用该算法。测量行必须保存实际尺寸、选用规则和算法快照,修改确认箱数复用已保存单箱快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
|
||||
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
|
||||
+12
@@ -7,6 +7,8 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
|
||||
import com.baiyue.firstleg.service.FbaPricingService;
|
||||
@@ -34,6 +36,16 @@ public class PlatformFbaPricingController {
|
||||
@Resource
|
||||
private FbaPricingService service;
|
||||
|
||||
/** 预览当前重量、体积对应的计费量和价格,不生成费用版本或改变履约状态。 */
|
||||
@PostMapping("/preview")
|
||||
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')")
|
||||
public ApiResponse<FeePreviewResponse> preview(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @Valid @RequestBody FeePreviewRequest request) {
|
||||
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.preview(scope, inboundId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/calculate")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
|
||||
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
|
||||
+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.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 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,
|
||||
@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);
|
||||
|
||||
@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);
|
||||
|
||||
@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);
|
||||
|
||||
@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);
|
||||
|
||||
@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);
|
||||
|
||||
@Update("""
|
||||
|
||||
@@ -76,6 +76,25 @@ public final class FbaPricingDtos {
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前输入的只读预览,人工单价仅用于预览金额,不写入费用。 */
|
||||
public record FeePreviewRequest(
|
||||
LocalDateTime pricingAt,
|
||||
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
|
||||
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
|
||||
@Size(max = 32) String weightSource,
|
||||
@Size(max = 32) String volumeSource,
|
||||
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedUnitPrice
|
||||
) {}
|
||||
|
||||
/** 当前选择值的只读计费预览,不包含费用版本或持久化标识。 */
|
||||
public record FeePreviewResponse(
|
||||
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
|
||||
BigDecimal chargeableQuantity, String chargeUnit,
|
||||
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
|
||||
BigDecimal unitPrice, BigDecimal additionalAmount, BigDecimal totalAmount,
|
||||
String currency, boolean priceMatched
|
||||
) {}
|
||||
|
||||
public record ManualQuoteRequest(
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
@@ -34,6 +36,7 @@ public class InboundMeasurementItemEntity {
|
||||
private BigDecimal volumeCbm;
|
||||
private BigDecimal totalVolumeCbm;
|
||||
private BigDecimal volumeWeightKg;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private Long chargeRuleId;
|
||||
private String coefficientMode;
|
||||
private String calculationRuleVersion;
|
||||
@@ -44,8 +47,12 @@ public class InboundMeasurementItemEntity {
|
||||
private BigDecimal outerLengthCm;
|
||||
private BigDecimal outerWidthCm;
|
||||
private BigDecimal outerHeightCm;
|
||||
// 清空修正表示恢复对应实测边,必须显式写入 NULL,不能被默认的非空更新策略忽略。
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal manualOuterLengthCm;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal manualOuterWidthCm;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal manualOuterHeightCm;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
+2
-1
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
|
||||
String providerNodeCode, String standardNode,
|
||||
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
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,
|
||||
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
|
||||
|
||||
+2
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
|
||||
private String locationName;
|
||||
private String sourceReference;
|
||||
private String rawPayloadHash;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
+3
-2
@@ -11,14 +11,15 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
||||
/** 保存不可变节点事实及执行该操作的账号快照。 */
|
||||
@Insert("""
|
||||
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,
|
||||
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
|
||||
(#{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.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
|
||||
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
|
||||
CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
|
||||
|
||||
+17
-9
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
|
||||
|
||||
public long customerId(long id) { return required(id).getCustomerId(); }
|
||||
|
||||
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
|
||||
/** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
|
||||
@Transactional
|
||||
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
|
||||
long containerBookingId,
|
||||
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
|
||||
persistenceStage = "NODE_FACT";
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "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) {
|
||||
persistenceStage = "CROSS_REGION_OUTBOX";
|
||||
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
|
||||
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
|
||||
}
|
||||
|
||||
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
|
||||
@Transactional
|
||||
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
|
||||
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
|
||||
request.providerNodeCode(), node,
|
||||
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) {
|
||||
return detail(value);
|
||||
}
|
||||
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
|
||||
return detail(value);
|
||||
}
|
||||
|
||||
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
|
||||
@Transactional
|
||||
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
|
||||
ManualNodeRequest request) {
|
||||
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
|
||||
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
|
||||
request.observedAt(),
|
||||
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) {
|
||||
return complete(id, operatorId, key, detail(value));
|
||||
}
|
||||
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
|
||||
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
||||
}
|
||||
|
||||
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
|
||||
@Transactional
|
||||
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
|
||||
String operatorName, String key,
|
||||
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
|
||||
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
|
||||
"MANUAL", providerEventId,
|
||||
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) {
|
||||
CarrierScheduleQueryEntity before = copy(query);
|
||||
requireManualActualAdvance(query, node, request.actualAt());
|
||||
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
|
||||
repository.histories(value.getId()).stream().map(this::history).toList());
|
||||
}
|
||||
|
||||
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
|
||||
private NodeFact node(CarrierScheduleNodeFactEntity value) {
|
||||
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
|
||||
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
|
||||
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
|
||||
value.getRawPayloadHash(), value.getCreatedAt());
|
||||
value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
|
||||
}
|
||||
|
||||
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
|
||||
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
|
||||
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
||||
}
|
||||
|
||||
/** 为查询任务追加节点事实并透传人工操作账号。 */
|
||||
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
|
||||
String providerNodeCode,
|
||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
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,
|
||||
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
|
||||
sourceReference, rawPayloadHash);
|
||||
sourceReference, rawPayloadHash, operatorId, operatorName);
|
||||
}
|
||||
|
||||
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
|
||||
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
|
||||
String providerCode, String providerEventId, String providerNodeCode,
|
||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash) {
|
||||
String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
|
||||
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
|
||||
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
|
||||
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.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
|
||||
if (repository.insertIfAbsent(fact)) return fact;
|
||||
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
|
||||
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
|
||||
|
||||
@@ -44,21 +44,22 @@ public class CartonMeasurementService {
|
||||
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
|
||||
private final MeasurementImageClient imageClient;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
private final CustomerDimensionWeightingService dimensionWeighting;
|
||||
private final MeasurementBatchService measurementBatchService;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
|
||||
/** 设备及手工单箱录入复用测量批次的计算规则。 */
|
||||
public CartonMeasurementService(InboundMeasurementRepository repository,
|
||||
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
|
||||
MeasurementImageClient imageClient,
|
||||
TransactionTemplate transactionTemplate,
|
||||
CustomerDimensionWeightingService dimensionWeighting) {
|
||||
MeasurementBatchService measurementBatchService) {
|
||||
this.repository = repository;
|
||||
this.resolverProvider = resolverProvider;
|
||||
this.imageClient = imageClient;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
this.dimensionWeighting = dimensionWeighting;
|
||||
this.measurementBatchService = measurementBatchService;
|
||||
}
|
||||
|
||||
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
|
||||
@@ -178,6 +179,7 @@ public class CartonMeasurementService {
|
||||
return result(measurement, item, false);
|
||||
}
|
||||
|
||||
/** 保存设备原始尺寸与证据,按客户默认算法或已保存的行算法生成计费快照。 */
|
||||
private UpsertOutcome persist(long tenantId, long operatorId,
|
||||
CartonLabelBindingResolver.Binding binding,
|
||||
String boxCode, CartonMeasurementCommand command, long imageFileId,
|
||||
@@ -228,7 +230,7 @@ public class CartonMeasurementService {
|
||||
item.setMachineNo(command.machineNo().trim());
|
||||
item.setDeviceMeasuredAt(command.measuredAt());
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
applyCalculatedValues(item, dimensionWeighting.load(tenantId));
|
||||
measurementBatchService.calculateCartonDimensions(item);
|
||||
|
||||
if (created) {
|
||||
repository.insertItem(item);
|
||||
@@ -265,33 +267,12 @@ public class CartonMeasurementService {
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private void applyCalculatedValues(InboundMeasurementItemEntity item,
|
||||
CustomerDimensionWeightingService.RuleSet dimensionRules) {
|
||||
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolveDeviceMeasurement(
|
||||
item.getLengthCm(), item.getWidthCm(), item.getHeightCm());
|
||||
BigDecimal unitRaw = weighted.rawVolumeCbm();
|
||||
BigDecimal unitVolume = weighted.unitVolumeCbm();
|
||||
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
||||
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.UNNECESSARY);
|
||||
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
|
||||
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
|
||||
item.setUnitVolumeCbm(unitVolume);
|
||||
item.setVolumeRawCbm(totalRaw);
|
||||
item.setVolumeCbm(totalVolume);
|
||||
item.setTotalVolumeCbm(totalVolume);
|
||||
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
|
||||
item.setOuterLengthCm(weighted.outerLengthCm());
|
||||
item.setOuterWidthCm(weighted.outerWidthCm());
|
||||
item.setOuterHeightCm(weighted.outerHeightCm());
|
||||
}
|
||||
|
||||
/** 仅修改确认箱数时复用已保存的单箱快照,不重新套用当前客户规则。 */
|
||||
private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) {
|
||||
if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm())
|
||||
|| !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm())
|
||||
|| !StringUtils.hasText(item.getCalculationRuleVersion())) {
|
||||
applyCalculatedValues(item, dimensionWeighting.load(item.getTenantId()));
|
||||
measurementBatchService.calculateCartonDimensions(item);
|
||||
return;
|
||||
}
|
||||
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
||||
|
||||
+18
-20
@@ -25,6 +25,7 @@ public class CustomerDimensionWeightingService {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/** 加载完整四档箱规及客户明确选择的默认算法,配置存在不代表算法已启用。 */
|
||||
public RuleSet load(long tenantId) {
|
||||
List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId);
|
||||
if (values == null || values.size() != CustomerDimensionBoxType.values().length
|
||||
@@ -33,7 +34,14 @@ public class CustomerDimensionWeightingService {
|
||||
.collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) {
|
||||
throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积");
|
||||
}
|
||||
return new RuleSet(values.stream().map(Rule::from).toList());
|
||||
List<String> modes = values.stream().map(value -> value.getDefaultCalculationMode() == null
|
||||
|| value.getDefaultCalculationMode().isBlank() ? "ACTUAL"
|
||||
: value.getDefaultCalculationMode().trim().toUpperCase(java.util.Locale.ROOT))
|
||||
.distinct().toList();
|
||||
if (modes.size() != 1 || !List.of("ACTUAL", "ADD", "MULTIPLY").contains(modes.get(0))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "客户默认箱规计算方式配置不一致或无效");
|
||||
}
|
||||
return new RuleSet(values.stream().map(Rule::from).toList(), modes.get(0));
|
||||
}
|
||||
|
||||
private static boolean invalidRule(CustomerDimensionConfigEntity value) {
|
||||
@@ -48,14 +56,22 @@ public class CustomerDimensionWeightingService {
|
||||
|
||||
public static final class RuleSet {
|
||||
private final List<Rule> rules;
|
||||
private final String defaultCalculationMode;
|
||||
|
||||
private RuleSet(List<Rule> rules) {
|
||||
/** 保存同一客户的箱规和默认算法,供设备及人工测量统一使用。 */
|
||||
private RuleSet(List<Rule> rules, String defaultCalculationMode) {
|
||||
this.defaultCalculationMode = defaultCalculationMode;
|
||||
this.rules = rules.stream()
|
||||
.sorted(Comparator.comparing(Rule::innerVolume)
|
||||
.thenComparing(rule -> rule.boxType().ordinal()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 返回客户已启用的默认算法;ACTUAL 表示尺寸不调整。 */
|
||||
public String defaultCalculationMode() {
|
||||
return defaultCalculationMode;
|
||||
}
|
||||
|
||||
public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) {
|
||||
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
|
||||
return rules.stream()
|
||||
@@ -66,24 +82,6 @@ public class CustomerDimensionWeightingService {
|
||||
.weightedActualDimensions(lengthCm, widthCm, heightCm));
|
||||
}
|
||||
|
||||
public WeightedDimensions resolveDeviceMeasurement(BigDecimal lengthCm, BigDecimal widthCm,
|
||||
BigDecimal heightCm) {
|
||||
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
|
||||
return rules.stream()
|
||||
.filter(rule -> rule.contains(measured))
|
||||
.findFirst()
|
||||
.map(rule -> rule.weightedActualDimensions(lengthCm, widthCm, heightCm))
|
||||
.orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm));
|
||||
}
|
||||
|
||||
private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
|
||||
BigDecimal heightCm) {
|
||||
BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm)
|
||||
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
|
||||
return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm,
|
||||
rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP),
|
||||
"ACTUAL_DIMENSION_V1");
|
||||
}
|
||||
}
|
||||
|
||||
public record WeightedDimensions(
|
||||
|
||||
@@ -5,6 +5,8 @@ import jakarta.annotation.Resource;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
|
||||
@@ -63,6 +65,41 @@ public class FbaPricingService {
|
||||
@Resource
|
||||
private ContainerTierAmountAllocator containerTierAllocator;
|
||||
|
||||
/** 按当前选择值只读预览,复用正式计价和柜级阶梯规则,不写费用、幂等或财务记录。 */
|
||||
public FeePreviewResponse preview(long tenantId, long inboundId, FeePreviewRequest request) {
|
||||
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
|
||||
requireMeasurementSources(tenantId, inboundId, request.weightSource(), request.volumeSource());
|
||||
BigDecimal weight = selectedWeight(inbound, request.selectedWeightKg());
|
||||
BigDecimal volume = selectedVolume(inbound, request.selectedVolumeCbm());
|
||||
PricingPolicy policy = pricingPolicy(inbound,
|
||||
request.pricingAt() == null ? LocalDateTime.now(clock) : request.pricingAt());
|
||||
try {
|
||||
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
|
||||
BigDecimal additional = estimate.rate().getAdditionalFee() == null ? BigDecimal.ZERO
|
||||
: estimate.rate().getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
BigDecimal subtotal = request.selectedUnitPrice() == null
|
||||
|| request.selectedUnitPrice().compareTo(estimate.unitPrice()) == 0
|
||||
? estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)
|
||||
: estimate.chargeableQuantity().multiply(request.selectedUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
return new FeePreviewResponse(weight, volume, estimate.chargeableQuantity(), estimate.chargeUnit(),
|
||||
estimate.conversionFactor(), estimate.roundingScale(), estimate.roundingMode(),
|
||||
estimate.unitPrice(), additional,
|
||||
subtotal.add(additional),
|
||||
estimate.currency(), true);
|
||||
} catch (BizException exception) {
|
||||
// 仅价格未命中且可人工定价的 FBA 返回计费量;其他业务错误继续明确拒绝。
|
||||
if (exception.getResultCode() != ResultCode.NOT_FOUND || !"FBA".equals(inbound.getDestinationType())) {
|
||||
throw exception;
|
||||
}
|
||||
BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363");
|
||||
return new FeePreviewResponse(weight, volume, manualChargeableQuantity(policy.chargeMode(), weight, volume),
|
||||
policy.chargeMode(), factor, 6, "HALF_UP", null,
|
||||
BigDecimal.ZERO.setScale(MONEY_SCALE), request.selectedUnitPrice() == null ? null
|
||||
: manualChargeableQuantity(policy.chargeMode(), weight, volume).multiply(request.selectedUnitPrice())
|
||||
.setScale(MONEY_SCALE, RoundingMode.HALF_UP), "CNY", false);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
||||
LocalDateTime requestedAt, String idempotencyKey) {
|
||||
@@ -77,6 +114,7 @@ public class FbaPricingService {
|
||||
selectedVolumeCbm, null, null, idempotencyKey);
|
||||
}
|
||||
|
||||
/** 复用预览的计价规则生成费用版本,仅正式确认流程写入计费事实。 */
|
||||
@Transactional
|
||||
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
||||
LocalDateTime requestedAt, BigDecimal selectedWeightKg,
|
||||
@@ -105,38 +143,11 @@ public class FbaPricingService {
|
||||
throw conflict("客户已确认,不允许再修改或重新发送费用");
|
||||
}
|
||||
}
|
||||
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
|
||||
boolean actualMeasurementSource = "ACTUAL".equalsIgnoreCase(weightSource)
|
||||
|| "ACTUAL".equalsIgnoreCase(volumeSource);
|
||||
boolean outerMeasurementSource = "OUTER".equalsIgnoreCase(weightSource)
|
||||
|| "OUTER".equalsIgnoreCase(volumeSource);
|
||||
if ((actualMeasurementSource || outerMeasurementSource) && measurement == null) {
|
||||
throw conflict(actualMeasurementSource
|
||||
? "实际测量数据不存在,不能生成对客费用"
|
||||
: "外箱规测量批次不存在,不能生成对客费用");
|
||||
}
|
||||
if (actualMeasurementSource || outerMeasurementSource) {
|
||||
var measurementItems = measurementRepository.listItems(tenantId, measurement.getId());
|
||||
if (actualMeasurementSource && measurementItems.isEmpty()) {
|
||||
throw conflict("实际测量明细不存在,不能生成对客费用");
|
||||
}
|
||||
boolean incomplete = outerMeasurementSource && (measurementItems.isEmpty() || measurementItems.stream().anyMatch(item ->
|
||||
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|
||||
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|
||||
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|
||||
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
|
||||
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
|
||||
}
|
||||
var measurement = requireMeasurementSources(tenantId, inboundId, weightSource, volumeSource);
|
||||
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
|
||||
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
|
||||
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
||||
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
||||
pricingTaxMode(inbound), policy.chargeMode(),
|
||||
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
|
||||
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
|
||||
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
|
||||
BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP);
|
||||
estimate = applyContainerCustomerTier(inbound, estimate, weight, volume);
|
||||
BigDecimal weight = selectedWeight(inbound, selectedWeightKg);
|
||||
BigDecimal volume = selectedVolume(inbound, selectedVolumeCbm);
|
||||
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
|
||||
RateRow rate = estimate.rate();
|
||||
BigDecimal chargeable = estimate.chargeableQuantity();
|
||||
BigDecimal converted = chargeable;
|
||||
@@ -179,6 +190,7 @@ public class FbaPricingService {
|
||||
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
||||
}
|
||||
|
||||
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
|
||||
@Transactional
|
||||
public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId,
|
||||
ManualQuoteRequest request, String idempotencyKey) {
|
||||
@@ -219,9 +231,7 @@ public class FbaPricingService {
|
||||
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
|
||||
String unit = policy.chargeMode();
|
||||
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
|
||||
BigDecimal quantity = ("KG".equals(unit) ? weight.max(volume.multiply(factor))
|
||||
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
|
||||
.setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal quantity = manualChargeableQuantity(unit, weight, volume);
|
||||
BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
int version = repository.nextFeeVersion(tenantId, inboundId);
|
||||
InboundFeeEntity fee = new InboundFeeEntity();
|
||||
@@ -267,6 +277,61 @@ public class FbaPricingService {
|
||||
return response(inbound, fee, request.unitPrice(), nextStatus);
|
||||
}
|
||||
|
||||
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
|
||||
private ActivePriceBookEstimator.Estimate estimateFee(long tenantId, InboundPricingEntity inbound,
|
||||
PricingPolicy policy, BigDecimal weight, BigDecimal volume) {
|
||||
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
|
||||
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
||||
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
||||
pricingTaxMode(inbound), policy.chargeMode(), weight, volume));
|
||||
return applyContainerCustomerTier(inbound, estimate, weight, volume);
|
||||
}
|
||||
|
||||
/** 计费重量统一保留三位精度,预览与最终费用使用同一基数。 */
|
||||
private BigDecimal selectedWeight(InboundPricingEntity inbound, BigDecimal selected) {
|
||||
BigDecimal weight = selected == null ? inbound.getActualNetWeightKg() : selected;
|
||||
if (weight == null || weight.signum() <= 0) throw conflict("计费重量必须大于0");
|
||||
return weight.setScale(3, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** 计费体积统一保留四位精度,预览与最终费用使用同一基数。 */
|
||||
private BigDecimal selectedVolume(InboundPricingEntity inbound, BigDecimal selected) {
|
||||
BigDecimal volume = selected == null ? inbound.getActualVolumeCbm() : selected;
|
||||
if (volume == null || volume.signum() <= 0) throw conflict("计费体积必须大于0");
|
||||
return volume.setScale(4, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** 人工定价的重量与体积换算规则由服务端统一提供给预览和正式报价。 */
|
||||
private BigDecimal manualChargeableQuantity(String unit, BigDecimal weight, BigDecimal volume) {
|
||||
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
|
||||
return ("KG".equals(unit) ? weight.max(volume.multiply(factor))
|
||||
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
|
||||
.setScale(6, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** 仅验证测量来源及保存快照完整性,不写入或重新计算测量数据。 */
|
||||
private com.baiyue.firstleg.entity.InboundMeasurementEntity requireMeasurementSources(
|
||||
long tenantId, long inboundId, String weightSource, String volumeSource) {
|
||||
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
|
||||
boolean actual = "ACTUAL".equalsIgnoreCase(weightSource) || "ACTUAL".equalsIgnoreCase(volumeSource);
|
||||
boolean outer = "OUTER".equalsIgnoreCase(weightSource) || "OUTER".equalsIgnoreCase(volumeSource);
|
||||
if ((actual || outer) && measurement == null) {
|
||||
throw conflict(actual ? "实际测量数据不存在,不能生成对客费用" : "外箱规测量批次不存在,不能生成对客费用");
|
||||
}
|
||||
if (actual || outer) {
|
||||
var items = measurementRepository.listItems(tenantId, measurement.getId());
|
||||
if (actual && items.isEmpty()) throw conflict("实际测量明细不存在,不能生成对客费用");
|
||||
if (outer && (items.isEmpty() || items.stream().anyMatch(item ->
|
||||
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|
||||
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|
||||
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|
||||
|| !StringUtils.hasText(item.getCalculationRuleVersion())))) {
|
||||
throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
|
||||
}
|
||||
}
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private ActivePriceBookEstimator.Estimate applyContainerCustomerTier(
|
||||
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
|
||||
BigDecimal currentWeight, BigDecimal currentVolume) {
|
||||
|
||||
@@ -56,6 +56,7 @@ public class MeasurementBatchService {
|
||||
@Resource
|
||||
private FbaPricingRepository pricingRepository;
|
||||
|
||||
/** 按已选算法保存测量批次及计算快照,不推进履约状态。 */
|
||||
@Transactional
|
||||
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
|
||||
var inbound = repository.findInbound(command.tenantId(), command.inboundId());
|
||||
@@ -68,7 +69,7 @@ public class MeasurementBatchService {
|
||||
}
|
||||
validateSource(command);
|
||||
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
|
||||
command, dimensionWeighting.load(command.tenantId()));
|
||||
command.tenantId(), command.items(), dimensionWeighting.load(command.tenantId()));
|
||||
|
||||
InboundMeasurementEntity measurement;
|
||||
if (command.measurementId() == null) {
|
||||
@@ -93,6 +94,7 @@ public class MeasurementBatchService {
|
||||
return measurement;
|
||||
}
|
||||
|
||||
/** 修改批次算法时,以实测及人工修正基数重算,避免重复叠加。 */
|
||||
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> existingItems,
|
||||
String coefficientMode, Long chargeRuleId) {
|
||||
@@ -112,7 +114,7 @@ public class MeasurementBatchService {
|
||||
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
|
||||
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
|
||||
List<InboundMeasurementItemEntity> recalculated = calculateItems(
|
||||
command, dimensionWeighting.load(tenantId));
|
||||
tenantId, command.items(), dimensionWeighting.load(tenantId));
|
||||
for (int index = 0; index < existingItems.size(); index++) {
|
||||
InboundMeasurementItemEntity target = existingItems.get(index);
|
||||
InboundMeasurementItemEntity source = recalculated.get(index);
|
||||
@@ -121,6 +123,7 @@ public class MeasurementBatchService {
|
||||
applyTotals(measurement, existingItems);
|
||||
}
|
||||
|
||||
/** 按保存的算法重算单行外箱修正,并更新批次汇总。 */
|
||||
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> existingItems, long itemId,
|
||||
BigDecimal outerLengthCm, BigDecimal outerWidthCm,
|
||||
@@ -143,14 +146,42 @@ public class MeasurementBatchService {
|
||||
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
|
||||
target.getRemark())));
|
||||
InboundMeasurementItemEntity recalculated = calculateItems(
|
||||
command, dimensionWeighting.load(tenantId)).get(0);
|
||||
tenantId, command.items(), dimensionWeighting.load(tenantId)).get(0);
|
||||
applyRecalculatedItem(target, recalculated, true);
|
||||
applyTotals(measurement, existingItems);
|
||||
}
|
||||
|
||||
/** 设备首次测量与复测共用批次算法,保留已有人工修正及明确选择的算法。 */
|
||||
void calculateCartonDimensions(InboundMeasurementItemEntity item) {
|
||||
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
|
||||
item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
|
||||
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getSourceImageFileId(),
|
||||
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
|
||||
item.getManualOuterHeightCm(), item.getChargeRuleId(), item.getCoefficientMode(), item.getRemark());
|
||||
InboundMeasurementItemEntity calculated = calculateItems(item.getTenantId(), List.of(source),
|
||||
dimensionWeighting.load(item.getTenantId())).get(0);
|
||||
copyCalculatedValues(item, calculated);
|
||||
}
|
||||
|
||||
/** 保存重算后的行,保留箱码、设备证据和批次身份。 */
|
||||
private void applyRecalculatedItem(InboundMeasurementItemEntity target,
|
||||
InboundMeasurementItemEntity source,
|
||||
boolean updateManualDimensions) {
|
||||
copyCalculatedValues(target, source);
|
||||
if (updateManualDimensions) {
|
||||
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
|
||||
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
|
||||
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
|
||||
}
|
||||
target.setUpdatedAt(LocalDateTime.now());
|
||||
if (!repository.updateItem(target)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一复制尺寸、体积、材积重及算法快照,不覆盖原始测量值。 */
|
||||
private void copyCalculatedValues(InboundMeasurementItemEntity target,
|
||||
InboundMeasurementItemEntity source) {
|
||||
target.setGirthCm(source.getGirthCm());
|
||||
target.setUnitVolumeCbm(source.getUnitVolumeCbm());
|
||||
target.setVolumeRawCbm(source.getVolumeRawCbm());
|
||||
@@ -163,15 +194,6 @@ public class MeasurementBatchService {
|
||||
target.setOuterLengthCm(source.getOuterLengthCm());
|
||||
target.setOuterWidthCm(source.getOuterWidthCm());
|
||||
target.setOuterHeightCm(source.getOuterHeightCm());
|
||||
if (updateManualDimensions) {
|
||||
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
|
||||
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
|
||||
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
|
||||
}
|
||||
target.setUpdatedAt(LocalDateTime.now());
|
||||
if (!repository.updateItem(target)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,10 +279,12 @@ public class MeasurementBatchService {
|
||||
measurement.setActualGoodsName(command.actualGoodsName());
|
||||
}
|
||||
|
||||
/** 明确的行算法优先;未选择时仅使用客户默认算法,不因预置系数而自动启用。 */
|
||||
private List<InboundMeasurementItemEntity> calculateItems(
|
||||
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) {
|
||||
long tenantId, List<MeasurementBatchCommand.Item> sourceItems,
|
||||
CustomerDimensionWeightingService.RuleSet dimensionRules) {
|
||||
Set<String> itemNumbers = new HashSet<>();
|
||||
return command.items().stream().map(source -> {
|
||||
return sourceItems.stream().map(source -> {
|
||||
if (!itemNumbers.add(source.itemNo())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
|
||||
}
|
||||
@@ -279,10 +303,9 @@ public class MeasurementBatchService {
|
||||
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
|
||||
baseLength, baseWidth, baseHeight);
|
||||
String requestedMode = normalizeMode(source.coefficientMode());
|
||||
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode)
|
||||
? requireCoefficientRule(command.tenantId(), source.chargeRuleId())
|
||||
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId()) : null;
|
||||
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
|
||||
String coefficientMode = requestedMode == null ? dimensionRules.defaultCalculationMode() : requestedMode;
|
||||
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(coefficientMode)
|
||||
? requireCoefficientRule(tenantId, source.chargeRuleId()) : null;
|
||||
boolean useDimensionRule = "ADD".equals(coefficientMode);
|
||||
BigDecimal addition = weighted.edgeAdditionCm() == null
|
||||
? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2));
|
||||
@@ -374,10 +397,6 @@ public class MeasurementBatchService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId) {
|
||||
return pricingRepository.findDefaultRule(tenantId, tenantId);
|
||||
}
|
||||
|
||||
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) {
|
||||
CustomerChargeRuleEntity rule = ruleId == null
|
||||
? pricingRepository.findDefaultRule(tenantId, tenantId)
|
||||
|
||||
@@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
|
||||
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
|
||||
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 '事实入库时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
||||
|
||||
@@ -234,6 +234,7 @@ components:
|
||||
locationCode: {type: string, maxLength: 32, nullable: true}
|
||||
locationName: {type: string, maxLength: 128, 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}$'}
|
||||
createdAt: {type: string, format: date-time}
|
||||
ExceptionFact:
|
||||
|
||||
@@ -147,6 +147,13 @@ paths:
|
||||
description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}}
|
||||
responses: {'200': {description: 成功或业务失败}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/preview:
|
||||
post:
|
||||
summary: 只读预览当前计费重量和体积
|
||||
description: 不生成费用版本,不修改测量、费用、履约状态或财务记录;与正式计价共用价格匹配及柜级阶梯规则。FBA价格未命中时返回 priceMatched=false、计费量和空单价,供人工单价预览使用;其他错误明确返回。需要 firstleg:inbound:confirm-send-fee 或 firstleg:fba-pricing:calculate 权限。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewRequest'}}}}
|
||||
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewApiResponse'}}}}, '403': {description: 无权限}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/calculate:
|
||||
post:
|
||||
summary: 按审核通过测量快照试算 FBA 费用
|
||||
@@ -296,6 +303,32 @@ components:
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
deleted: {type: boolean, nullable: true}
|
||||
version: {type: integer, nullable: true}
|
||||
FeePreviewApiResponse:
|
||||
type: object
|
||||
required: [code, message, data]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/FeePreviewResponse'}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
timestamp: {type: string, format: date-time, nullable: true}
|
||||
FeePreviewResponse:
|
||||
type: object
|
||||
required: [actualWeightKg, actualVolumeCbm, chargeableQuantity, chargeUnit, conversionFactor, roundingScale, roundingMode, unitPrice, additionalAmount, totalAmount, currency, priceMatched]
|
||||
properties:
|
||||
actualWeightKg: {type: number}
|
||||
actualVolumeCbm: {type: number}
|
||||
chargeableQuantity: {type: number}
|
||||
chargeUnit: {type: string, enum: [KG, CBM]}
|
||||
conversionFactor: {type: number}
|
||||
roundingScale: {type: integer}
|
||||
roundingMode: {type: string}
|
||||
unitPrice: {type: number, nullable: true}
|
||||
additionalAmount: {type: number}
|
||||
totalAmount: {type: number, nullable: true, description: 按本次人工单价或参考单价计算并取整后的费用合计;两种单价都缺失时为空}
|
||||
currency: {type: string}
|
||||
priceMatched: {type: boolean}
|
||||
FeeSnapshotApiResponse:
|
||||
type: object
|
||||
required: [code, message, data]
|
||||
@@ -503,6 +536,15 @@ components:
|
||||
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0}
|
||||
unitPrice: {type: number, exclusiveMinimum: true, minimum: 0}
|
||||
reason: {type: string, minLength: 2, maxLength: 500}
|
||||
FeePreviewRequest:
|
||||
type: object
|
||||
properties:
|
||||
pricingAt: {type: string, format: date-time, nullable: true}
|
||||
selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
|
||||
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
|
||||
weightSource: {type: string, maxLength: 32, nullable: true}
|
||||
volumeSource: {type: string, maxLength: 32, nullable: true}
|
||||
selectedUnitPrice: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 本次人工单价;为空时使用当前参考单价,仅用于只读金额预览}
|
||||
CalculateRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -2245,7 +2245,7 @@ components:
|
||||
outerWidthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱宽度修正;保存后参与联动计算}
|
||||
outerHeightCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱高度修正;保存后参与联动计算}
|
||||
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true}
|
||||
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true, description: 明确选择的行算法优先;未指定时仅使用客户默认算法,ACTUAL 不调整修正后的实测尺寸,系数配置存在不代表启用 MULTIPLY}
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
InboundMeasurementDetailEnvelope:
|
||||
type: object
|
||||
|
||||
@@ -19,6 +19,7 @@ class FbaPricingContractTest {
|
||||
@Test
|
||||
@DisplayName("确认和发送费用兼容新的组合权限与原有权限")
|
||||
void allowsCombinedConfirmSendPermissionAndLegacyPermissions() {
|
||||
assertPricingAuthority("preview", "hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')");
|
||||
assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
||||
+ " 'firstleg:fba-pricing:confirm')");
|
||||
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
||||
@@ -58,6 +59,8 @@ class FbaPricingContractTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/fba-pricing/customer-rules/versions:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/preview:")
|
||||
.contains("不生成费用版本")
|
||||
.contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:")
|
||||
.contains("/api/platform/v1/fba-pricing/carrier-rates/versions:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:")
|
||||
|
||||
+8
@@ -28,6 +28,14 @@ class ContainerCustomsMapperContractTest {
|
||||
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 {
|
||||
String value=sql("completedPlanForOrder",long.class,long.class);
|
||||
assertThat(value)
|
||||
|
||||
+10
@@ -8,6 +8,16 @@ import java.nio.charset.StandardCharsets;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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
|
||||
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
|
||||
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;
|
||||
|
||||
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
|
||||
void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 真实 Mapper 保存并返回操作用户名,账号更名后重放不改写原署名。 */
|
||||
@Test
|
||||
void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() {
|
||||
var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
|
||||
"ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles",
|
||||
"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();
|
||||
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body);
|
||||
service.addContainerManualNode(3,10001,"renamed_operator","original-body-key",body);
|
||||
assertThat(allSnapshot()).isEqualTo(saved);
|
||||
assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key",
|
||||
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
|
||||
|
||||
+19
-8
@@ -183,6 +183,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
|
||||
}
|
||||
|
||||
/** 出仓节点记录确认人的账号,供两个货柜详情展示。 */
|
||||
@Test
|
||||
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
@@ -194,7 +195,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
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.getCarrierCode()).isEqualTo("COSCO");
|
||||
@@ -204,23 +205,25 @@ class CarrierScheduleQueryServiceTest {
|
||||
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
|
||||
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
|
||||
assertThat(result.getCreatedBy()).isZero();
|
||||
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM");
|
||||
assertThat(result.getUpdatedBy()).isZero();
|
||||
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM");
|
||||
assertThat(result.getCreatedBy()).isEqualTo(7L);
|
||||
assertThat(result.getCreatedByName()).isEqualTo("platform_admin");
|
||||
assertThat(result.getUpdatedBy()).isEqualTo(7L);
|
||||
assertThat(result.getUpdatedByName()).isEqualTo("platform_admin");
|
||||
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
|
||||
verify(nodeOutbox).publish(eq(result), argThat(fact ->
|
||||
"FIRSTLEG".equals(fact.getProviderCode())
|
||||
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
|
||||
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
|
||||
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
|
||||
&& Long.valueOf(7L).equals(fact.getOperatorId())
|
||||
&& "platform_admin".equals(fact.getOperatorName())
|
||||
&& fact.getActualAt() != null), eq("ZCSU7881970"));
|
||||
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
|
||||
"AUTO_CREATE".equals(history.getAction())
|
||||
&& history.getFromVersion() == null
|
||||
&& Integer.valueOf(0).equals(history.getToVersion())
|
||||
&& Long.valueOf(0L).equals(history.getOperatorId())
|
||||
&& "SYSTEM".equals(history.getOperatorName())
|
||||
&& Long.valueOf(7L).equals(history.getOperatorId())
|
||||
&& "platform_admin".equals(history.getOperatorName())
|
||||
&& "国内确认出仓自动创建".equals(history.getReason())));
|
||||
}
|
||||
|
||||
@@ -360,6 +363,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||
}
|
||||
|
||||
/** 自动采集推进原节点,但不冒用平台操作账号。 */
|
||||
@Test
|
||||
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
|
||||
CarrierScheduleQueryEntity task = task(0, 0);
|
||||
@@ -377,6 +381,8 @@ class CarrierScheduleQueryServiceTest {
|
||||
assertThat(result.query().query().status()).isEqualTo("READY");
|
||||
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
|
||||
assertThat(result.nodes()).isEmpty();
|
||||
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
|
||||
fact.getOperatorId() == null && fact.getOperatorName() == null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -448,6 +454,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
|
||||
}
|
||||
|
||||
/** 人工实际节点保留操作账号及既有还柜幂等行为。 */
|
||||
@Test
|
||||
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
|
||||
CarrierScheduleQueryEntity task = task(0, 3);
|
||||
@@ -462,6 +469,8 @@ class CarrierScheduleQueryServiceTest {
|
||||
|
||||
assertThat(result.query().query().status()).isEqualTo("STOPPED");
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -488,6 +497,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository).updateState(before, 3, 7L, "admin");
|
||||
}
|
||||
|
||||
/** 柜级补录沿用当前账号,并保持已有跟踪任务和事件投递。 */
|
||||
@Test
|
||||
void containerManualNodeJoinsAndAdvancesExistingCarrierTask() {
|
||||
CarrierScheduleQueryEntity task = task(0, 8);
|
||||
@@ -509,7 +519,8 @@ class CarrierScheduleQueryServiceTest {
|
||||
"PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup"));
|
||||
|
||||
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).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
|
||||
"MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId())
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 使用真实 MyBatis 更新语句验证清空外箱修正,不以 Mock 掩盖 NULL 被忽略的问题。 */
|
||||
class InboundMeasurementItemPersistenceTest {
|
||||
private SqlSession session;
|
||||
private JdbcTemplate sql;
|
||||
private InboundMeasurementRepository repository;
|
||||
|
||||
/** 每项用例使用隔离内存表,仅保留本轮更新涉及的列。 */
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:measurement-clear-" + UUID.randomUUID() + ";MODE=MySQL");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(InboundMeasurementItemMapper.class);
|
||||
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
|
||||
session.getConnection();
|
||||
sql = new JdbcTemplate(ds);
|
||||
sql.execute("CREATE TABLE fl_inbound_measurement_item(id BIGINT PRIMARY KEY, tenant_id BIGINT, "
|
||||
+ "manual_outer_length_cm DECIMAL(12,3), manual_outer_width_cm DECIMAL(12,3), "
|
||||
+ "manual_outer_height_cm DECIMAL(12,3), charge_rule_id BIGINT)");
|
||||
sql.update("INSERT INTO fl_inbound_measurement_item VALUES (88, 10001, 63, 53, 53, 77)");
|
||||
repository = new InboundMeasurementRepository();
|
||||
ReflectionTestUtils.setField(repository, "items", session.getMapper(InboundMeasurementItemMapper.class));
|
||||
}
|
||||
|
||||
/** 关闭隔离数据库的最后一个会话,释放本项测试资源。 */
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (session != null) session.close();
|
||||
}
|
||||
|
||||
/** 单边清空与全部清空都须真正持久化,并可解除已取消的系数规则关联。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"false,true,true", "true,false,true", "true,true,false", "false,false,false"})
|
||||
void persistsClearedWarehouseDimensions(boolean keepLength, boolean keepWidth, boolean keepHeight) {
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setId(88L);
|
||||
item.setTenantId(10001L);
|
||||
item.setManualOuterLengthCm(keepLength ? new BigDecimal("63") : null);
|
||||
item.setManualOuterWidthCm(keepWidth ? new BigDecimal("53") : null);
|
||||
item.setManualOuterHeightCm(keepHeight ? new BigDecimal("53") : null);
|
||||
item.setChargeRuleId(null);
|
||||
|
||||
assertThat(repository.updateItem(item)).isTrue();
|
||||
var stored = sql.queryForMap("SELECT * FROM fl_inbound_measurement_item WHERE id=88");
|
||||
assertThat(stored.get("MANUAL_OUTER_LENGTH_CM")).isEqualTo(keepLength ? new BigDecimal("63.000") : null);
|
||||
assertThat(stored.get("MANUAL_OUTER_WIDTH_CM")).isEqualTo(keepWidth ? new BigDecimal("53.000") : null);
|
||||
assertThat(stored.get("MANUAL_OUTER_HEIGHT_CM")).isEqualTo(keepHeight ? new BigDecimal("53.000") : null);
|
||||
assertThat(stored.get("CHARGE_RULE_ID")).isNull();
|
||||
|
||||
item.setTenantId(20002L);
|
||||
assertThat(repository.updateItem(item)).isFalse();
|
||||
}
|
||||
}
|
||||
+68
-2
@@ -7,12 +7,14 @@ import com.baiyue.firstleg.dto.CartonMeasurementCommand;
|
||||
import com.baiyue.firstleg.dto.CartonMeasurementResult;
|
||||
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
|
||||
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -46,13 +48,18 @@ class CartonMeasurementServiceTest {
|
||||
private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class);
|
||||
private final CustomerDimensionWeightingService dimensionWeighting =
|
||||
new CustomerDimensionWeightingService(dimensionConfigs);
|
||||
private final MeasurementBatchService measurementBatchService = new MeasurementBatchService();
|
||||
private final FbaPricingRepository pricingRepository = mock(FbaPricingRepository.class);
|
||||
private CartonMeasurementService service;
|
||||
private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>();
|
||||
|
||||
@BeforeEach
|
||||
/** 构造真实共享尺寸计算入口,设备回归不以模拟计算结果代替业务算法。 */
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(measurementBatchService, "dimensionWeighting", dimensionWeighting);
|
||||
ReflectionTestUtils.setField(measurementBatchService, "pricingRepository", pricingRepository);
|
||||
service = new CartonMeasurementService(repository, resolverProvider, images, transactions,
|
||||
dimensionWeighting);
|
||||
measurementBatchService);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||
when(resolverProvider.getIfAvailable()).thenReturn(resolver);
|
||||
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
|
||||
@@ -164,7 +171,9 @@ class CartonMeasurementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 超出四档尺寸且未启用调整规则时,仍按设备原尺寸计算。 */
|
||||
void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesActualDimensions() {
|
||||
useDefaultMode("ACTUAL");
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
|
||||
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
|
||||
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
|
||||
@@ -187,7 +196,7 @@ class CartonMeasurementServiceTest {
|
||||
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("60.100");
|
||||
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("50.100");
|
||||
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("50.100");
|
||||
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.151");
|
||||
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.1509");
|
||||
assertThat(insertedItem.get().getOverLimitWarning()).isNull();
|
||||
verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE));
|
||||
}
|
||||
@@ -347,6 +356,61 @@ class CartonMeasurementServiceTest {
|
||||
verify(repository).updateInbound(inbound);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 预置系数记录存在但未启用时,设备首次测量不加边也不乘系数。 */
|
||||
void firstScanUsesActualDimensionsWhenCoefficientIsNotEnabled() {
|
||||
useDefaultMode("ACTUAL");
|
||||
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
|
||||
rule.setLengthFactor(new BigDecimal("1.25"));
|
||||
when(pricingRepository.findDefaultRule(10001L, 10001L)).thenReturn(rule);
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
|
||||
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
|
||||
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
|
||||
service.upsert(10001L, 90001L, acceptanceCommand());
|
||||
|
||||
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("59.500");
|
||||
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("41.200");
|
||||
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("31.300");
|
||||
assertThat(insertedItem.get().getCoefficientMode()).isEqualTo("ACTUAL");
|
||||
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.0768");
|
||||
verify(pricingRepository, never()).findDefaultRule(10001L, 10001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 客户明确启用系数后,设备首次录入与人工批次使用相同系数算法。 */
|
||||
void firstScanAppliesExplicitlyEnabledMultiplyDefault() {
|
||||
useDefaultMode("MULTIPLY");
|
||||
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
|
||||
rule.setId(77L);
|
||||
rule.setTenantId(10001L);
|
||||
rule.setStatus("ACTIVE");
|
||||
rule.setRuleVersion(2);
|
||||
rule.setLengthFactor(new BigDecimal("1.25"));
|
||||
when(pricingRepository.findDefaultRule(10001L, 10001L)).thenReturn(rule);
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
|
||||
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
|
||||
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
|
||||
service.upsert(10001L, 90001L, acceptanceCommand());
|
||||
|
||||
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("74.375");
|
||||
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("51.500");
|
||||
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("39.125");
|
||||
assertThat(insertedItem.get().getCoefficientMode()).isEqualTo("MULTIPLY");
|
||||
assertThat(insertedItem.get().getChargeRuleId()).isEqualTo(77L);
|
||||
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.1499");
|
||||
}
|
||||
|
||||
/** 设置客户默认算法,与是否保存系数规则分开。 */
|
||||
private void useDefaultMode(String mode) {
|
||||
List<CustomerDimensionConfigEntity> rules = dimensionRules(10001L);
|
||||
rules.forEach(rule -> rule.setDefaultCalculationMode(mode));
|
||||
when(dimensionConfigs.findAll(10001L)).thenReturn(rules);
|
||||
}
|
||||
|
||||
private CartonMeasurementCommand command() {
|
||||
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"),
|
||||
new BigDecimal("40.200"), new BigDecimal("30.300"),
|
||||
@@ -388,6 +452,7 @@ class CartonMeasurementServiceTest {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 既有加边用例显式启用 ADD,避免依赖配置存在即生效的旧行为。 */
|
||||
private List<CustomerDimensionConfigEntity> dimensionRules(long tenantId) {
|
||||
return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> {
|
||||
CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity();
|
||||
@@ -395,6 +460,7 @@ class CartonMeasurementServiceTest {
|
||||
rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm());
|
||||
rule.setInnerHeightCm(type.defaultHeightCm());
|
||||
rule.setEdgeAdditionCm(new BigDecimal("2.000"));
|
||||
rule.setDefaultCalculationMode("ADD");
|
||||
return rule;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
+11
-18
@@ -39,15 +39,9 @@ class CustomerDimensionWeightingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void deviceMeasurementsUseActualDimensionsAndAddEachSideWhenRuleMatches() {
|
||||
var weighted = service.load(10001L).resolveDeviceMeasurement(
|
||||
new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10"));
|
||||
|
||||
assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL);
|
||||
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("14.000");
|
||||
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("14.000");
|
||||
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("14.000");
|
||||
assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3");
|
||||
/** 未勾选默认调整算法时,四档尺寸配置不能隐式启用加边。 */
|
||||
void defaultCalculationModeRemainsActualWhenNoAdjustmentWasSelected() {
|
||||
assertThat(service.load(10001L).defaultCalculationMode()).isEqualTo("ACTUAL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,16 +75,15 @@ class CustomerDimensionWeightingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void deviceDimensionsOutsideConfiguredRulesRemainUnchanged() {
|
||||
var weighted = service.load(10001L).resolveDeviceMeasurement(
|
||||
new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50"));
|
||||
/** 默认算法必须在四档配置中一致,防止各录入入口取得不同计算方式。 */
|
||||
void inconsistentCustomerDefaultModesAreRejected() {
|
||||
List<CustomerDimensionConfigEntity> rules = defaultRules(10001L);
|
||||
rules.get(0).setDefaultCalculationMode("MULTIPLY");
|
||||
when(repository.findAll(10001L)).thenReturn(rules);
|
||||
|
||||
assertThat(weighted.boxType()).isNull();
|
||||
assertThat(weighted.edgeAdditionCm()).isNull();
|
||||
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("60.001");
|
||||
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("50.000");
|
||||
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("50.000");
|
||||
assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
|
||||
assertThatThrownBy(() -> service.load(10001L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("默认箱规计算方式配置不一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
|
||||
@@ -70,6 +71,87 @@ class FbaPricingServiceTest {
|
||||
@MockBean private ContainerTierAmountAllocator containerTierAllocator;
|
||||
@Resource private FbaPricingService service;
|
||||
|
||||
@Test
|
||||
/** 预览只按当前选择值估价,不复用旧费用,也不产生任何计费写入。 */
|
||||
void previewsSelectedOuterVolumeWithoutWritingFeeState() {
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("570.500", "3.6894"));
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(901L);
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setOuterLengthCm(new BigDecimal("63"));
|
||||
item.setOuterWidthCm(new BigDecimal("53"));
|
||||
item.setOuterHeightCm(new BigDecimal("53"));
|
||||
item.setCalculationRuleVersion("WAREHOUSE_OUTER_OVERRIDE_V1");
|
||||
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
|
||||
when(measurementRepository.listItems(TENANT, 901L)).thenReturn(List.of(item));
|
||||
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
|
||||
estimate("KG", "650.298000", "13.6", "8844.05", "0"));
|
||||
|
||||
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
|
||||
new BigDecimal("570.500"), new BigDecimal("3.8940"), "ACTUAL", "OUTER", null));
|
||||
|
||||
assertThat(preview.actualVolumeCbm()).isEqualByComparingTo("3.8940");
|
||||
assertThat(preview.chargeableQuantity()).isEqualByComparingTo("650.298000");
|
||||
assertThat(preview.totalAmount()).isEqualByComparingTo("8844.05");
|
||||
assertThat(preview.priceMatched()).isTrue();
|
||||
verify(priceBookEstimator).estimate(eq(TENANT), argThat(request ->
|
||||
request.actualWeightKg().compareTo(new BigDecimal("570.5")) == 0
|
||||
&& request.actualVolumeCbm().compareTo(new BigDecimal("3.894")) == 0));
|
||||
verify(repository, never()).insertFee(any());
|
||||
verify(repository, never()).insertIdempotency(any());
|
||||
verify(repository, never()).nextFeeVersion(anyLong(), anyLong());
|
||||
verify(repository, never()).markInboundCalculated(anyLong(), anyLong(), any(), any(), any(), any(), any());
|
||||
org.mockito.Mockito.verifyNoInteractions(financeOutbox, feeDetails);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** FBA 未命中价格时仍返回服务端计费量,供人工单价预览使用。 */
|
||||
void previewsManualQuantityWhenPriceIsMissing() {
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("570.500", "3.6894"));
|
||||
when(priceBookEstimator.estimate(anyLong(), any()))
|
||||
.thenThrow(new BizException(ResultCode.NOT_FOUND, "价格未命中"));
|
||||
|
||||
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
|
||||
new BigDecimal("570.5"), new BigDecimal("3.894"), null, null, null));
|
||||
|
||||
assertThat(preview.priceMatched()).isFalse();
|
||||
assertThat(preview.unitPrice()).isNull();
|
||||
assertThat(preview.chargeableQuantity()).isEqualByComparingTo("650.298000");
|
||||
assertThat(preview.additionalAmount()).isZero();
|
||||
assertThat(preview.totalAmount()).isNull();
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 外箱快照缺失时拒绝预览,不把当前规则的前端推导结果视作已保存事实。 */
|
||||
void rejectsOuterPreviewWithoutSavedMeasurementSnapshot() {
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("100", "1"));
|
||||
assertThatThrownBy(() -> service.preview(TENANT, INBOUND,
|
||||
new FeePreviewRequest(NOW, new BigDecimal("100"), BigDecimal.ONE, "ACTUAL", "OUTER", null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("测量数据不存在");
|
||||
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox, feeDetails);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 预览不能读取其他客户的入库单。 */
|
||||
void rejectsCrossCustomerPreview() {
|
||||
assertThatThrownBy(() -> service.preview(10002L, INBOUND, new FeePreviewRequest(NOW, null, null, null, null, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("入库单不存在");
|
||||
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox, feeDetails);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 人工单价的金额也由服务端精确取整,不在页面重复实现舍入。 */
|
||||
void previewsManualUnitPriceUsingExactMoneyRounding() {
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("1", "0.001"));
|
||||
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(estimate("KG", "1", "2", "2", "0"));
|
||||
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
|
||||
BigDecimal.ONE, new BigDecimal("0.001"), null, null, new BigDecimal("1.005")));
|
||||
assertThat(preview.unitPrice()).isEqualByComparingTo("2");
|
||||
assertThat(preview.totalAmount()).isEqualByComparingTo("1.01");
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("客户B不能读取客户A的费用快照")
|
||||
void rejectsCrossCustomerFeeLookup() {
|
||||
|
||||
+129
-3
@@ -52,6 +52,7 @@ class MeasurementBatchServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 未选择调整算法时,第三方与人工批次均保存实际外箱快照。 */
|
||||
void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() {
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001",
|
||||
@@ -73,10 +74,10 @@ class MeasurementBatchServiceTest {
|
||||
assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000");
|
||||
assertThat(items.getValue().get(0).getCalculationRuleVersion())
|
||||
.isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1");
|
||||
assertThat(items.getValue().get(0).getCoefficientMode()).isNull();
|
||||
assertThat(items.getValue().get(0).getCoefficientMode()).isEqualTo("ACTUAL");
|
||||
assertThat(items.getValue().get(0).getChargeRuleId()).isNull();
|
||||
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000");
|
||||
verify(pricingRepository).findDefaultRule(1L, 1L);
|
||||
verify(pricingRepository, never()).findDefaultRule(1L, 1L);
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
@@ -173,8 +174,78 @@ class MeasurementBatchServiceTest {
|
||||
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
/** 全部清空人工修正后,计算与批次汇总须恢复设备实测尺寸。 */
|
||||
@Test
|
||||
void clearsWarehouseOverridesAndRecalculatesFromDeviceDimensions() {
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(100L);
|
||||
measurement.setTenantId(1L);
|
||||
measurement.setInboundId(10L);
|
||||
measurement.setVersion(3);
|
||||
measurement.setCreatedBy(9L);
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setId(200L);
|
||||
item.setTenantId(1L);
|
||||
item.setItemNo("B1");
|
||||
item.setPackageCount(22);
|
||||
item.setLengthCm(new BigDecimal("62"));
|
||||
item.setWidthCm(new BigDecimal("52"));
|
||||
item.setHeightCm(new BigDecimal("52"));
|
||||
item.setManualOuterLengthCm(new BigDecimal("63"));
|
||||
item.setManualOuterWidthCm(new BigDecimal("53"));
|
||||
item.setManualOuterHeightCm(new BigDecimal("53"));
|
||||
item.setCoefficientMode("ACTUAL");
|
||||
when(repository.updateItem(item)).thenReturn(true);
|
||||
|
||||
service.recalculateOuterDimensions(1L, measurement, List.of(item), 200L, null, null, null);
|
||||
|
||||
assertThat(item.getManualOuterLengthCm()).isNull();
|
||||
assertThat(item.getManualOuterWidthCm()).isNull();
|
||||
assertThat(item.getManualOuterHeightCm()).isNull();
|
||||
assertThat(item.getOuterLengthCm()).isEqualByComparingTo("62.000");
|
||||
assertThat(item.getOuterWidthCm()).isEqualByComparingTo("52.000");
|
||||
assertThat(item.getOuterHeightCm()).isEqualByComparingTo("52.000");
|
||||
assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("3.6894");
|
||||
verify(repository).updateItem(item);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 客户未启用算法时,预置系数不能放大仓库修正的实际外箱体积。 */
|
||||
void savedCoefficientDoesNotOverrideActualDefaultMode() {
|
||||
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
|
||||
rule.setId(77L);
|
||||
rule.setTenantId(1L);
|
||||
rule.setCustomerId(1L);
|
||||
rule.setStatus("ACTIVE");
|
||||
rule.setDefaultRule(true);
|
||||
rule.setLengthFactor(new BigDecimal("1.25"));
|
||||
when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule);
|
||||
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
|
||||
80001L, "001", "A", 22, new BigDecimal("62"), new BigDecimal("52"),
|
||||
new BigDecimal("52"), 500L, null, new BigDecimal("63"), new BigDecimal("53"),
|
||||
new BigDecimal("53"), null, null, "仓库修正");
|
||||
|
||||
service.saveBatch(new MeasurementBatchCommand(
|
||||
1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
|
||||
LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)));
|
||||
|
||||
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
|
||||
org.mockito.ArgumentMatchers.eq(100L), items.capture());
|
||||
InboundMeasurementItemEntity calculated = items.getValue().get(0);
|
||||
assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("63.000");
|
||||
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("53.000");
|
||||
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("53.000");
|
||||
assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("3.8940");
|
||||
assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL");
|
||||
assertThat(calculated.getChargeRuleId()).isNull();
|
||||
verify(pricingRepository, never()).findDefaultRule(1L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 只有客户明确启用系数算法,才自动选取当前默认系数配置。 */
|
||||
void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() {
|
||||
useDefaultMode("MULTIPLY");
|
||||
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
|
||||
rule.setId(77L);
|
||||
rule.setTenantId(1L);
|
||||
@@ -212,7 +283,9 @@ class MeasurementBatchServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 系数模式按当前尺寸匹配档位,同一系数作用于三个维度。 */
|
||||
void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() {
|
||||
useDefaultMode("MULTIPLY");
|
||||
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
|
||||
rule.setId(78L);
|
||||
rule.setTenantId(1L);
|
||||
@@ -251,6 +324,7 @@ class MeasurementBatchServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 无默认系数且未选择调整算法时,显式保存 ACTUAL 计算方式。 */
|
||||
void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() {
|
||||
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
|
||||
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
|
||||
@@ -269,7 +343,7 @@ class MeasurementBatchServiceTest {
|
||||
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000");
|
||||
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000");
|
||||
assertThat(calculated.getChargeRuleId()).isNull();
|
||||
assertThat(calculated.getCoefficientMode()).isNull();
|
||||
assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL");
|
||||
assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
|
||||
}
|
||||
|
||||
@@ -530,6 +604,58 @@ class MeasurementBatchServiceTest {
|
||||
assertThat(saved.getDeviceMeasuredAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 行明确选择实测算法后,即使客户默认系数已启用,也不能被覆盖。 */
|
||||
void explicitActualModeOverridesCustomerMultiplyDefault() {
|
||||
useDefaultMode("MULTIPLY");
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setTenantId(1L);
|
||||
item.setItemNo("B1");
|
||||
item.setPackageCount(22);
|
||||
item.setLengthCm(new BigDecimal("62"));
|
||||
item.setWidthCm(new BigDecimal("52"));
|
||||
item.setHeightCm(new BigDecimal("52"));
|
||||
item.setManualOuterLengthCm(new BigDecimal("63"));
|
||||
item.setManualOuterWidthCm(new BigDecimal("53"));
|
||||
item.setManualOuterHeightCm(new BigDecimal("53"));
|
||||
item.setCoefficientMode("ACTUAL");
|
||||
|
||||
service.calculateCartonDimensions(item);
|
||||
|
||||
assertThat(item.getTotalVolumeCbm()).isEqualByComparingTo("3.8940");
|
||||
assertThat(item.getCoefficientMode()).isEqualTo("ACTUAL");
|
||||
assertThat(item.getChargeRuleId()).isNull();
|
||||
verify(pricingRepository, never()).findDefaultRule(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 客户明确启用加边规则后,未指定行算法的设备及人工录入均采用该默认方式。 */
|
||||
void appliesCustomerAddDefaultWhenRowModeIsNotSelected() {
|
||||
useDefaultMode("ADD");
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setTenantId(1L);
|
||||
item.setItemNo("B1");
|
||||
item.setPackageCount(1);
|
||||
item.setLengthCm(new BigDecimal("30"));
|
||||
item.setWidthCm(new BigDecimal("20"));
|
||||
item.setHeightCm(new BigDecimal("20"));
|
||||
|
||||
service.calculateCartonDimensions(item);
|
||||
|
||||
assertThat(item.getOuterLengthCm()).isEqualByComparingTo("34.000");
|
||||
assertThat(item.getOuterWidthCm()).isEqualByComparingTo("24.000");
|
||||
assertThat(item.getOuterHeightCm()).isEqualByComparingTo("24.000");
|
||||
assertThat(item.getCoefficientMode()).isEqualTo("ADD");
|
||||
verify(pricingRepository, never()).findDefaultRule(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
/** 为用例设置客户真正启用的算法,独立于系数配置是否存在。 */
|
||||
private void useDefaultMode(String mode) {
|
||||
List<CustomerDimensionConfigEntity> rules = dimensionRules(1L);
|
||||
rules.forEach(rule -> rule.setDefaultCalculationMode(mode));
|
||||
when(dimensionConfigs.findAll(1L)).thenReturn(rules);
|
||||
}
|
||||
|
||||
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
|
||||
return new MeasurementBatchCommand.Item(
|
||||
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
|
||||
|
||||
@@ -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);
|
||||
@@ -30,8 +30,8 @@ DROP PROCEDURE check_overseas_fee_permission_identity;
|
||||
START TRANSACTION;
|
||||
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,deleted)
|
||||
VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=0,status='ACTIVE',deleted=0;
|
||||
VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=5105000,sort_no=60,status='ACTIVE',deleted=0;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,r.id,5117200,CURRENT_TIMESTAMP(3) FROM iam_role r
|
||||
|
||||
@@ -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/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`。
|
||||
|
||||
@@ -68,18 +68,19 @@
|
||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` |
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
|
||||
| 海外业务资金指令与结果 | `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` |
|
||||
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
|
||||
| 海外仓报价、月度汇率、车辆主数据 | `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/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不直读车辆持久化适配器 |
|
||||
| 整车调度与理货审核 | `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`核对实收来源 |
|
||||
| 拣货前计划容量与逐段余位 | `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容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
||||
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
|
||||
| 实物装载、逐段容量与双端整车发车 | `dispatch/service/DispatchLoadingDepartureService.java`、`DispatchLoadingDocumentService.java`;双端使用 `recordVersion`;装车确认、照片齐全后的自动派送、容量与幂等只查当前排车主契约,无公开 `/depart` |
|
||||
| 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
|
||||
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
|
||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
|
||||
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购、换标、人工海外费用和仓租通过 `WALLET_COMMAND_REQUESTED` 请求 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
|
||||
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 |
|
||||
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,部分出库只承担原批次份额,余货不重置库龄。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存,按真实出库部分生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
|
||||
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,中转部分出库按原批次份额计费,退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
|
||||
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
|
||||
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
|
||||
@@ -100,7 +100,7 @@
|
||||
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
|
||||
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
|
||||
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
|
||||
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
|
||||
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。全车正常派送完成自动结案,结案不改客户状态/费用;结案与车/司机释放占用同事务,提货全部到仓、本土卡车完成也直接释放,不需二次确认。自动释放记录运输完成时刻及路线终点,保留车辆主档状态。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
|
||||
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
|
||||
- 排车执行与 `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/`。
|
||||
@@ -136,7 +136,7 @@
|
||||
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
||||
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
||||
- 一件代发废弃:申请只锁箱 `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`。
|
||||
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
|
||||
|
||||
@@ -171,3 +171,5 @@
|
||||
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
|
||||
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
|
||||
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
|
||||
|
||||
- 废弃PDA:无箱唛关联的普通库存先扫本单托盘,再扫描现场箱唛自动累计各明细实际箱数,禁止手填数量绕过扫码。现场箱唛独立保存到 actual_carton_marks_json,不改客户选箱快照;提交校验数量一致、同托及同单跨托盘不重复,失败不落盘、可刷新重扫。客户/平台详情返回 actualCartonMarks、palletNo、实际箱数和原备注。数量申请达到目标后释放未用预留。
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
### 当前维护入口(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节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。
|
||||
|
||||
- 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。
|
||||
@@ -28,6 +30,10 @@
|
||||
|
||||
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
|
||||
|
||||
### 客户原单运输照片读取边界(2026-11-03)
|
||||
|
||||
客户订单域通过 `trucking.port.MerchantTransportPhotos` 声明只读照片查询和内容接口,散板及卡车客户 Controller 复用已有原单列表权限与认证客户范围。仅 `dispatch.transport.MerchantTransportPhotoService` 实现此端口,按同客户商业原单的有效排车关联读取散板当前货物/审核轮次照片或本土卡车取货/完成凭证;下载再次核对当前绑定。结构门禁只登记此实现类到端口的精确依赖,不开放客户订单 Controller 到排车具体 Service、Repository 或 Mapper,也不新增包循环。
|
||||
|
||||
### 装车资料触发派送边界(2026-10-21)
|
||||
|
||||
`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。
|
||||
|
||||
@@ -4,24 +4,30 @@
|
||||
|
||||
## 首版规则
|
||||
|
||||
仓租项目 `FEE_TYPE_077` 同时支持手动添加。尾程订单与公共费用编辑窗口均展示有效仓租项目;人工仓租沿普通人工费用的审核、结算方式及资金身份处理,可在审核前调整。自动仓租仍以 `createdBy=STORAGE_RENT` 和仓租项目共同识别,继续走出库计租、预占及仓租审核流程;同单同项目补收沿用已有费用提示和原因必填规则。
|
||||
|
||||
费用入口为 Admin「海外业务 → 海外费用管理」,仅保留「费用明细」与「仓租库存」两个页签。仓租库存查看相关单据、入库批次、库龄、剩余数量及出库数量记录,不展示金额和审核、调价入口。仓租金额及入库/出库时间、出库批次、计算金额、应扣及实扣金额在费用明细详情查询,多入库批次分别展示实际入库时间。审核与调价统一在对应尾程单据详情的「费用明细」办理,审核前调价必须填写说明。复用 Firstleg 公共费用项目,仓租项目为 `FEE_TYPE_077 / 仓储费`;不建立第二套海外费用目录。人工费用选择真实客户、当前服务业务单、项目、金额与币种后创建,详情保留必要的审核和资金状态。
|
||||
|
||||
海外自动仓租的详情、审核和调价共用计算过程展示:来源批次、入库与出库时间、计费规则、实际托数或出库CBM、原库龄、此前已计费天数、各档实际计费日区间、天数×计租基数×快照单价及原币小计,并显示原币金额×保存汇率的人民币换算。新费用把基数与阶梯结果固化在原预占快照中;历史费用只按原报价快照和完整原批次基数解释,旧比例费用明确标识原口径,缺少依据不按最新报价猜算。各档金额保留原始计算精度,费用总额按分四舍五入。
|
||||
|
||||
头程附加费审核后推送 Finance 账单。其余海外人工应收创建时冻结余额及可用授信,审核通过后扣款,Finance 成功扣款回执后登记本地已收费用,直接显示已完成,不再推送财务账单;驳回、取消释放冻结。在途回执、失败重试与同键重放不能重复扣款。车辆或同车运输不能代替每笔费用对应的客户和业务订单。
|
||||
|
||||
现有尾程订单没有权威的“原报价已含派送费”字段。首版新增头程派送费仍由审核人员核对原报价,已包含则驳回该费用;系统尚不支持自动判断报价包含关系,审核提示不能替代人工核对。
|
||||
|
||||
头程货最终目的地为自有海外仓、仓储转运(含外来货)以及退货建立计租批次。仓租全部使用余额及可用授信。实际 PDA 入库开始、实际 PDA 出库停止本次出库份额计租,每24小时计一天,不足24小时按一天。余货保持原入库时刻,不重置免费期;移库、换标和拆合托不新增免费期。
|
||||
头程货最终目的地为自有海外仓、仓储转运(含外来货)以及退货建立计租批次。仓租全部使用余额及可用授信。实际 PDA 入库开始,每24小时计一天,不足24小时按一天。中转按实际出库份额停止计租;退货按原托整托收费,原托剩余一箱仍计一托,直到清空才停止计租。余货保持原入库时刻,不重置免费期;移库、换标和拆合托不新增免费期。
|
||||
|
||||
头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。
|
||||
|
||||
| 规则 | 计费阶梯(USD) | 数量口径 |
|
||||
|---|---|---|
|
||||
| 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 |
|
||||
| 退货仓租 | 第1–30天1.5;31–60天2;61–90天3;91天起4 | 原始托数向上取整,按本次出库占原托比例分摊 |
|
||||
| 退货仓租 | 第1–30天1.5;31–60天2;61–90天3;91天起4 | USD/原托/天,不足一托按一托;部分出库不按件分摊,同托同计租日仅收一次 |
|
||||
|
||||
自动计租优先读取现有客户仓租配置;无客户配置时使用上述两张报价表。USD 配置缺少整组中转或退货阶梯时,缺失组补用对应默认报价;非 USD 配置不得直接补用美元价格。已配置阶梯须从第1天连续覆盖至无限期,有限尾阶梯、缺段、负价或不支持的币种均阻断计价,不将配置错误或服务异常当作默认报价。
|
||||
|
||||
每次出库资金预校验保存客户、配置编号及版本、币种、完整阶梯和计算结果快照。有效预占和实际扣款使用原快照;修改配置不重算已冻结或已生成的费用。后续新预占按最新客户配置计算本次出库份额的全部库龄,仍保留最初入库时间;本版不把一次出库按历次调价日期再拆段计费。过期或货物、计租天数等依据变化时须重新预校验。
|
||||
每次出库资金预校验保存客户、配置编号及版本、币种、完整阶梯和计算结果快照。有效预占和实际扣款使用原快照;修改配置不重算已冻结或已生成的费用。中转新预占按最新客户配置计算本次出库份额的全部库龄;退货新预占只计算原托尚未生成仓租的期间。两者都保留最初入库时间,按原库龄匹配阶梯,不按历次调价日期再拆段计费。过期或货物、计租天数、退货原托已计费截止日变化时须重新预校验并释放旧冻结。
|
||||
|
||||
退货原托的 `charged_days` 在实际出库与待审核费用生成的同一事务内推进,预占、取消、资金回执不推进。待审核费用已覆盖的期间不重复生成;审核减免或驳回属于该期间的人工处理,不因后续出库再次收取。一个扫码含同原托多个箱或 SKU 时先按原托去重;多原托分别收费。商品份额仅追踪库存数量与来源。并发扫码发现其他出库已推进截止日时阻断旧预占,重新预校验后只冻结未覆盖期间的金额。
|
||||
|
||||
原币金额与人民币结算额分别保留,冻结时固定汇率快照。无有效汇率时不得将美元直接作为人民币扣款。免费期零仓租保留出库记录,不要求汇率、不冻结资金。仓租自动计算,出库仅登记待审核费用并保留冻结;批准后沿原资金身份按实额扣款,驳回释放原冻结。订单费用和海外费用管理均展示原批次实际入库、实际出库、原计算金额、应扣金额及实扣金额。审核前允许原币金额调整且说明必填,保存版本;调低释放多余占用,调高由Finance结算时原子检查差额,余额不足保留原冻结,不登记实扣。原零元改非零先补冻结,成功后才能审核。其余客户收费人工录入并审核。历史已扣费用保留结果,不补审、不重复扣款。
|
||||
|
||||
@@ -30,12 +36,13 @@
|
||||
## 计租验收样例
|
||||
|
||||
1. 中转批次10箱共2CBM,第10天出4箱:3个收费日 × 0.8CBM × 0.8 = **1.92 USD**;余6箱第31天出库:(23×0.8+1×1) × 1.2CBM = **23.28 USD**。余货仍从最初入库计龄。
|
||||
2. 退货原批次不足一托,100件按1托,第10天出40件:10×1.5×40%=**6 USD**;第40天出余60件:(30×1.5+10×2)×60%=**39 USD**。第二次出库不再次将剩余货物进位到1托。
|
||||
2. 退货原批次不足一托,100件按1托,第10天出40件:10×1.5=**15 USD**;同一计租日再出部分货不重复收费;剩余一箱仍为一托,第40天清空时补收第11–40天:20×1.5+10×2=**50 USD**,整托40天合计**65 USD**。第61天或第91天仍有货时,新增日分别按3或4 USD/托计费。
|
||||
3. 入库后恰好24小时为1天,24小时加1毫秒为2天;恰好7天仍免费,7天加1毫秒进入第8天。对金额先按份额精算,完整收费命令汇总后按结算精度取整。
|
||||
|
||||
## 发布配套
|
||||
|
||||
- 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。
|
||||
- 退货整托口径更新:先对已有 Lastleg 库执行 `deploy/sql/return-storage-rent-whole-pallet.sql` 补充 `charged_days`,再配套发布 Lastleg。新库两份基线已同步。切换时暂停相关出库并取消未消费预占;既有比例计费记录需逐单确认处理方式,脚本不回填计费截止日、不追收或修改既有费用和资金。当前仅准备源码和脚本,未据此认定 TEST 已生效。
|
||||
- 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。
|
||||
- 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。
|
||||
- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。指定 TEST 先使用[表与审核人字段补齐](../../deploy/sql/overseas-fees-storage-rent-structure-add.sql),再使用[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql);精确范围与失败语义只维护在[数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。
|
||||
@@ -70,7 +77,7 @@
|
||||
| 人工海外收费 | 创建只冻结;审核后恰好扣一次;驳回/取消释放;不足余额但授信足够可成功,二者合计不足明确失败 |
|
||||
| 头程附加费 | 审核前不入账单,批准后回到正确头程来源且不扣钱包;人工核对原报价,已含派送费的新增收费须驳回 |
|
||||
| 仓租原生入库 | 头程自有仓、外来转运和退货实际扫码均生成正确原批次;非计租范围不误收 |
|
||||
| 部分及分批出库 | 原托比例、阶梯、库龄和余量符合上例;整托/多箱/混SKU全部来源核对后才能扣库存 |
|
||||
| 部分及分批出库 | 中转保持来源比例;退货余一箱仍按整托,新期间按原库龄阶梯,同托同日不重复收费;整托/多箱/混SKU全部来源核对后才能扣库存 |
|
||||
| 客户价格与默认报价 | 客户专价优先;无配置按两张默认报价;接口超时或无效阶梯不允许默认为免费或默认报价 |
|
||||
| 调价与币种 | 有效预占及已生成费用不受调价影响,新预占使用新版本;USD、CNY 原币及分币种汇总准确,不将混合币种直接相加 |
|
||||
| 资金门禁 | 未冻结成功、资金不足、过期、未知网络结果均不放行库存写;成功后仅本次实际出库份额扣款 |
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
| 主状态 | 编码 | 操作 |
|
||||
|---|---|---|
|
||||
| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 |
|
||||
| 资金处理中 | `FUNDING_PENDING` | 订单已受理,等待Finance冻结回执 |
|
||||
| 资金失败 | `FUNDING_FAILED` | 展示失败原因,可在原订单重试 |
|
||||
| 待审核 | `PENDING_REVIEW` | 冻结成功后,平台审核并确定采购仓 |
|
||||
| 收货中 | `RECEIVING` | 平台分批登记数量和照片 |
|
||||
| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 |
|
||||
| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 |
|
||||
@@ -14,13 +16,13 @@
|
||||
|
||||
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
|
||||
|
||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。重新上传成功后清除当前驳回原因,本轮其他批次仍可继续换图;`photosNeedUpdate` 只标识仍绑定本轮驳回照片,不作为重新提交门禁。再次提交采购完成成功后清除上一轮驳回原因和照片基线,待客户确认及确认后的页面均不展示旧驳回提示。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||
|
||||
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
|
||||
|
||||
主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。
|
||||
|
||||
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。每次创建出库批次前,本采购主单必须至少有一项未作废应收费用,且全部未作废应收费用均须有效并审核通过;缺少费用、待审核或驳回时提示并阻止出库,前后端共同校验。应付与已作废费用不能替代应收费用明细;原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
|
||||
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。费用明细缺失或尚未审核时可提示运营补充,但不阻止创建出库批次;出库资格只由采购状态、可用数量、物流跟踪号及操作权限决定。原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
|
||||
|
||||
客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。
|
||||
|
||||
@@ -28,13 +30,13 @@
|
||||
|
||||
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
|
||||
|
||||
- 创建采购单后为待审核,审核后直接收货中。
|
||||
- 客户创建采购单后先资金处理中,冻结成功进入待审核,失败保留原单重试;审核后直接收货中。
|
||||
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
|
||||
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
|
||||
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
|
||||
- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。
|
||||
- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。
|
||||
- 无费用、仅作废费用、仅应付费用或任一应收尚未审核通过时,不能创建出库;全部有效应收审核通过后允许出库。
|
||||
- 无费用、费用尚未审核或驳回时均允许创建出库;页面提示不影响出库操作。
|
||||
|
||||
自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。
|
||||
|
||||
@@ -46,3 +48,12 @@
|
||||
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
|
||||
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
|
||||
- 未执行环境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真实回执与人工截图验收仍待环境联调。
|
||||
|
||||
@@ -76,12 +76,14 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|
||||
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
|
||||
'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService',
|
||||
'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService',
|
||||
'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController',
|
||||
'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source)
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort'
|
||||
|| [
|
||||
'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
|
||||
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
|
||||
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.FleetTripModels',
|
||||
'TransportTaskProjection->trip.TransportTripChanged',
|
||||
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
|
||||
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
|
||||
'TransportTaskService->photo.FleetResourcePhotoModels',
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 读取原货物的货件资料,始终按客户及订单身份隔离。 */
|
||||
@Repository
|
||||
public class AppointmentCargoReferenceRepository {
|
||||
private final AppointmentCargoVerificationMapper mapper;
|
||||
|
||||
/** 复用预约货物 Mapper,不跨服务或跨库补读申报。 */
|
||||
public AppointmentCargoReferenceRepository(AppointmentCargoVerificationMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 一次批读本次明确货源的原货件号,未实收建单的外接货保持空列表。 */
|
||||
public Map<List<String>, List<String>> originalReferences(List<AppointmentSourceService.Source> sources) {
|
||||
var orders = sources.stream().filter(source -> source.orderId() != null)
|
||||
.map(source -> new AppointmentCoverageMapper.OrderRef(source.customerId(), source.orderId())).distinct().toList();
|
||||
var byOrder = orders.isEmpty() ? Map.<AppointmentCoverageMapper.OrderRef, List<AppointmentCargoVerificationMapper.ShipmentReference>>of()
|
||||
: mapper.originalShipmentReferences(orders).stream().collect(Collectors.groupingBy(
|
||||
row -> new AppointmentCoverageMapper.OrderRef(row.customerId(), row.orderId())));
|
||||
var result = new HashMap<List<String>, List<String>>();
|
||||
for (var source : sources) {
|
||||
var references = byOrder.getOrDefault(new AppointmentCoverageMapper.OrderRef(source.customerId(), source.orderId()), List.of())
|
||||
.stream().map(AppointmentCargoVerificationMapper.ShipmentReference::shipmentReference).filter(Objects::nonNull)
|
||||
.map(String::trim).filter(value -> !value.isEmpty()).distinct().sorted().toList();
|
||||
result.put(List.of(source.customerId(), source.sourceKey()), references);
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
}
|
||||
+17
@@ -8,6 +8,23 @@ import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface AppointmentCargoVerificationMapper extends BaseMapper<AppointmentCargoVerification> {
|
||||
record ShipmentReference(String customerId, Long orderId, String shipmentReference) { }
|
||||
|
||||
/** 按已授权的客户与原订单读取原有货件号和追踪号,仓储转运读取其正式来源。 */
|
||||
@Select({"<script>",
|
||||
"SELECT o.customer_id AS customerId,o.id AS orderId,",
|
||||
"CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.fba_shipment_no ELSE d.shipment_no END AS shipmentReference ",
|
||||
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id AND o.order_type<>'WAREHOUSE_TRANSFER' ",
|
||||
AppointmentCoverageMapper.TRANSFER_JOIN,
|
||||
"WHERE <foreach collection='orders' item='o' open='(' separator=' OR ' close=')'>(o.id=#{o.orderId} AND o.customer_id=#{o.customerId})</foreach>",
|
||||
"UNION ALL SELECT o.customer_id AS customerId,o.id AS orderId,",
|
||||
"CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.tracking_no ELSE d.fba_tracking_no END AS shipmentReference ",
|
||||
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id AND o.order_type<>'WAREHOUSE_TRANSFER' ",
|
||||
AppointmentCoverageMapper.TRANSFER_JOIN,
|
||||
"WHERE <foreach collection='orders' item='o' open='(' separator=' OR ' close=')'>(o.id=#{o.orderId} AND o.customer_id=#{o.customerId})</foreach>",
|
||||
"</script>"})
|
||||
java.util.List<ShipmentReference> originalShipmentReferences(@Param("orders") java.util.List<AppointmentCoverageMapper.OrderRef> orders);
|
||||
|
||||
/** Same canonical source row used by allocation; flush pre-lock observations before re-reading. */
|
||||
@Select("SELECT id FROM ll_order WHERE id=#{orderId} FOR UPDATE")
|
||||
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
|
||||
|
||||
+20
-9
@@ -21,10 +21,12 @@ public class AppointmentCargoVerificationService {
|
||||
private final AppointmentSourceService sources;
|
||||
private final AppointmentCargoVerificationMapper mapper;
|
||||
private final ObjectMapper json;
|
||||
private final AppointmentCargoReferenceRepository references;
|
||||
|
||||
/** 核实记录与原货物资料分别读取,已有货件号无需重复录入。 */
|
||||
public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper,
|
||||
ObjectMapper json) {
|
||||
this.sources=sources; this.mapper=mapper; this.json=json;
|
||||
ObjectMapper json, AppointmentCargoReferenceRepository references) {
|
||||
this.sources=sources; this.mapper=mapper; this.json=json; this.references=references;
|
||||
}
|
||||
public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion,
|
||||
@Min(0) int expectedVersion, @Positive Integer packageCount,
|
||||
@@ -34,7 +36,7 @@ public class AppointmentCargoVerificationService {
|
||||
Integer packageCount, List<String> shipmentReferences, String remark,
|
||||
String operatorName, Instant verifiedAt) { }
|
||||
public record Context(AppointmentSourceService.Source source, Verification verification,
|
||||
long allocatedPackages, long unknownAllocationCount, Integer effectivePackages,
|
||||
long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, List<String> effectiveShipmentReferences,
|
||||
Long availablePackages, boolean sourceChanged, List<String> allowedActions,
|
||||
List<String> blockReasons) { }
|
||||
|
||||
@@ -50,12 +52,13 @@ public class AppointmentCargoVerificationService {
|
||||
public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) {
|
||||
public Context requireContext() { if(error!=null) throw error; return context; }
|
||||
}
|
||||
/** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */
|
||||
/** 在本次批读内共用来源、仓库及原货件资料,不缓存可变业务事实。 */
|
||||
public List<Inspection> inspectBatch(List<AppointmentCoverageService.SourceRef> refs) {
|
||||
var result=new ArrayList<Inspection>();
|
||||
for(int start=0;start<refs.size();start+=100) {
|
||||
var batch=refs.subList(start,Math.min(start+100,refs.size()));
|
||||
var resolved=sources.inspectBatch(batch);
|
||||
var originals=originalShipmentReferences(resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::source).toList());
|
||||
var valid=resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::reference).toList();
|
||||
var latest=new HashMap<List<String>,AppointmentCargoVerification>();
|
||||
var allocations=new HashMap<List<String>,AppointmentCargoVerificationMapper.Allocation>();
|
||||
@@ -65,18 +68,19 @@ public class AppointmentCargoVerificationService {
|
||||
}
|
||||
for(var resolution:resolved) {
|
||||
var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey());
|
||||
result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))):null,resolution.error()));
|
||||
result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0)),originals.getOrDefault(key,List.of())):null,resolution.error()));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
/** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */
|
||||
/** 使用已授权来源读取本票核实记录及原有货件资料。 */
|
||||
public Context inspect(AppointmentSourceService.Source source) {
|
||||
var row=mapper.latest(source.customerId(),source.sourceKey());
|
||||
var allocation=mapper.allocated(source.customerId(),source.sourceKey());
|
||||
return inspect(source,row,allocation);
|
||||
return inspect(source,row,allocation,originalShipmentReferences(List.of(source)).getOrDefault(List.of(source.customerId(),source.sourceKey()),List.of()));
|
||||
}
|
||||
private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation) {
|
||||
/** 已保存的货件核实值优先;尚未补录货件时带入原货物的已有编号。 */
|
||||
private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation,List<String> originals) {
|
||||
boolean changed=row!=null && changed(row,source);
|
||||
Integer total=row==null?source.packages():row.getPackageCount();
|
||||
var reasons=new ArrayList<>(source.blockReasons());
|
||||
@@ -88,10 +92,17 @@ public class AppointmentCargoVerificationService {
|
||||
?null:total.longValue()-allocation.packages();
|
||||
// Actions describe this endpoint's business capability; controller permissions remain mandatory.
|
||||
var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.<String>of();
|
||||
return new Context(source,row==null?null:view(row),allocation.packages(),allocation.unknownItems(),total,
|
||||
var verified=row==null?null:view(row);
|
||||
var effectiveReferences=verified==null || verified.shipmentReferences().isEmpty()?originals:verified.shipmentReferences();
|
||||
return new Context(source,verified,allocation.packages(),allocation.unknownItems(),total,effectiveReferences,
|
||||
available,changed,actions,List.copyOf(reasons));
|
||||
}
|
||||
|
||||
/** 对预约明细和核实表单提供同一批原始货件资料,不改写原订单。 */
|
||||
public Map<List<String>,List<String>> originalShipmentReferences(List<AppointmentSourceService.Source> sources) {
|
||||
return references.originalReferences(sources);
|
||||
}
|
||||
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) {
|
||||
validate(request,operatorId);
|
||||
|
||||
+4
-2
@@ -20,6 +20,7 @@ public class AppointmentCoverageService {
|
||||
this.mapper=mapper; this.external=external;
|
||||
}
|
||||
|
||||
/** 按真实货物与外部约号核对覆盖,确认时段缺失不伪造时间也不撤销已确认事实。 */
|
||||
public List<Coverage> query(List<SourceRef> input) {
|
||||
if (input==null || input.isEmpty()) return List.of();
|
||||
if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) {
|
||||
@@ -85,7 +86,7 @@ public class AppointmentCoverageService {
|
||||
for (var fact:relevant) {
|
||||
if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { invalid=true; continue; }
|
||||
covered+=fact.packages();
|
||||
boolean success="SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null && !blank(fact.confirmationNo()) && !blank(fact.timezone());
|
||||
boolean success="SUCCESS".equals(fact.status()) && !blank(fact.confirmationNo()) && !blank(fact.timezone());
|
||||
if (success) confirmed+=fact.packages();
|
||||
distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone()));
|
||||
changed|=!Objects.equals(currentVersion,fact.sourceVersion());
|
||||
@@ -99,7 +100,8 @@ public class AppointmentCoverageService {
|
||||
else if (confirmed<packages) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
|
||||
else { status="CONFIRMED"; }
|
||||
if (relevant.stream().anyMatch(f->"SUCCESS".equals(f.status()) && blank(f.timezone()))) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED");
|
||||
if (evidence==null ? order.shipmentReferencesMissing() : !hasReferences(evidence.getShipmentReferencesJson())) missing.add("FBA_SHIPMENT_REFERENCES");
|
||||
// 只补录过箱数的核实记录不能遮蔽原订单已经完整提供的货件号。
|
||||
if (order.shipmentReferencesMissing() && (evidence==null || !hasReferences(evidence.getShipmentReferencesJson()))) missing.add("FBA_SHIPMENT_REFERENCES");
|
||||
if (changed) missing.add("SOURCE_CHANGED_REVIEW");
|
||||
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing),
|
||||
(booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid));
|
||||
|
||||
+6
-2
@@ -7,15 +7,19 @@ import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
/** Resolves a location's appointment window without using the operator's system time zone. */
|
||||
/** 解析预约地点的真实确认时段,不使用操作人的系统时区或虚构确认时间。 */
|
||||
public final class AppointmentTimePolicy {
|
||||
private AppointmentTimePolicy() { }
|
||||
|
||||
public record Window(Instant start, Instant end, String timezone) { }
|
||||
|
||||
/** 未提供确认时段时保持为空;提供时仍校验地区时区、夏令时及先后顺序。 */
|
||||
public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone,
|
||||
ZoneOffset startOffset, ZoneOffset endOffset) {
|
||||
if (start == null) throw new IllegalArgumentException("请填写预约开始时间");
|
||||
if (start == null) {
|
||||
if (end != null || startOffset != null || endOffset != null) throw new IllegalArgumentException("未填写确认开始时间,不能填写确认结束时间或时差");
|
||||
return new Window(null, null, timezone);
|
||||
}
|
||||
if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区");
|
||||
final ZoneId zone;
|
||||
try { zone = ZoneId.of(timezone); }
|
||||
|
||||
+7
-1
@@ -18,6 +18,7 @@ public interface AppointmentWorkDispatchPort {
|
||||
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
|
||||
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
|
||||
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items);
|
||||
/** 正式确认的已知时段原样返回;未提供确认时间时保持为空。 */
|
||||
record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages,
|
||||
String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { }
|
||||
record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount,
|
||||
@@ -29,11 +30,16 @@ public interface AppointmentWorkDispatchPort {
|
||||
/** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */
|
||||
void bindItems(long dispatchId,List<PhysicalItem> touchedItems,List<Allocation> allocations);
|
||||
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. */
|
||||
String departureBlockReason(String dispatchNo);
|
||||
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
|
||||
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
|
||||
/** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */
|
||||
/** FBA选入前须已有正式外部确认;提交审核、审核通过和开始拣货继续复核运输安排及已知时段。 */
|
||||
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
|
||||
List<ReceivingWindow> reviewEvidence(String dispatchNo);
|
||||
void removeItem(long dispatchId,long itemId);
|
||||
|
||||
+15
-2
@@ -39,6 +39,11 @@ public class DispatchDeliveryRepository {
|
||||
public void create(OutboundOrder value) { outbounds.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); }
|
||||
/** 仅更新已锁定并复验的客户出库执行事实。 */
|
||||
@@ -58,14 +63,22 @@ public class DispatchDeliveryRepository {
|
||||
/** 全部子单锁已收齐后,才按来源数字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())
|
||||
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
|
||||
.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::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); }
|
||||
}
|
||||
|
||||
+96
-9
@@ -26,7 +26,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */
|
||||
@Service
|
||||
public class DispatchDeliveryService implements DispatchDeliveryPort {
|
||||
public class DispatchDeliveryService implements DispatchDeliveryPort, com.baiyue.lastleg.dispatch.port.DispatchOutboundPort {
|
||||
private final DispatchExecutionPort execution;
|
||||
private final DispatchDeliveryRepository repository;
|
||||
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("派送分组重复");
|
||||
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);
|
||||
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>();
|
||||
for (var group:groups) {
|
||||
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);
|
||||
}
|
||||
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
|
||||
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("已存在关联单据但缺少完整安排事实,请核对");
|
||||
var groups=grouping.groups(context);var external=fees.external(context);
|
||||
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());
|
||||
}
|
||||
|
||||
/** 理货审核后只创建待出库单,不提前生成派送、费用或真实拣货事实。 */
|
||||
@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,
|
||||
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
|
||||
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
|
||||
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()) {
|
||||
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);
|
||||
order.setOutboundNo(numbers.next("OUTBOUND"));
|
||||
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);
|
||||
repository.create(delivery);deliveries.put(group.key(),delivery);
|
||||
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());
|
||||
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
|
||||
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)
|
||||
public DepartureFacts departureFacts(Context context,boolean 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);
|
||||
String reason=arrangement==null?"请先安排本车全部客户派送":
|
||||
!Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash())
|
||||
?"派送安排的装车或货物依据已经失效":!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;
|
||||
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),
|
||||
@@ -242,7 +328,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
||||
public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) {
|
||||
if (departedAt==null) throw conflict("缺少实际发车时间");
|
||||
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 delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt));
|
||||
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) {
|
||||
var saved=repository.arrangement(context.dispatchId(),false);
|
||||
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>();
|
||||
for (var delivery:repository.deliveries(context.dispatchId(),false)) {
|
||||
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 refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey())))
|
||||
.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());
|
||||
history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo());
|
||||
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); }
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -34,10 +34,11 @@ public class CargoDiscardController {
|
||||
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
|
||||
}
|
||||
|
||||
/** 从可信登录身份保存申请人姓名快照。 */
|
||||
@PostMapping
|
||||
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
|
||||
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
|
||||
return ApiResponse.success(service.create(scope(user, customerId), operator(user), org.springframework.util.StringUtils.hasText(user.displayName()) ? user.displayName() : user.username(), request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
||||
+2
-1
@@ -24,10 +24,11 @@ public class MerchantCargoDiscardController {
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
|
||||
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
|
||||
/** 从可信登录身份保存申请人姓名快照。 */
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
|
||||
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), org.springframework.util.StringUtils.hasText(user.displayName()) ? user.displayName() : user.username(), request)); }
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
|
||||
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+4
-1
@@ -17,7 +17,10 @@ public final class CargoDiscardRequests {
|
||||
|
||||
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
|
||||
@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,
|
||||
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
|
||||
|
||||
+7
-2
@@ -38,11 +38,16 @@ public final class CargoDiscardResponses {
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
int totalPackages, BigDecimal totalFee, String currency, String status,
|
||||
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt,
|
||||
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages) {}
|
||||
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages, String createdByName) {}
|
||||
|
||||
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
||||
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) {
|
||||
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null);
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ public class CargoDiscardApplication {
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
private String createdByName;
|
||||
private String reviewedBy;
|
||||
private String reviewRemark;
|
||||
private LocalDateTime customerConfirmedAt;
|
||||
|
||||
@@ -18,6 +18,7 @@ public class CargoDiscardItem {
|
||||
private Long returnCartonId;
|
||||
private Long inventoryPositionId;
|
||||
private String cartonMarksJson;
|
||||
private String actualCartonMarksJson;
|
||||
private Boolean cartonScanRequired;
|
||||
private String inboundCode;
|
||||
private Long profileId;
|
||||
|
||||
+19
@@ -84,6 +84,25 @@ public interface CargoDiscardInventoryMapper {
|
||||
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
|
||||
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
|
||||
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
|
||||
/** 现场箱唛须能溯源到该客户该仓位的实收流水或原入仓单,不能凭数量放行。 */
|
||||
@Select("""
|
||||
SELECT l.carton_mark FROM ll_inventory_ledger l
|
||||
JOIN ll_inventory_position p ON p.customer_id=l.customer_id AND p.id=l.position_id AND p.balance_id=l.balance_id
|
||||
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id AND b.inbound_order_id=l.inbound_order_id
|
||||
WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId}
|
||||
AND l.carton_mark IS NOT NULL AND l.carton_mark<>''
|
||||
GROUP BY l.carton_mark HAVING SUM(l.delta_packages)>0
|
||||
UNION
|
||||
SELECT DISTINCT c.carton_mark FROM ll_inventory_position p
|
||||
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
|
||||
JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id
|
||||
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.received_at IS NOT NULL
|
||||
AND c.status IN ('AVAILABLE','DISCARD_PENDING') AND c.carton_mark IS NOT NULL AND c.carton_mark<>''
|
||||
AND (c.warehouse_pallet_id=p.pallet_id OR c.warehouse_pallet_id IS NULL)
|
||||
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
|
||||
""")
|
||||
List<String> traceableCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
|
||||
|
||||
/** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */
|
||||
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
|
||||
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
|
||||
|
||||
+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 件数。 */
|
||||
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() { }
|
||||
|
||||
|
||||
+8
-3
@@ -13,7 +13,11 @@ public final class PdaCargoDiscardDtos {
|
||||
public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
|
||||
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,
|
||||
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
|
||||
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
|
||||
@@ -26,8 +30,9 @@ public final class PdaCargoDiscardDtos {
|
||||
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
|
||||
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
|
||||
Integer actualPackages, String status, String quantityUnit,
|
||||
boolean actualQuantityEditable) { }
|
||||
boolean actualQuantityEditable, List<String> allowedCartonMarks) { }
|
||||
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
|
||||
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) { }
|
||||
}
|
||||
|
||||
+57
-10
@@ -69,18 +69,35 @@ public class PdaCargoDiscardService {
|
||||
if (!line.actualQuantityEditable() && actual != line.plannedPackages())
|
||||
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
|
||||
}
|
||||
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) {
|
||||
// 有真实箱号必须逐箱核对,兼容字段不能绕过扫码校验。
|
||||
if ("CARTON".equals(task.scanMode())) {
|
||||
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
|
||||
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
|
||||
throw conflict("必须扫描全部箱唛,或选择一键全部废弃");
|
||||
throw conflict("必须扫描本次废弃的全部箱号");
|
||||
}
|
||||
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()) {
|
||||
CargoDiscardItem item = repository.item(line.itemId());
|
||||
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|
||||
|| !Objects.equals(application.getId(), item.getApplicationId())
|
||||
|| !physicalCode(application, item).equals(task.palletNo()))
|
||||
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())) stockService.validateActualCartonMarks(application.getCustomerId(), item, scanned);
|
||||
if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned)))
|
||||
throw conflict("已完成托盘的实际废弃箱唛不能修改");
|
||||
}
|
||||
if ("COMPLETED".equals(item.getPdaStatus())
|
||||
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
|
||||
throw conflict("已完成托盘的实际数量不能修改");
|
||||
@@ -91,10 +108,23 @@ public class PdaCargoDiscardService {
|
||||
|| !application.getCustomerId().equals(item.getCustomerId())
|
||||
|| !Objects.equals(application.getId(), item.getApplicationId())))
|
||||
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()))) return task;
|
||||
// 已完成托盘在整单未结束时也只核对原结果,不能再次计入数量池或扣减库存。
|
||||
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) {
|
||||
if (!terminal) photoService.confirm(application, task.palletNo(), request.photoFileIds(), true);
|
||||
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("废弃终态不能执行未完成托盘");
|
||||
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,
|
||||
@@ -113,12 +143,25 @@ public class PdaCargoDiscardService {
|
||||
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
|
||||
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);
|
||||
stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
|
||||
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
|
||||
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
|
||||
}
|
||||
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
|
||||
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
|
||||
.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()
|
||||
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
|
||||
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
|
||||
@@ -176,16 +219,20 @@ public class PdaCargoDiscardService {
|
||||
CargoDiscardItem first = entry.getValue().get(0);
|
||||
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
|
||||
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
|
||||
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
|
||||
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i),
|
||||
"INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i) ? stockService.allowedCartonMarks(application.getCustomerId(), i) : cartonMarks(application, i))).toList();
|
||||
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
|
||||
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
|
||||
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
|
||||
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
|
||||
// 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
|
||||
boolean cartonScan = !marks.isEmpty() || entry.getValue().stream()
|
||||
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
|
||||
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
|
||||
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
|
||||
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();
|
||||
}
|
||||
/** 相关托盘只从客户库存事实读取,不从请求推断。 */
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@ public class CargoDiscardQuantityRepository {
|
||||
public int boxes(CargoDiscardApplication application) {
|
||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||
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); }
|
||||
}
|
||||
+2
@@ -32,6 +32,8 @@ public class CargoDiscardStockRepository {
|
||||
public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; }
|
||||
/** 读取散箱实收流水中的箱码快照。 */
|
||||
public List<String> looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); }
|
||||
/** 读取本仓位原入库单与实收流水允许扫描的箱唛。 */
|
||||
public List<String> traceableMarks(String customer, Long position) { return stock.traceableCartonMarks(customer, position); }
|
||||
/** 保存废弃申请的库存来源明细。 */
|
||||
public void saveItem(CargoDiscardItem item) { items.insert(item); }
|
||||
/** 保存申请所选实物箱快照。 */
|
||||
|
||||
+5
-3
@@ -51,7 +51,7 @@ public class CargoDiscardOutboundService {
|
||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
|
||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
||||
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.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
|
||||
@@ -67,7 +67,8 @@ public class CargoDiscardOutboundService {
|
||||
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
|
||||
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
|
||||
palletNo = position.getPalletNo();
|
||||
cartonMarks = String.join(",", stockService.cartonMarks(line));
|
||||
cartonMarks = String.join(",", line.getActualCartonMarksJson() == null
|
||||
? stockService.cartonMarks(line) : stockService.actualCartonMarks(line));
|
||||
} else if (returned) {
|
||||
var carton = returnCartons.selectById(line.getReturnCartonId());
|
||||
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
|
||||
@@ -134,7 +135,8 @@ public class CargoDiscardOutboundService {
|
||||
};
|
||||
outbound.setStatus(status);
|
||||
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);
|
||||
}
|
||||
/** 保留逐 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); }
|
||||
}
|
||||
+41
-15
@@ -84,16 +84,10 @@ public class CargoDiscardService {
|
||||
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
|
||||
private BigDecimal feePerPackage = new BigDecimal("5.00");
|
||||
|
||||
/** 仅提供允许废弃的普通库存和退货箱候选。 */
|
||||
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
|
||||
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode));
|
||||
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>();
|
||||
for (CargoDiscardCandidateRow row : rows) {
|
||||
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
|
||||
value.add(row);
|
||||
}
|
||||
List<CargoDiscardResponses.Candidate> result = grouped.values().stream()
|
||||
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
|
||||
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
|
||||
// 货物废弃仅开放普通库存和退货库存,不提供一件代发订单候选。
|
||||
List<CargoDiscardResponses.Candidate> result = new java.util.ArrayList<>();
|
||||
result.addAll(stockService.candidates(customerId, trim(inboundCode)));
|
||||
String keyword = trim(inboundCode);
|
||||
if (returnCartons == null || returnCartonItems == null) return result;
|
||||
@@ -141,12 +135,16 @@ public class CargoDiscardService {
|
||||
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
|
||||
}
|
||||
|
||||
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
|
||||
/** 申请人姓名由认证身份提供快照,不采信请求字段。 */
|
||||
@Transactional
|
||||
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
|
||||
public CargoDiscardResponses.Detail create(String customerId, String operatorId, String operatorName,
|
||||
CargoDiscardRequests.Create request) {
|
||||
if (request.items().stream().anyMatch(item -> item.orderId() != null))
|
||||
throw badRequest("货物废弃不支持一件代发订单");
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
if (request.requestedPackages() != null && !StringUtils.hasText(request.remark()))
|
||||
throw badRequest("无箱唛数量废弃必须填写备注");
|
||||
CargoDiscardApplication replay = applications.findKey(customerId, key);
|
||||
if (replay != null) {
|
||||
if (!requestHash.equals(replay.getRequestHash())) {
|
||||
@@ -219,6 +217,13 @@ public class CargoDiscardService {
|
||||
}
|
||||
total = Math.addExact(total, returnCartonCache.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();
|
||||
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
@@ -234,7 +239,7 @@ public class CargoDiscardService {
|
||||
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
|
||||
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
|
||||
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
|
||||
application.setRequestHash(requestHash); application.setCreatedBy(operatorId);
|
||||
application.setRequestHash(requestHash); application.setCreatedBy(operatorId); application.setCreatedByName(operatorName);
|
||||
application.setCreatedAt(now); application.setUpdatedAt(now);
|
||||
applications.insert(application);
|
||||
|
||||
@@ -283,13 +288,15 @@ public class CargoDiscardService {
|
||||
return statisticsService.statistics(customerId, query);
|
||||
}
|
||||
|
||||
/** 详情返回各来源实际完成箱唛及托盘,退货沿用实收箱事实。 */
|
||||
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
|
||||
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
|
||||
if (application == null) throw notFound("Cargo discard application not found");
|
||||
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
|
||||
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
|
||||
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(),
|
||||
actualMarks(customerId, application, value), detailPallet(customerId, value)))
|
||||
.toList();
|
||||
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
|
||||
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
|
||||
@@ -298,6 +305,25 @@ public class CargoDiscardService {
|
||||
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews, photoService.list(application, null, true));
|
||||
}
|
||||
|
||||
/** 实际箱唛优先取作业快照,退货已完成明细可沿已核验的所选箱恢复。 */
|
||||
private List<String> actualMarks(String customer, CargoDiscardApplication application, CargoDiscardItem item) {
|
||||
var marks = stockService.actualCartonMarks(item);
|
||||
if (!marks.isEmpty() || !"COMPLETED".equals(item.getPdaStatus()) || item.getActualDiscardPackages() == null || item.getActualDiscardPackages() <= 0) return marks;
|
||||
return selectedMarks(customer, application, item);
|
||||
}
|
||||
|
||||
/** 托盘只读对应来源的真实实收记录,散箱保留明确标记。 */
|
||||
private String detailPallet(String customer, CargoDiscardItem item) {
|
||||
if (item.getInventoryPositionId() != null) return stockService.position(customer, item.getInventoryPositionId()).getPalletNo();
|
||||
if (isReturnItem(item)) {
|
||||
var carton = returnCartons.selectById(item.getReturnCartonId());
|
||||
if (carton == null || !customer.equals(carton.getCustomerId())) return null;
|
||||
return StringUtils.hasText(carton.getPalletCode()) ? carton.getPalletCode() : "散箱";
|
||||
}
|
||||
var profile = profiles.selectById(item.getProfileId());
|
||||
return profile != null && customer.equals(profile.getCustomerId()) ? profile.getPalletNo() : null;
|
||||
}
|
||||
|
||||
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String discardNo, long fileId) {
|
||||
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
|
||||
if (application == null) throw notFound("Cargo discard application not found");
|
||||
@@ -522,13 +548,13 @@ public class CargoDiscardService {
|
||||
int boxes = quantities.boxes(value);
|
||||
var lines = items.listApplication(value.getCustomerId(), value.getId());
|
||||
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)
|
||||
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
|
||||
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
|
||||
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
|
||||
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual);
|
||||
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual, value.getCreatedByName());
|
||||
}
|
||||
|
||||
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ public class CargoDiscardStatisticsService {
|
||||
boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true;
|
||||
for (var application : applications) {
|
||||
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; }
|
||||
var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList();
|
||||
if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); }
|
||||
|
||||
+86
-15
@@ -20,14 +20,18 @@ public class CargoDiscardStockService {
|
||||
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
|
||||
@Resource private InventoryCommandService commands;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private CargoDiscardRelabelStockService relabelStock;
|
||||
|
||||
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
|
||||
public List<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().filter(position -> !repository.dropshipPosition(customer, position.getId())).map(position -> {
|
||||
var balance = repository.balance(position.getBalanceId());
|
||||
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
|
||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
||||
var cartons = repository.cartons(customer, position.getId());
|
||||
long available = available(customer, position, balance);
|
||||
var cartons = cartons(customer, position);
|
||||
boolean complete = completeCartons(cartons, available);
|
||||
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
|
||||
contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
|
||||
@@ -40,6 +44,15 @@ public class CargoDiscardStockService {
|
||||
}).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、预留上限与具体箱子的归属。 */
|
||||
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
|
||||
var position = position(customer, request.inventoryPositionId());
|
||||
@@ -47,33 +60,38 @@ public class CargoDiscardStockService {
|
||||
if (balance == null || !customer.equals(balance.getCustomerId())
|
||||
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
|
||||
throw conflict("库存不属于当前客户或仓库");
|
||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
||||
long available = available(customer, position, balance);
|
||||
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
|
||||
if (request.cartonIds() != null) {
|
||||
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
|
||||
throw conflict("废弃箱子数量与所选箱子不一致或重复");
|
||||
var cartons = repository.cartons(customer, position.getId());
|
||||
var cartons = cartons(customer, position);
|
||||
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
|
||||
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
|
||||
if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
|
||||
} else {
|
||||
if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子");
|
||||
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
|
||||
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
|
||||
if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中");
|
||||
}
|
||||
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
|
||||
if (repository.dropshipPosition(customer, position.getId())) throw conflict("货物废弃不支持一件代发库存");
|
||||
return balance;
|
||||
}
|
||||
|
||||
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
|
||||
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
|
||||
relabelStock.lock(customer, List.of(request.inventoryPositionId()));
|
||||
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
|
||||
var balance = validate(customer, request);
|
||||
reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>());
|
||||
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 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
|
||||
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();
|
||||
relabelStock.lock(customer, stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().toList());
|
||||
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
|
||||
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
|
||||
});
|
||||
@@ -82,7 +100,7 @@ public class CargoDiscardStockService {
|
||||
var selectedSkus = new HashMap<String, Set<String>>();
|
||||
for (var request : stocks) {
|
||||
balances.put(request.inventoryPositionId(), validate(customer, request));
|
||||
var facts = repository.cartons(customer, request.inventoryPositionId());
|
||||
var facts = cartons(customer, position(customer, request.inventoryPositionId()));
|
||||
cartons.put(request.inventoryPositionId(), facts);
|
||||
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
|
||||
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
|
||||
@@ -95,12 +113,17 @@ public class CargoDiscardStockService {
|
||||
}
|
||||
var lockedBoxes = new HashSet<String>();
|
||||
var heldBoxes = new HashSet<String>();
|
||||
for (var request : stocks) reserveValidated(customer, operator, application, request, at,
|
||||
balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes);
|
||||
boolean relabelReserved = false;
|
||||
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 分别预留并保存相同箱唛快照。 */
|
||||
private void reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at,
|
||||
/** 使用变更箱状态前的完整事实保存各 SKU 快照,返回本行是否新增换标箱预留。 */
|
||||
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) {
|
||||
var position = position(customer, request.inventoryPositionId());
|
||||
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("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
|
||||
snapshotMarks(line, marks);
|
||||
}
|
||||
int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count();
|
||||
if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD"));
|
||||
var newlyHeld = selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).toList();
|
||||
int physical = selected.isEmpty() ? request.discardPackages() : newlyHeld.size();
|
||||
if (physical > 0) {
|
||||
relabelStock.handoff(customer, operator, application, line, newlyHeld);
|
||||
commands.hold(customer, operator, move(application, line, physical, "HOLD"));
|
||||
}
|
||||
repository.saveItem(line);
|
||||
return newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType()));
|
||||
}
|
||||
|
||||
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
|
||||
@@ -165,14 +193,18 @@ public class CargoDiscardStockService {
|
||||
|
||||
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
|
||||
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
|
||||
var links = repository.selectedCartons(customer, application.getId());
|
||||
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
|
||||
changeSelectedCartons(customer, application, line, "AVAILABLE");
|
||||
int physical = physicalPackages(customer, application, line);
|
||||
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
|
||||
relabelStock.restore(customer, application, line, links);
|
||||
}
|
||||
|
||||
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
|
||||
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
|
||||
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
|
||||
if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
|
||||
if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
|
||||
throw conflict("必须完成本次申请所选全部箱子,不能漏出");
|
||||
@@ -203,6 +235,7 @@ public class CargoDiscardStockService {
|
||||
|
||||
/** 按箱来源执行带原状态条件的变更。 */
|
||||
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
|
||||
if ("RELABEL".equals(source)) return "DISCARD_PENDING".equals(to) ? relabelStock.claim(customer, cartonId) : relabelStock.finish(customer, cartonId, "DISCARDED".equals(to));
|
||||
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
|
||||
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
|
||||
return repository.changeInboundCarton(customer, cartonId, from, to);
|
||||
@@ -237,8 +270,46 @@ public class CargoDiscardStockService {
|
||||
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); }
|
||||
}
|
||||
/** 可扫描范围只来自该申请明细仓位的原入仓及当前净实收箱唛。 */
|
||||
public List<String> allowedCartonMarks(String customer, CargoDiscardItem line) {
|
||||
position(customer, line.getInventoryPositionId());
|
||||
return repository.traceableMarks(customer, line.getInventoryPositionId());
|
||||
}
|
||||
/** 无逐箱快照时仍需验证原入库箱唛归属,无法溯源的箱子拒绝出库。 */
|
||||
public void validateActualCartonMarks(String customer, CargoDiscardItem line, List<String> marks) {
|
||||
position(customer, line.getInventoryPositionId());
|
||||
var allowed = new HashSet<>(repository.traceableMarks(customer, line.getInventoryPositionId()));
|
||||
if (!allowed.containsAll(marks)) throw conflict("箱唛不属于本废弃单的原入仓库存,请核对后重新扫描");
|
||||
}
|
||||
|
||||
/** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */
|
||||
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 来源保留未指定标记,不猜测商品。 */
|
||||
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; }
|
||||
/** 返回可恢复的业务冲突。 */
|
||||
|
||||
+8
@@ -90,6 +90,14 @@ public class DispatchAggregateController {
|
||||
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")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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("/pickup-complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<Detail> completePickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody Confirm request){return ApiResponse.success(service.completePickup(DispatchActorFactory.platform(user),number,request));}
|
||||
/** 上传不提前推进业务状态。 */
|
||||
@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
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts looseboardFacts;
|
||||
private final DispatchAggregateRepository aggregates;
|
||||
private final OutboundOrderMapper outbounds;
|
||||
private final DeliveryOrderMapper deliveries;
|
||||
@@ -79,7 +80,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||
}
|
||||
@Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) {
|
||||
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();
|
||||
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();
|
||||
@@ -90,10 +91,11 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||
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 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>();
|
||||
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>();
|
||||
for(var delivery:deliveryRows) {
|
||||
var outbound=byCustomer.get(delivery.getCustomerId());
|
||||
@@ -107,12 +109,20 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||
@Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean 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(looseboardFacts!=null && !looseboardFacts.cargo(row.getId()).isEmpty()) {
|
||||
boolean ready=(looseboardFacts.remoteItemIds(row.getId()).isEmpty() || looseboardFacts.pickupConfirmed(row)) && 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>();
|
||||
for(var delivery:facts.deliveries()) {
|
||||
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;
|
||||
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;
|
||||
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("派送交接保存失败");
|
||||
}
|
||||
}
|
||||
/** 混排照片取货不产生客户出库单,历史实托和其他来源仍按原链核对。 */
|
||||
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 static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);}
|
||||
/** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
|
||||
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) { }
|
||||
/** 上传只写本车、本类证据。 */
|
||||
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
|
||||
}
|
||||
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
|
||||
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");}
|
||||
|
||||
+10
-3
@@ -36,9 +36,13 @@ public interface DispatchAggregateMapper {
|
||||
""";
|
||||
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
|
||||
String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END";
|
||||
// 仓内已发运而异地货尚未确认取齐,同时属于装车后和派送中;确认取齐后仅保留派送中。
|
||||
String PICKUP_PENDING = "(d.stage='DELIVERING' AND d.departed_at IS NOT NULL AND d.shipped_package_count < d.package_count)";
|
||||
String CURRENT_FILTER = """
|
||||
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if>
|
||||
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
||||
<if test='q.stage != null'><choose><when test='q.stage == "AFTER_LOADING"'> AND (d.stage='AFTER_LOADING' OR
|
||||
""" + PICKUP_PENDING + """
|
||||
)</when><otherwise> AND d.stage=#{q.stage}</otherwise></choose></if>
|
||||
<if test='q.section == "VEHICLE_INFO"'> AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED')</if>
|
||||
<if test='q.section == "DISPATCH_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING'</if>
|
||||
<if test='q.section == "LOADING_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING'</if>
|
||||
@@ -49,7 +53,10 @@ public interface DispatchAggregateMapper {
|
||||
record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId,
|
||||
String fbaAppointmentNo, String status, String stage, String section, String reviewStatus,
|
||||
long offset, int limit) { }
|
||||
record Summary(String status, String stage, String reviewState, long quantity) { }
|
||||
record Summary(String status, String stage, String reviewState, long quantity,long pendingPickups) {
|
||||
@AutomapConstructor public Summary { }
|
||||
public Summary(String status,String stage,String reviewState,long quantity){this(status,stage,reviewState,quantity,0);}
|
||||
}
|
||||
|
||||
/** 首次创建整车所有权及初始版本,不写customer_id。 */
|
||||
@Insert("""
|
||||
@@ -95,7 +102,7 @@ public interface DispatchAggregateMapper {
|
||||
long count(@Param("q") Search query);
|
||||
|
||||
/** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */
|
||||
@Select("<script>SELECT d.status,d.stage,d.review_state AS reviewState,COUNT(*) AS quantity FROM ll_dispatch_order d WHERE 1=1 "
|
||||
@Select("<script>SELECT d.status,d.stage,d.review_state AS reviewState,COUNT(*) AS quantity,SUM(CASE WHEN " + PICKUP_PENDING + " THEN 1 ELSE 0 END) AS pendingPickups FROM ll_dispatch_order d WHERE 1=1 "
|
||||
+ BASE_FILTER + " GROUP BY d.status,d.stage,d.review_state</script>")
|
||||
List<Summary> summaries(@Param("q") Search query);
|
||||
|
||||
|
||||
+12
-2
@@ -26,6 +26,14 @@ public interface DispatchCandidateMapper {
|
||||
"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 " +
|
||||
"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.
|
||||
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 " +
|
||||
@@ -180,7 +188,7 @@ public interface DispatchCandidateMapper {
|
||||
"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 " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
@@ -285,7 +293,7 @@ public interface DispatchCandidateMapper {
|
||||
"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 " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
@@ -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 " +
|
||||
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
|
||||
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
||||
// 部分废弃后只统计仍在仓内的换标箱,保留原任务供追溯。
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=ri.customer_id AND dc.carton_id=ri.id AND dc.source_type='RELABEL_DISCARDED') " +
|
||||
"<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='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>" +
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
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 snapshot_json FROM ll_dispatch_history WHERE dispatch_id=#{id} AND action='LOOSEBOARD_PICKUP_COMPLETE' ORDER BY record_version,id")
|
||||
List<String> pickupConfirmations(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
|
||||
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}")
|
||||
long realPicked(@Param("id") long dispatchId);
|
||||
|
||||
+15
-2
@@ -28,13 +28,17 @@ public interface DispatchSourceMapper {
|
||||
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
|
||||
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,
|
||||
""" + 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 " + """
|
||||
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_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
|
||||
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
|
||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||
""" + "</script>")
|
||||
@@ -192,6 +196,15 @@ public interface DispatchSourceMapper {
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND source_pallet_key=#{key} AND status='ACTIVE'")
|
||||
int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key,@Param("at")LocalDateTime at);
|
||||
|
||||
/** 仅认可本车本客户同一来源的既有释放事实;他车重新占用后不得继续确认取货。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_dispatch_pallet_lock original WHERE original.dispatch_order_id=#{dispatch} "
|
||||
+ "AND original.customer_id=#{customer} AND original.source_pallet_key=#{key} AND original.status='RELEASED' "
|
||||
+ "AND original.active_key IS NULL AND original.released_at IS NOT NULL "
|
||||
+ "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock current_claim WHERE current_claim.customer_id=#{customer} "
|
||||
+ "AND current_claim.source_pallet_key=#{key} AND current_claim.status='ACTIVE')")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
int releasedByDispatch(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key);
|
||||
|
||||
/** 原计划收货只补齐本逻辑成员的实际实托和已取得的物理占用引用。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},physical_lock_id=#{physical},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL AND physical_lock_id IS NULL")
|
||||
int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("physical")long physical,@Param("at")LocalDateTime at);
|
||||
|
||||
+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);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
/** 客户照片的真实承运范围,只关联同客户商业原单,不按展示编号猜来源。 */
|
||||
@Mapper
|
||||
public interface MerchantTransportPhotoMapper {
|
||||
/** 申报散板或实收散板均沿原单、入仓计划和排车明细精确关联。 */
|
||||
@Select("""
|
||||
SELECT DISTINCT d.dispatch_no FROM ll_local_looseboard_order o
|
||||
JOIN ll_inbound_plan p ON p.source_order_id=o.lastleg_order_id AND p.customer_id=o.customer_id
|
||||
JOIN ll_inbound_plan_pallet pp ON pp.plan_id=p.id AND pp.customer_id=p.customer_id
|
||||
JOIN ll_dispatch_item i ON i.plan_pallet_id=pp.id AND i.customer_id=o.customer_id
|
||||
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status='ACTIVE'
|
||||
WHERE o.customer_id=#{customer} AND o.order_no=#{orderNo} ORDER BY d.dispatch_no
|
||||
""")
|
||||
List<String> looseboardDispatches(@Param("customer")String customer,@Param("orderNo")String orderNo);
|
||||
|
||||
/** 本土卡车只读取本客户原单当前有效排车,已取消安排不暴露。 */
|
||||
@Select("""
|
||||
SELECT t.dispatch_no FROM ll_dispatch_truck_task t
|
||||
JOIN ll_dispatch_order d ON d.dispatch_no=t.dispatch_no AND d.status='ACTIVE' AND d.task_type='LOCAL_TRUCK'
|
||||
WHERE t.customer_id=#{customer} AND t.order_no=#{orderNo} AND t.active_order IS NOT NULL
|
||||
ORDER BY d.id
|
||||
""")
|
||||
List<String> truckDispatches(@Param("customer")String customer,@Param("orderNo")String orderNo);
|
||||
}
|
||||
+7
-3
@@ -24,8 +24,10 @@ public final class DispatchCommands {
|
||||
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
|
||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
|
||||
/** 仅智能排车建单时显式选择发布,未选择时不发布。 */
|
||||
public Create { publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand); }
|
||||
/** 草稿未知计划时间保持为空,避免重试时动态补时间破坏原请求幂等。 */
|
||||
public Create {
|
||||
publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand);
|
||||
}
|
||||
}
|
||||
|
||||
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
|
||||
@@ -78,7 +80,9 @@ public final class DispatchCommands {
|
||||
|
||||
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
|
||||
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); }
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user