Compare commits

...
8 Commits
Author SHA1 Message Date
liuyihe 0bd25de906 提交 2026-10-29 15:49:18 +08:00
liuyihe 849f8dc5f2 提交 2026-10-29 14:29:02 +08:00
liuyihe 1b27d7e6c2 提交 2026-10-29 13:36:38 +08:00
ouyangxq 353c86f4cf 为船期节点记录并返回操作人快照 2026-10-28 16:56:33 +08:00
liuyihe 71a1026e21 提交 2026-10-28 14:44:59 +08:00
liuyihe f3d57869f2 费用提交 2026-10-28 13:57:34 +08:00
liuyihe 7086713fdd Merge remote-tracking branch 'origin/test' into test 2026-10-28 13:42:18 +08:00
liuyihe 66b0935b11 费用提交 2026-10-28 13:42:03 +08:00
101 changed files with 1897 additions and 166 deletions
+7
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
`migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql` 修复海外人工费用资金重试报 S0500:仅将 `baiyue_lastleg.ll_finance_fee_command.action` 扩为 `VARCHAR(32)`,容纳 20 字符的 `MANUAL_FUNDING_RETRY`,保留中文备注、非空及原幂等唯一键。Lastleg 源基线和海外空库基线同步。已有环境先核对目标库及列定义,经授权执行 V35、回查列长度和记录总数,再通过原费用重试入口验证;可重复执行,不修改费用、审核、余额或资金记录,不重放空库基线。DDL 隐式提交,遇错停止;本次仅准备修复文件,尚未执行测试环境 SQL 或部署,文件存在不表示已经执行。
`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。 `overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。 `overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
@@ -18,6 +20,8 @@
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
船节点日志在 `fl_carrier_schedule_node_fact.operator_id/operator_name` 保存操作时的账号与用户名快照;人工补录和确认出仓写入,船司自动采集为空。两列定义同步于 Firstleg 船节点模块基线与国内统一空库基线,更新结构后配套发布 Firstleg 与 Admin。既有 TEST 的字段变更须单独授权执行,不重放空库初始化,不回填历史节点。
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。 `overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。 `overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
@@ -220,3 +224,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。 采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
@@ -0,0 +1,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,29 @@
-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
@@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称', location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用', source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id bigint unsigned DEFAULT NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name varchar(128) DEFAULT NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -8465,3 +8467,9 @@ USE baiyue_iam;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
USE baiyue_lastleg;
-- 现场扫描清单独立于客户选箱快照;重复执行不改现有业务数据。
SET @discard_actual_marks_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_carton_marks_json'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_carton_marks_json JSON NULL COMMENT ''PDA现场实际废弃箱唛清单,与客户选箱快照独立保存''');
PREPARE discard_actual_marks_stmt FROM @discard_actual_marks_ddl;
EXECUTE discard_actual_marks_stmt;
DEALLOCATE PREPARE discard_actual_marks_stmt;
+25 -2
View File
@@ -917,7 +917,7 @@ CREATE TABLE ll_return_carton_stock_receipt (
CREATE TABLE ll_finance_fee_command ( CREATE TABLE ll_finance_fee_command (
id bigint NOT NULL COMMENT '幂等命令ID', id bigint NOT NULL COMMENT '幂等命令ID',
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
action varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试',
idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键',
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256', request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256',
source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID', source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID',
@@ -2105,6 +2105,8 @@ CREATE TABLE ll_overseas_procurement_order (
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
@@ -4897,4 +4899,25 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
PRIMARY KEY (order_id,command_key) PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables; CREATE TABLE ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 223 AS initialized_tables;
View File
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
String providerNodeCode, String standardNode, String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} String sourceReference, String rawPayloadHash, LocalDateTime createdAt,
String operatorName) {}
public record ContainerNodeDetail(Long containerBookingId, String currentNode, public record ContainerNodeDetail(Long containerBookingId, String currentNode,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {} LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
private String locationName; private String locationName;
private String sourceReference; private String sourceReference;
private String rawPayloadHash; private String rawPayloadHash;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
} }
@@ -11,14 +11,15 @@ import java.util.List;
@Mapper @Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> { public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
/** 保存不可变节点事实及执行该操作的账号快照。 */
@Insert(""" @Insert("""
INSERT INTO fl_carrier_schedule_node_fact INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at, (id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,operator_id,operator_name,created_at)
VALUES VALUES
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode}, (#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
CURRENT_TIMESTAMP(3)) CURRENT_TIMESTAMP(3))
""") """)
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value); int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
public long customerId(long id) { return required(id).getCustomerId(); } public long customerId(long id) { return required(id).getCustomerId(); }
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ /** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
@Transactional @Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId, public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId, long containerBookingId,
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
persistenceStage = "NODE_FACT"; persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId)); sha256("OUTBOUND_CONFIRM|" + containerBookingId), operatorId, operatorName);
if (departed != null) { if (departed != null) {
persistenceStage = "CROSS_REGION_OUTBOX"; persistenceStage = "CROSS_REGION_OUTBOX";
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), ""); + ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
} }
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
@Transactional @Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) { public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleQueryEntity value = requiredLocked(id);
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(), CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node, request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash()); request.locationName(), request.sourceReference(), request.rawPayloadHash(), null, null);
if (fact == null) { if (fact == null) {
return detail(value); return detail(value);
} }
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
return detail(value); return detail(value);
} }
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
@Transactional @Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key, public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) { ManualNodeRequest request) {
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node, CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(), request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request)); "MANUAL:" + operatorId, hash(operation, id, request), operatorId, operatorName);
if (fact == null) { if (fact == null) {
return complete(id, operatorId, key, detail(value)); return complete(id, operatorId, key, detail(value));
} }
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId)); return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
} }
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
@Transactional @Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId, public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key, String operatorName, String key,
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId, CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId, "MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash); request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash,
operatorId, operatorName);
if (fact != null && query != null) { if (fact != null && query != null) {
CarrierScheduleQueryEntity before = copy(query); CarrierScheduleQueryEntity before = copy(query);
requireManualActualAdvance(query, node, request.actualAt()); requireManualActualAdvance(query, node, request.actualAt());
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
repository.histories(value.getId()).stream().map(this::history).toList()); repository.histories(value.getId()).stream().map(this::history).toList());
} }
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
private NodeFact node(CarrierScheduleNodeFactEntity value) { private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(), return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt()); value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
} }
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) { private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
} }
/** 为查询任务追加节点事实并透传人工操作账号。 */
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId, private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId, return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName, providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash); sourceReference, rawPayloadHash, operatorId, operatorName);
} }
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId, private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode, String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId); fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
@@ -589,6 +596,7 @@ public class CarrierScheduleQueryService {
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
if (repository.insertIfAbsent(fact)) return fact; if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey()); CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) { if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
@@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
location_name VARCHAR(128) NULL COMMENT '节点地点名称', location_name VARCHAR(128) NULL COMMENT '节点地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id BIGINT UNSIGNED NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name VARCHAR(128) NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -234,6 +234,7 @@ components:
locationCode: {type: string, maxLength: 32, nullable: true} locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true} locationName: {type: string, maxLength: 128, nullable: true}
sourceReference: {type: string, maxLength: 500, nullable: true} sourceReference: {type: string, maxLength: 500, nullable: true}
operatorName: {type: string, maxLength: 128, nullable: true, description: '节点操作人用户名快照;人工补录和确认出仓时记录,自动采集为空'}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
createdAt: {type: string, format: date-time} createdAt: {type: string, format: date-time}
ExceptionFact: ExceptionFact:
@@ -8,6 +8,16 @@ import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryOpenApiContractTest { class CarrierScheduleQueryOpenApiContractTest {
/** 两个节点读取入口使用相同的操作用户名响应契约。 */
@Test
void nodeFactsExposeTheRecordedOperatorUsername() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
String node = yaml.substring(yaml.indexOf(" NodeFact:"), yaml.indexOf(" ExceptionFact:"));
assertThat(node).contains("operatorName: {type: string, maxLength: 128, nullable: true")
.contains("节点操作人用户名快照");
}
@Test @Test
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception { void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml") String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
@@ -16,6 +16,23 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySqlContractTest { class CarrierScheduleQuerySqlContractTest {
/** 模块与部署基线共同保存节点署名,Mapper 同次写入两项快照。 */
@Test
void nodeFactsPersistOperatorSnapshotInBothBaselines() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
for (String source : List.of(moduleSql(), unified)) {
String lower = source.toLowerCase(java.util.Locale.ROOT);
int start = lower.indexOf("fl_carrier_schedule_node_fact (");
String table = lower.substring(start, lower.indexOf("engine=innodb", start));
assertThat(table).contains("operator_id bigint unsigned", "operator_name varchar(128)");
}
String insert = String.join("\n", com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper.class
.getMethod("insertStrict", com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)
.getAnnotation(org.apache.ibatis.annotations.Insert.class).value());
assertThat(insert).contains("operator_id,operator_name", "#{value.operatorId},#{value.operatorName}");
}
@Test @Test
void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception { void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception {
JdbcDataSource database = new JdbcDataSource(); JdbcDataSource database = new JdbcDataSource();
@@ -133,14 +133,21 @@ class CarrierScheduleExceptionPersistenceTest {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8);
} }
/** 真实 Mapper 保存并返回操作用户名,账号更名后重放不改写原署名。 */
@Test @Test
void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() { void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() {
var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
"ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles", "ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles",
"synthetic container manual fact"); "synthetic container manual fact");
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body); var detail = service.addContainerManualNode(3,10001,"platform_admin","original-body-key",body);
assertThat(detail.nodes().stream().filter(node -> "MANUAL".equals(node.providerCode())).toList())
.singleElement().satisfies(node -> assertThat(node.operatorName()).isEqualTo("platform_admin"));
assertThat(jdbc.queryForObject("SELECT operator_id FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", Long.class))
.isEqualTo(10001L);
assertThat(jdbc.queryForObject("SELECT operator_name FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", String.class))
.isEqualTo("platform_admin");
var saved = allSnapshot(); var saved = allSnapshot();
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body); service.addContainerManualNode(3,10001,"renamed_operator","original-body-key",body);
assertThat(allSnapshot()).isEqualTo(saved); assertThat(allSnapshot()).isEqualTo(saved);
assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key", assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key",
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
@@ -183,6 +183,7 @@ class CarrierScheduleQueryServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移"); .isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
} }
/** 出仓节点记录确认人的账号,供两个货柜详情展示。 */
@Test @Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
@@ -194,7 +195,7 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"); CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "platform_admin");
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO"); assertThat(result.getCarrierCode()).isEqualTo("COSCO");
@@ -204,23 +205,25 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(result.getCreatedBy()).isZero(); assertThat(result.getCreatedBy()).isEqualTo(7L);
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM"); assertThat(result.getCreatedByName()).isEqualTo("platform_admin");
assertThat(result.getUpdatedBy()).isZero(); assertThat(result.getUpdatedBy()).isEqualTo(7L);
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM"); assertThat(result.getUpdatedByName()).isEqualTo("platform_admin");
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox).publish(eq(result), argThat(fact -> verify(nodeOutbox).publish(eq(result), argThat(fact ->
"FIRSTLEG".equals(fact.getProviderCode()) "FIRSTLEG".equals(fact.getProviderCode())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId()) && "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode()) && "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode()) && "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId())
&& "platform_admin".equals(fact.getOperatorName())
&& fact.getActualAt() != null), eq("ZCSU7881970")); && fact.getActualAt() != null), eq("ZCSU7881970"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction()) "AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null && history.getFromVersion() == null
&& Integer.valueOf(0).equals(history.getToVersion()) && Integer.valueOf(0).equals(history.getToVersion())
&& Long.valueOf(0L).equals(history.getOperatorId()) && Long.valueOf(7L).equals(history.getOperatorId())
&& "SYSTEM".equals(history.getOperatorName()) && "platform_admin".equals(history.getOperatorName())
&& "国内确认出仓自动创建".equals(history.getReason()))); && "国内确认出仓自动创建".equals(history.getReason())));
} }
@@ -360,6 +363,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
} }
/** 自动采集推进原节点,但不冒用平台操作账号。 */
@Test @Test
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0); CarrierScheduleQueryEntity task = task(0, 0);
@@ -377,6 +381,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT"); assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
assertThat(result.nodes()).isEmpty(); assertThat(result.nodes()).isEmpty();
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
fact.getOperatorId() == null && fact.getOperatorName() == null));
} }
@Test @Test
@@ -448,6 +454,7 @@ class CarrierScheduleQueryServiceTest {
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970")); verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
} }
/** 人工实际节点保留操作账号及既有还柜幂等行为。 */
@Test @Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3); CarrierScheduleQueryEntity task = task(0, 3);
@@ -462,6 +469,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("STOPPED"); assertThat(result.query().query().status()).isEqualTo("STOPPED");
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED"); assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
} }
@@ -488,6 +497,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository).updateState(before, 3, 7L, "admin"); verify(repository).updateState(before, 3, 7L, "admin");
} }
/** 柜级补录沿用当前账号,并保持已有跟踪任务和事件投递。 */
@Test @Test
void containerManualNodeJoinsAndAdvancesExistingCarrierTask() { void containerManualNodeJoinsAndAdvancesExistingCarrierTask() {
CarrierScheduleQueryEntity task = task(0, 8); CarrierScheduleQueryEntity task = task(0, 8);
@@ -509,7 +519,8 @@ class CarrierScheduleQueryServiceTest {
"PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup")); "PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup"));
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId()) verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId())
&& fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode()))); && fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
verify(repository).updateState(task, 8, 7L, "admin"); verify(repository).updateState(task, 8, 7L, "admin");
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId()) "MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId())
+1 -1
View File
@@ -79,7 +79,7 @@
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | | 实物装载、逐段容量与双端整车发车 | 两端`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权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` | | 测试 | `src/test/java/com/baiyue/lastleg/` |
+3 -1
View File
@@ -100,7 +100,7 @@
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 - 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 - 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 - 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 - 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。全车正常派送完成自动结案,结案不改客户状态/费用;结案与车/司机释放占用同事务,提货全部到仓、本土卡车完成也直接释放,不需二次确认。自动释放记录运输完成时刻及路线终点,保留车辆主档状态。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 - 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 - 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。
@@ -171,3 +171,5 @@
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。 - 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。 - 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。 - 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
- 废弃PDA:无箱唛关联的普通库存先扫本单托盘,再扫描现场箱唛自动累计各明细实际箱数,禁止手填数量绕过扫码。现场箱唛独立保存到 actual_carton_marks_json,不改客户选箱快照;提交校验数量一致、同托及同单跨托盘不重复,失败不落盘、可刷新重扫。客户/平台详情返回 actualCartonMarks、palletNo、实际箱数和原备注。数量申请达到目标后释放未用预留。
+12 -3
View File
@@ -14,13 +14,13 @@
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。 拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。 出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。 主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。每次创建出库批次前,本采购主单必须至少有一项未作废应收费用,且全部未作废应收费用均须有效并审核通过;缺少费用、待审核或驳回时提示并阻止出库,前后端共同校验。应付与已作废费用不能替代应收费用明细;原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。 费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。费用明细缺失或尚未审核时可提示运营补充,但不阻止创建出库批次;出库资格只由采购状态、可用数量、物流跟踪号及操作权限决定。原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。 客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。
@@ -34,7 +34,7 @@
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。 - 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。 - 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。
- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。 - 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。
- 无费用、仅作废费用、仅应付费用或任一应收尚未审核通过时,不能创建出库;全部有效应收审核通过后允许出库。 - 无费用、费用尚未审核或驳回时均允许创建出库;页面提示不影响出库操作。
自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。 自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。
@@ -46,3 +46,12 @@
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。 - Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。 - 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。 - 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。
## 2026-10-29 客户下单预算与页面调整
客户采购下单前,以相同幂等键调用 `order-budgets/procurement/{key}`,金额为预算单价乘数量,按有效汇率折人民币;仅Finance冻结成功且usable=true时允许建单。余额不足使用可用授信,合计不足则提示Finance原因,不创建订单。免费样品金额为0时不发资金指令。未绑定预占15分钟后释放,晚到回执仍释放;已绑定订单终态释放,失败定时重试。预算预占独立于现有人工费用冻结/实扣,不计入已扣款汇总。
本土卡车同样校验预算,托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。
结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。
@@ -17,7 +17,10 @@ public final class CargoDiscardRequests {
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items, public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {} @Size(max = 1000) String remark, @Positive Integer requestedPackages) {
/** 逐箱申请从明细计数,无箱唛申请另传整单目标箱数。 */
public Create(List<Item> items, String idempotencyKey, String remark) { this(items, idempotencyKey, remark, null); }
}
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode, public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) { @Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
@@ -42,7 +42,12 @@ public final class CargoDiscardResponses {
public record Item(Long id, Long orderId, String inboundCode, String skuCode, public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
String quantityUnit, Integer actualDiscardPackages, String pdaStatus) { String quantityUnit, Integer actualDiscardPackages, String pdaStatus,
List<String> actualCartonMarks, String palletNo) {
/** 保留历史详情调用,未记录的现场箱唛和托盘不补造。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks, String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, quantityUnit, actualDiscardPackages, pdaStatus, List.of(), null);
}
/** 无来源事实的调用不猜测数量单位或实际完成数。 */ /** 无来源事实的调用不猜测数量单位或实际完成数。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) { public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null); this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null);
@@ -18,6 +18,7 @@ public class CargoDiscardItem {
private Long returnCartonId; private Long returnCartonId;
private Long inventoryPositionId; private Long inventoryPositionId;
private String cartonMarksJson; private String cartonMarksJson;
private String actualCartonMarksJson;
private Boolean cartonScanRequired; private Boolean cartonScanRequired;
private String inboundCode; private String inboundCode;
private Long profileId; private Long profileId;
@@ -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&gt;0 AND b.hold_packages&gt;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 != &quot;&quot;'>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);
}
@@ -6,6 +6,17 @@ import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */ /** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities { public final class CargoDiscardQuantities {
/** 明细保留候选托盘预留容量,整单总箱数小于容量时按客户目标跨托盘分配。 */
public static boolean quantityPool(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
return application.getTotalPackages() != null && !lines.isEmpty()
&& lines.stream().allMatch(i -> "INVENTORY".equals(i.getSourceType()) && i.getInventoryPositionId() != null
&& !Boolean.TRUE.equals(i.getCartonScanRequired()) && (i.getCartonMarksJson() == null || "[]".equals(i.getCartonMarksJson())))
&& application.getTotalPackages() < boxes(lines, false);
}
/** 客户申请箱数与候选库存容量分别统计。 */
public static int requested(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
return lines.isEmpty() || quantityPool(application, lines) ? application.getTotalPackages() : boxes(lines, false);
}
/** 数量规则不需要实例化。 */ /** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { } private CargoDiscardQuantities() { }
@@ -13,7 +13,11 @@ public final class PdaCargoDiscardDtos {
public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) { public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
public Scan(String palletNo) { this(palletNo, null); } public Scan(String palletNo) { this(palletNo, null); }
} }
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages,
List<@NotBlank @Size(max = 128) String> scannedCartonMarks) {
/** 已关联箱唛的旧调用继续使用整托扫码清单。 */
public ActualLine(Long itemId, int actualPackages) { this(itemId, actualPackages, null); }
}
public record Confirm(@NotEmpty List<@Valid ActualLine> lines, public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds, @NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons, List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
@@ -29,5 +33,6 @@ public final class PdaCargoDiscardDtos {
boolean actualQuantityEditable) { } boolean actualQuantityEditable) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo, String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { } String scanMode, List<String> cartonMarks, String remark,
Integer requestedPackages, Integer remainingPackages) { }
} }
@@ -69,18 +69,34 @@ public class PdaCargoDiscardService {
if (!line.actualQuantityEditable() && actual != line.plannedPackages()) if (!line.actualQuantityEditable() && actual != line.plannedPackages())
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
} }
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) { // 有真实箱号必须逐箱核对,兼容字段不能绕过扫码校验。
if ("CARTON".equals(task.scanMode())) {
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks(); var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks()))) if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
throw conflict("必须扫描全部箱唛,或选择一键全部废弃"); throw conflict("必须扫描本次废弃的全部箱号");
} }
List<CargoDiscardItem> palletItems = new ArrayList<>(); List<CargoDiscardItem> palletItems = new ArrayList<>();
// 无箱唛关联的库存按现场逐箱扫码计数,箱唛仅记录实弃事实,不冒充原箱级关联。
var observed = new HashSet<String>();
var otherMarks = repository.items(application.getCustomerId(), application.getId()).stream()
.filter(i -> "INVENTORY".equals(i.getSourceType()))
.filter(i -> !physicalCode(application, i).equals(task.palletNo()))
.flatMap(i -> stockService.actualCartonMarks(i).stream()).collect(Collectors.toSet());
for (PdaCargoDiscardDtos.Sku line : task.skus()) { for (PdaCargoDiscardDtos.Sku line : task.skus()) {
CargoDiscardItem item = repository.item(line.itemId()); CargoDiscardItem item = repository.item(line.itemId());
if (item == null || !application.getCustomerId().equals(item.getCustomerId()) if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId()) || !Objects.equals(application.getId(), item.getApplicationId())
|| !physicalCode(application, item).equals(task.palletNo())) || !physicalCode(application, item).equals(task.palletNo()))
throw conflict("废弃明细不属于当前申请或托盘"); throw conflict("废弃明细不属于当前申请或托盘");
if (line.actualQuantityEditable()) {
var input = request.lines().stream().filter(i -> i.itemId().equals(line.itemId())).findFirst().orElseThrow();
var scanned = input.scannedCartonMarks() == null ? List.<String>of() : input.scannedCartonMarks();
if (scanned.size() != input.actualPackages() || scanned.stream().anyMatch(mark -> mark == null || mark.isBlank()
|| mark.length() > 128 || !mark.equals(mark.trim()) || !observed.add(mark) || otherMarks.contains(mark)))
throw conflict("实际废弃箱数必须与逐箱扫码一致,箱唛不能重复或跨托盘重复废弃");
if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned)))
throw conflict("已完成托盘的实际废弃箱唛不能修改");
}
if ("COMPLETED".equals(item.getPdaStatus()) if ("COMPLETED".equals(item.getPdaStatus())
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) && !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
throw conflict("已完成托盘的实际数量不能修改"); throw conflict("已完成托盘的实际数量不能修改");
@@ -91,6 +107,18 @@ public class PdaCargoDiscardService {
|| !application.getCustomerId().equals(item.getCustomerId()) || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId()))) || !Objects.equals(application.getId(), item.getApplicationId())))
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
if (terminal && palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) 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(), photoService.confirm(application, task.palletNo(), request.photoFileIds(),
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus())));
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。 // 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
@@ -113,12 +141,25 @@ public class PdaCargoDiscardService {
adjustments.discardPalletItem(application.getCustomerId(), application, item, links, adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
actuals.get(item.getId()), identity.operatorKey(), now); actuals.get(item.getId()), identity.operatorKey(), now);
} }
var actualMarks = task.skus().stream().filter(s -> s.itemId().equals(item.getId())).findFirst().orElseThrow().actualQuantityEditable()
? request.lines().stream().filter(i -> i.itemId().equals(item.getId())).findFirst().orElseThrow().scannedCartonMarks()
: cartonMarks(application, item);
if ("INVENTORY".equals(item.getSourceType())) stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item); item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
} }
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item) task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct() .filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
// 达到客户申请总数后无需逐个空扫其余托盘,仅释放未使用预留,不扣库存、不伪造照片。
allItems = repository.items(application.getCustomerId(), application.getId());
if (quantityPool && com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true) == application.getTotalPackages()) {
for (var unused : allItems) if (!"COMPLETED".equals(unused.getPdaStatus())) {
stockService.discard(application.getCustomerId(), identity.operatorKey(), application, unused, 0);
unused.setActualDiscardPackages(0); unused.setPdaStatus("COMPLETED");
unused.setPdaOperatorId(identity.operatorKey()); unused.setPdaCompletedAt(now); repository.saveItem(unused);
}
}
if (repository.items(application.getCustomerId(), application.getId()).stream() if (repository.items(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes( int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
@@ -179,13 +220,16 @@ public class PdaCargoDiscardService {
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList(); isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList(); var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 // 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream() boolean cartonScan = !marks.isEmpty() || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired())); .anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛"); if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(), return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(), entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks); status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks,
application.getRemark(), com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, allItems),
application.getTotalPackages() == null ? null : Math.max(0, application.getTotalPackages()
- com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true)));
}).toList(); }).toList();
} }
/** 相关托盘只从客户库存事实读取,不从请求推断。 */ /** 相关托盘只从客户库存事实读取,不从请求推断。 */
@@ -15,6 +15,6 @@ public class CargoDiscardQuantityRepository {
public int boxes(CargoDiscardApplication application) { public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId()); var lines = items.listApplication(application.getCustomerId(), application.getId());
boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus()); boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed); return lines.isEmpty() ? application.getTotalPackages() : completed ? CargoDiscardQuantities.boxes(lines, true) : CargoDiscardQuantities.requested(application, lines);
} }
} }
@@ -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); }
}
@@ -51,7 +51,7 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND")); outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false)); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application)); outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO); outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
@@ -67,7 +67,8 @@ public class CargoDiscardOutboundService {
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId()); var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配"); if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
palletNo = position.getPalletNo(); palletNo = position.getPalletNo();
cartonMarks = String.join(",", stockService.cartonMarks(line)); cartonMarks = String.join(",", line.getActualCartonMarksJson() == null
? stockService.cartonMarks(line) : stockService.actualCartonMarks(line));
} else if (returned) { } else if (returned) {
var carton = returnCartons.selectById(line.getReturnCartonId()); var carton = returnCartons.selectById(line.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId()) if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
@@ -134,7 +135,8 @@ public class CargoDiscardOutboundService {
}; };
outbound.setStatus(status); outbound.setStatus(status);
outbound.setLxCodes(null); outbound.setLxCodes(null);
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status))); outbound.setPackageCount("OUTBOUNDED".equals(status) ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true)
: com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
} }
/** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */ /** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */
@@ -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); }
}
@@ -147,6 +147,8 @@ public class CargoDiscardService {
CargoDiscardRequests.Create request) { CargoDiscardRequests.Create request) {
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
if (request.requestedPackages() != null && !StringUtils.hasText(request.remark()))
throw badRequest("无箱唛数量废弃必须填写备注");
CargoDiscardApplication replay = applications.findKey(customerId, key); CargoDiscardApplication replay = applications.findKey(customerId, key);
if (replay != null) { if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) { if (!requestHash.equals(replay.getRequestHash())) {
@@ -219,6 +221,13 @@ public class CargoDiscardService {
} }
total = Math.addExact(total, returnCartonCache.size()); total = Math.addExact(total, returnCartonCache.size());
total = Math.addExact(total, requestedBoxes.size()); total = Math.addExact(total, requestedBoxes.size());
// 客户只指定总箱数,托盘候选预留后由仓库分配实际数量。
if (request.requestedPackages() != null) {
if (request.requestedPackages() <= 0 || request.requestedPackages() > total
|| request.items().stream().anyMatch(i -> !stockService.quantitySelectionAllowed(customerId, i)))
throw badRequest("按数量废弃仅支持有托盘且无箱唛关联的库存,申请箱数不能超过可用库存");
total = request.requestedPackages();
}
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication(); CargoDiscardApplication application = new CargoDiscardApplication();
@@ -289,7 +298,9 @@ public class CargoDiscardService {
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream() List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value), value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus())) value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus(),
stockService.actualCartonMarks(value), value.getInventoryPositionId() == null ? null
: stockService.position(customerId, value.getInventoryPositionId()).getPalletNo()))
.toList(); .toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream() List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -522,7 +533,7 @@ public class CargoDiscardService {
int boxes = quantities.boxes(value); int boxes = quantities.boxes(value);
var lines = items.listApplication(value.getCustomerId(), value.getId()); var lines = items.listApplication(value.getCustomerId(), value.getId());
var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList(); var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList();
int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false); int requested = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(value, lines);
Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null) Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null)
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null; ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
@@ -25,7 +25,7 @@ public class CargoDiscardStatisticsService {
boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true; boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true;
for (var application : applications) { for (var application : applications) {
var lines = dimensions.items(application.getCustomerId(), application.getId()); var lines = dimensions.items(application.getCustomerId(), application.getId());
boxes += lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); boxes += CargoDiscardQuantities.requested(application, lines);
if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; } if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; }
var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList(); var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList();
if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); } if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); }
@@ -20,14 +20,18 @@ public class CargoDiscardStockService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
@Resource private InventoryCommandService commands; @Resource private InventoryCommandService commands;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private CargoDiscardRelabelStockService relabelStock;
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */ /** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) { public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
return repository.candidates(customer, keyword).stream().map(position -> { var positions = new LinkedHashMap<Long, InventoryPosition>();
repository.candidates(customer, keyword).forEach(p -> positions.put(p.getId(), p));
relabelStock.positions(customer, keyword).forEach(p -> positions.put(p.getId(), p));
return positions.values().stream().map(position -> {
var balance = repository.balance(position.getBalanceId()); var balance = repository.balance(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在"); if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); long available = available(customer, position, balance);
var cartons = repository.cartons(customer, position.getId()); var cartons = cartons(customer, position);
boolean complete = completeCartons(cartons, available); boolean complete = completeCartons(cartons, available);
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(), var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of(); contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
@@ -40,6 +44,15 @@ public class CargoDiscardStockService {
}).filter(c -> c.availablePackages() > 0).toList(); }).filter(c -> c.availablePackages() > 0).toList();
} }
/** 按数量选择仅适用于有托盘且没有完整逐箱关联的普通库存。 */
public boolean quantitySelectionAllowed(String customer, CargoDiscardRequests.Item request) {
if (request.inventoryPositionId() == null || request.cartonIds() != null) return false;
var position = position(customer, request.inventoryPositionId());
var balance = validate(customer, request);
return position.getPalletNo() != null && !position.getPalletNo().isBlank()
&& !completeCartons(cartons(customer, position), available(customer, position, balance));
}
/** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */ /** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) { public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
var position = position(customer, request.inventoryPositionId()); var position = position(customer, request.inventoryPositionId());
@@ -47,18 +60,19 @@ public class CargoDiscardStockService {
if (balance == null || !customer.equals(balance.getCustomerId()) if (balance == null || !customer.equals(balance.getCustomerId())
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId())) || !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
throw conflict("库存不属于当前客户或仓库"); throw conflict("库存不属于当前客户或仓库");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); long available = available(customer, position, balance);
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足"); if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
if (request.cartonIds() != null) { if (request.cartonIds() != null) {
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size()) if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
throw conflict("废弃箱子数量与所选箱子不一致或重复"); throw conflict("废弃箱子数量与所选箱子不一致或重复");
var cartons = repository.cartons(customer, position.getId()); var cartons = cartons(customer, position);
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet()); var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选"); if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子"); if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
} else { } else {
if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子");
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中");
} }
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留"); if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
return balance; return balance;
@@ -66,14 +80,16 @@ public class CargoDiscardStockService {
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
relabelStock.lock(customer, List.of(request.inventoryPositionId()));
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
var balance = validate(customer, request); var balance = validate(customer, request);
reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>()); reserveValidated(customer, operator, application, request, at, balance, cartons(customer, position(customer, request.inventoryPositionId())), new HashSet<>(), new HashSet<>());
} }
/** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */ /** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) { public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) {
var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList(); var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList();
relabelStock.lock(customer, stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().toList());
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> { stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在"); if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
}); });
@@ -82,7 +98,7 @@ public class CargoDiscardStockService {
var selectedSkus = new HashMap<String, Set<String>>(); var selectedSkus = new HashMap<String, Set<String>>();
for (var request : stocks) { for (var request : stocks) {
balances.put(request.inventoryPositionId(), validate(customer, request)); balances.put(request.inventoryPositionId(), validate(customer, request));
var facts = repository.cartons(customer, request.inventoryPositionId()); var facts = cartons(customer, position(customer, request.inventoryPositionId()));
cartons.put(request.inventoryPositionId(), facts); cartons.put(request.inventoryPositionId(), facts);
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId())) for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim()); selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
@@ -124,9 +140,15 @@ public class CargoDiscardStockService {
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录"); if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
snapshotMarks(line, marks); snapshotMarks(line, marks);
} }
int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count(); var newlyHeld = selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).toList();
if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD")); int physical = selected.isEmpty() ? request.discardPackages() : newlyHeld.size();
if (physical > 0) {
relabelStock.handoff(customer, operator, application, line, newlyHeld);
commands.hold(customer, operator, move(application, line, physical, "HOLD"));
}
repository.saveItem(line); repository.saveItem(line);
if (newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType())))
relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer);
} }
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */ /** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
@@ -165,14 +187,18 @@ public class CargoDiscardStockService {
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */ /** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) { public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
var links = repository.selectedCartons(customer, application.getId());
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
changeSelectedCartons(customer, application, line, "AVAILABLE"); changeSelectedCartons(customer, application, line, "AVAILABLE");
int physical = physicalPackages(customer, application, line); int physical = physicalPackages(customer, application, line);
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE")); if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
relabelStock.restore(customer, application, line, links);
} }
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */ /** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) { public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数"); if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
if (!actualQuantityEditable(line) && actual != line.getDiscardPackages()) if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
throw conflict("必须完成本次申请所选全部箱子,不能漏出"); throw conflict("必须完成本次申请所选全部箱子,不能漏出");
@@ -203,6 +229,7 @@ public class CargoDiscardStockService {
/** 按箱来源执行带原状态条件的变更。 */ /** 按箱来源执行带原状态条件的变更。 */
private int changeBox(String customer, Long cartonId, String source, String from, String to) { private int changeBox(String customer, Long cartonId, String source, String from, String to) {
if ("RELABEL".equals(source)) return "DISCARD_PENDING".equals(to) ? relabelStock.claim(customer, cartonId) : relabelStock.finish(customer, cartonId, "DISCARDED".equals(to));
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to); if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
return repository.changeInboundCarton(customer, cartonId, from, to); return repository.changeInboundCarton(customer, cartonId, from, to);
@@ -237,8 +264,34 @@ public class CargoDiscardStockService {
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); } try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); } catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
} }
/** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */
public void saveActualCartonMarks(CargoDiscardItem line, List<String> marks) {
try { line.setActualCartonMarksJson(objectMapper.writeValueAsString(marks)); }
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛保存失败", e); }
}
/** 废弃详情读取真实提交的现场箱唛,历史未记录时返回空。 */
public List<String> actualCartonMarks(CargoDiscardItem line) {
if (line.getActualCartonMarksJson() == null) return List.of();
try { return objectMapper.readValue(line.getActualCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛读取失败", e); }
}
/** 无 SKU 来源保留未指定标记,不猜测商品。 */ /** 无 SKU 来源保留未指定标记,不猜测商品。 */
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); } private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
/** 当前可废弃数量包括已完成并确认的换标实箱,不包含其他业务冻结。 */
private long available(String customer, InventoryPosition position, InventoryBalance balance) {
long normal = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
long relabel = relabelStock.cartons(customer, position).size();
return normal + Math.min(relabel, value(balance.getHoldPackages()));
}
/** 原转运旧箱与当前换标实箱互斥,避免换托后重复展示。 */
private List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
var moved = relabelStock.transferred(customer);
var result = new ArrayList<>(repository.cartons(customer, position.getId()).stream()
.filter(c -> !"TRANSFER".equals(c.getSourceType()) || !moved.contains(c.getId())).toList());
result.addAll(relabelStock.cartons(customer, position));
return result;
}
/** 空库存桶按零处理。 */ /** 空库存桶按零处理。 */
private static long value(Long value) { return value == null ? 0 : value; } private static long value(Long value) { return value == null ? 0 : value; }
/** 返回可恢复的业务冲突。 */ /** 返回可恢复的业务冲突。 */
@@ -328,6 +328,8 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " + "LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " + "AND wtp.warehouse_pallet_id=pos.pallet_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
// 部分废弃后只统计仍在仓内的换标箱,保留原任务供追溯。
"AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=ri.customer_id AND dc.carton_id=ri.id AND dc.source_type='RELABEL_DISCARDED') " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" + "<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" + "<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" + "<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
@@ -27,10 +27,13 @@ public class DispatchCompletionService implements DispatchCompletionPort {
private final DispatchDeliveryPort delivery; private final DispatchDeliveryPort delivery;
private final DispatchSignalPort signals; private final DispatchSignalPort signals;
private final ObjectMapper json; private final ObjectMapper json;
private final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion;
/** 仅依赖共同端口,禁止持签收子锁回调父流程。 */ /** 仅依赖共同端口,禁止持签收子锁回调父流程。 */
public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet, public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet,
DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json) { DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json,
com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion) {
this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json; this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json;
this.completion=completion;
} }
/** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */ /** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */
@Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) @Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
@@ -50,6 +53,8 @@ public class DispatchCompletionService implements DispatchCompletionPort {
if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存"); if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存");
changed=true; changed=true;
} }
// 全部正常签收后直接结案释放,不再等待人工二次确认。
if (facts.allNormallySigned()) completion.completeDelivered(identity.tripNo(), SYSTEM.operatorId());
// 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。 // 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。
signals.acknowledge(signal,Instant.now()); signals.acknowledge(signal,Instant.now());
return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(), return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(),
@@ -16,11 +16,14 @@ public final class TransportTaskModels {
@NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { } @NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { } @NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
public record Cargo(int palletCount,int packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm,
String origin,String originAddress,String originContactName,String originContactPhone,
String destination,String destinationAddress,String destinationContactName,String destinationContactPhone) { }
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution, public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
String customerId,String orderNo,List<FleetPickupModels.Task> pickups, String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> departureEvidence, List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> departureEvidence,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence, List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { } Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources,Cargo cargo) { }
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason, @NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
@Min(0) Long dispatchRecordVersion, @Min(0) Long dispatchRecordVersion,
@@ -101,12 +101,16 @@ public class TransportTaskService {
var departureIds=commandEvidence.isEmpty()?List.<Long>of():evidenceJson(commandEvidence.get(0)); var departureIds=commandEvidence.isEmpty()?List.<Long>of():evidenceJson(commandEvidence.get(0));
var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList() var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList()
: "COMPLETED".equals(trip.getStatus()) ? departureIds : List.<Long>of(); : "COMPLETED".equals(trip.getStatus()) ? departureIds : List.<Long>of();
var cargo=link==null?null:trucks.findNo(link.customerId(),link.orderNo());
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(), return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(), "PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
"LOCAL_TRUCK".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) ? metadata(trip.getId(),departureIds) : "LOCAL_TRUCK".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) ? metadata(trip.getId(),departureIds) :
"LOCAL_TRUCK".equals(row.getTaskType()) && "COMPLETED".equals(trip.getStatus()) && commandEvidence.size()>1 ? metadata(trip.getId(),departureIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(), "LOCAL_TRUCK".equals(row.getTaskType()) && "COMPLETED".equals(trip.getStatus()) && commandEvidence.size()>1 ? metadata(trip.getId(),departureIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),
"LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),row.getRecordVersion(), "LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),row.getRecordVersion(),
reviewPhotos.resources(row)); reviewPhotos.resources(row),cargo==null?null:new TransportTaskModels.Cargo(
cargo.getPalletCount()==null?0:cargo.getPalletCount(),cargo.getPackageCount()==null?0:cargo.getPackageCount(),cargo.getTotalWeightKg(),cargo.getTotalVolumeCbm(),
cargo.getOrigin(),cargo.getOriginAddress(),cargo.getOriginContactName(),cargo.getOriginContactPhone(),
cargo.getDestination(),cargo.getDestinationAddress(),cargo.getDestinationContactName(),cargo.getDestinationContactPhone()));
} }
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */ /** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);} public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
@@ -15,7 +15,9 @@ public final class DriverModels {
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, Boolean active, String reviewStatus, @Size(max = 128) String keyword, Boolean active, String reviewStatus,
String ownership, String region, String licenseState, String ownership, String region, String licenseState,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) { @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd,
@Size(max = 64) String dispatchNo) {
/** 保留唯一规范构造器供 MVC 绑定,缺省分页使用第一页、每页二十条。 */
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
} }
@@ -29,6 +29,7 @@ public class DriverService {
private final ObjectMapper json; private final ObjectMapper json;
private final Clock clock; private final Clock clock;
private final FleetWarehouseDirectory warehouses; private final FleetWarehouseDirectory warehouses;
@Autowired private com.baiyue.lastleg.fleet.port.DriverAvailability availability;
@Autowired @Autowired
public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) { public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) {
@@ -55,6 +56,7 @@ public class DriverService {
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary); page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary);
} }
/** 先排除其他车次占用,再分页统计,避免空页或错误总数。 */
private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) { private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) {
String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态"); String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态");
String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属"); String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属");
@@ -65,6 +67,10 @@ public class DriverService {
.eq(review != null, DriverEntity::getReviewStatus, review) .eq(review != null, DriverEntity::getReviewStatus, review)
.eq(ownership != null, DriverEntity::getOwnership, ownership) .eq(ownership != null, DriverEntity::getOwnership, ownership)
.eq(region != null, DriverEntity::getRegion, region); .eq(region != null, DriverEntity::getRegion, region);
if (query.dispatchNo() != null && !query.dispatchNo().isBlank()) {
var unavailable = availability.unavailable(query.dispatchNo());
if (!unavailable.isEmpty()) filter.notIn(DriverEntity::getId, unavailable);
}
if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword) if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword)
.or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword)); .or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword));
String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期"); String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期");
@@ -189,7 +189,7 @@ public class FleetPickupService {
trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator); trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return tasks(number); repository.command(number,request.idempotencyKey(),hash);return tasks(number);
} }
/** 全部送仓后结案运输,车辆和司机仍沿原有入口分别结束任务。 */ /** 全部送仓后结案运输,并在同一事务内自动释放车辆和司机。 */
@Transactional @Transactional
public FleetTripModels.Detail complete(String number,String operator,FleetPickupModels.Command request) { public FleetTripModels.Detail complete(String number,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("COMPLETE",request); var trip=pickupTrip(number,true);String hash=commandHash("COMPLETE",request);
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.port;
import java.util.List;
/** 司机候选读取权威占用的车队端口。 */
public interface DriverAvailability {
/** 返回其他车次实际占用的司机,保留当前排车资源。 */
List<Long> unavailable(String dispatchNo);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.port.DriverAvailability;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 司机候选只读取车队权威占用,保留当前排车关联资源。 */
@Repository
public class DriverAvailabilityRepository implements DriverAvailability {
private final FleetResourceMapper resources;
/** 注入资源占用持久层。 */
public DriverAvailabilityRepository(FleetResourceMapper resources) { this.resources = resources; }
/** 返回其他车次占用的司机身份。 */
public List<Long> unavailable(String dispatchNo) { return resources.unavailableDrivers(dispatchNo); }
}
@@ -28,5 +28,12 @@ public interface FleetResourceMapper {
SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo}) SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo})
""") """)
List<Long> unavailableVehicles(@Param("dispatchNo") String dispatchNo); List<Long> unavailableVehicles(@Param("dispatchNo") String dispatchNo);
/** 当前排车的司机可保留,其他车次实际占用的司机不进入候选。 */
@Select("""
SELECT a.resource_id FROM ll_fleet_resource_assignment a
WHERE a.resource_kind='DRIVER' AND NOT EXISTS (
SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo})
""")
List<Long> unavailableDrivers(@Param("dispatchNo") String dispatchNo);
record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { } record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { }
} }
@@ -58,11 +58,26 @@ public class FleetTripCompletionService {
Instant completedAt = inspection.outcomes().stream().map(FleetCompletionModels.Outcome::terminalAt).max(Instant::compareTo).orElseThrow(); Instant completedAt = inspection.outcomes().stream().map(FleetCompletionModels.Outcome::terminalAt).max(Instant::compareTo).orElseThrow();
trip.setStatus("COMPLETED"); trip.setCompletedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)); trip.setStatus("COMPLETED"); trip.setCompletedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC));
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(), hash, completedAt, Instant.now(), operator, inspection.outcomes()))); trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(), hash, completedAt, Instant.now(), operator, inspection.outcomes())));
// Completion freezes cargo outcomes. It deliberately does not release a driver still returning. // 结案保存全部货物事实,并由统一执行入口同时释放车辆和司机。
history.recordExecution(trip, request.recordVersion(), "COMPLETE", request.reason(), operator); history.recordExecution(trip, request.recordVersion(), "COMPLETE", request.reason(), operator);
return history.detail(tripNo); return history.detail(tripNo);
} }
/** 正常派送汇总后重核全车终态,未全部履约时继续保留占用。 */
@Transactional
public void completeDelivered(String tripNo, String operator) {
var trip = required(tripNo, true);
if ("COMPLETED".equals(trip.getStatus())) {
if (trip.getVehicleReleaseJson() == null || trip.getDriverReleaseJson() == null)
history.recordExecution(trip, trip.getRecordVersion(), "AUTO_RELEASE", "派送完成自动释放", operator);
return;
}
var inspection = inspect(trip, true);
if (!inspection.canComplete()) return;
complete(tripNo, operator, new FleetCompletionModels.Complete(trip.getRecordVersion(),
"AUTO_COMPLETE:" + tripNo, "全部派送完成自动结案"));
}
@Transactional @Transactional
public FleetTripModels.Detail release(String tripNo, String operator, FleetCompletionModels.Release request) { public FleetTripModels.Detail release(String tripNo, String operator, FleetCompletionModels.Release request) {
var trip = required(tripNo, true); var trip = required(tripNo, true);
@@ -681,8 +681,27 @@ public class FleetTripService {
/** 在调用方已有事务内保存执行事实及对应历史。 */ /** 在调用方已有事务内保存执行事实及对应历史。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) { public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) {
// 完成运输与释放占用同事务保存,任一步失败均不得留下半完成状态。
if ("COMPLETED".equals(trip.getStatus())) releaseCompletedResources(trip, operator);
persist(trip, version, action, reason, operator); persist(trip, version, action, reason, operator);
} }
/** 仅删除本车次占用;保留车辆主档状态及已有释放事实,不影响后续车次。 */
private void releaseCompletedResources(FleetTripEntity trip, String operator) {
if (trip.getCompletedAt() == null || trip.getCompletionJson() == null) throw conflict("缺少运输完成依据");
var stops = read(trip.getStopsJson(), new TypeReference<List<FleetTripModels.Stop>>() {});
String location = stops.isEmpty() ? null : stops.get(stops.size()-1).name();
if (location == null || location.isBlank()) throw conflict("缺少运输终点");
for (String kind : List.of("VEHICLE", "DRIVER")) {
boolean vehicle = "VEHICLE".equals(kind);
if ((vehicle ? trip.getVehicleReleaseJson() : trip.getDriverReleaseJson()) != null) continue;
Long id = vehicle ? trip.getVehicleId() : trip.getDriverId();
if (id != null) repository.release(kind, id, trip.getId());
String key = "AUTO_COMPLETE:" + trip.getTripNo() + ":" + kind;
var released = new FleetCompletionModels.Released(key, hash(key), instant(trip.getCompletedAt()),
location, null, operator, clock.instant());
if (vehicle) trip.setVehicleReleaseJson(write(released)); else trip.setDriverReleaseJson(write(released));
}
}
/** 按指定模型解析历史 JSON,损坏记录明确报错。 */ /** 按指定模型解析历史 JSON,损坏记录明确报错。 */
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } } private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } }
/** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */ /** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */
@@ -16,6 +16,10 @@ import java.math.BigDecimal;
@Mapper @Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> { public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
/** 查询当前客户及海外仓的一件代发入仓码,供贴标、转运来源过滤。 */
@org.apache.ibatis.annotations.Select("SELECT DISTINCT inbound_code FROM ll_order WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND order_type='ONE_CLICK_DROPSHIP' AND inbound_code IS NOT NULL")
java.util.List<String> listDropshipInboundCodes(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("warehouseId") String warehouseId);
// Correlate both order identity and customer; never infer a business source from remarks. // Correlate both order identity and customer; never infer a business source from remarks.
// CHAR(128) uses the connection character set/collation on both sides. These keys // CHAR(128) uses the connection character set/collation on both sides. These keys
// have at most 128 characters; table defaults differ between the fleet and order schemas. // have at most 128 characters; table defaults differ between the fleet and order schemas.
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") @RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
public class MerchantOverseasProcurementController { public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service; @Resource private OverseasProcurementService service;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
@Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; @Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
@PostMapping @PostMapping
@@ -31,7 +32,8 @@ public class MerchantOverseasProcurementController {
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> create( public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> create(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) { @Valid @RequestBody OverseasProcurementRequests.Create request) {
var created=service.create(customer(user),operator(user),request); var created=budgets.submit(customer(user),"procurement",request.idempotencyKey(),request.budgetUnitPrice().multiply(java.math.BigDecimal.valueOf(request.purchaseQuantity())),request.currency(),
()->service.create(customer(user),operator(user),request),result->result.summary().orderNo());
return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo())); return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo()));
} }
@@ -29,6 +29,12 @@ public class MerchantProcurementWorkflowController {
public ApiResponse<Workflow> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Command request) { public ApiResponse<Workflow> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(workflow.confirm(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request)); return ApiResponse.success(workflow.confirm(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request));
} }
/** 客户驳回必须说明原因,由当前认证客户确认归属。 */
@PostMapping("/{orderNo}/reject")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_CREATE+"')")
public ApiResponse<Workflow> reject(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Rejection request) {
return ApiResponse.success(workflow.reject(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request));
}
/** 只下载本单收货与出库已关联凭证,其他内部文件不外露。 */ /** 只下载本单收货与出库已关联凭证,其他内部文件不外露。 */
@GetMapping("/{orderNo}/files/{fileId}/content") @GetMapping("/{orderNo}/files/{fileId}/content")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')") @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')")
@@ -29,6 +29,12 @@ public class ProcurementWorkflowController {
public ApiResponse<Workflow> receive(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) { public ApiResponse<Workflow> receive(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) {
return ApiResponse.success(service.receive(customerId,orderNo,platform(user,customerId),request)); return ApiResponse.success(service.receive(customerId,orderNo,platform(user,customerId),request));
} }
/** 客户驳回后的照片替换复用正式收货权限。 */
@PostMapping("/{orderNo}/receipt-photos")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Workflow> replacePhotos(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ReceiptPhotos request) {
return ApiResponse.success(service.replacePhotos(customerId,orderNo,platform(user,customerId),request));
}
/** 将本单实收交给客户确认。 */ /** 将本单实收交给客户确认。 */
@PostMapping("/{orderNo}/request-confirmation") @PostMapping("/{orderNo}/request-confirmation")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
@@ -47,6 +53,16 @@ public class ProcurementWorkflowController {
public ApiResponse<Workflow> dispatch(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) { public ApiResponse<Workflow> dispatch(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.dispatch(customerId,orderNo,batchId,platform(user,customerId),request)); return ApiResponse.success(service.dispatch(customerId,orderNo,batchId,platform(user,customerId),request));
} }
@PutMapping("/{orderNo}/outbounds/{batchId}")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Workflow> editOutbound(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody OutboundEdit request) {
return ApiResponse.success(service.editOutbound(customerId,orderNo,batchId,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/outbounds/{batchId}/cancel")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Workflow> cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.cancelOutbound(customerId,orderNo,batchId,platform(user,customerId),request));
}
/** 上传签收凭证并完成本批出库。 */ /** 上传签收凭证并完成本批出库。 */
@PostMapping("/{orderNo}/outbounds/{batchId}/complete") @PostMapping("/{orderNo}/outbounds/{batchId}/complete")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
@@ -8,10 +8,16 @@ import java.util.List;
public final class ProcurementWorkflowDtos { public final class ProcurementWorkflowDtos {
private ProcurementWorkflowDtos() { } private ProcurementWorkflowDtos() { }
public record Command(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey) { } public record Command(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey) { }
public record Rejection(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason) { }
public record ReceiptPhotos(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long receiptId,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
public record Receipt(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, public record Receipt(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long quantity,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { } @Positive long quantity,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
public record Outbound(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, public record Outbound(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long quantity,@NotBlank @Size(max=255) String trackingNo) { } @Positive long quantity,@NotBlank @Size(max=255) String trackingNo) { }
public record OutboundEdit(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long quantity,@NotBlank @Size(max=255) String trackingNo) { }
public record Completion(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, public record Completion(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { } @NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
public record ReceiptRow(Long id,long quantity,List<Long> fileIds,LocalDateTime receivedAt) { } public record ReceiptRow(Long id,long quantity,List<Long> fileIds,LocalDateTime receivedAt) { }
@@ -20,5 +26,5 @@ public final class ProcurementWorkflowDtos {
public record FileInfo(Long fileId,String fileName,String contentType) { } public record FileInfo(Long fileId,String fileName,String contentType) { }
public record Workflow(String orderNo,long version,String status,long receivedQuantity,long stockQuantity, public record Workflow(String orderNo,long version,String status,long receivedQuantity,long stockQuantity,
long reservedQuantity,long availableQuantity,List<ReceiptRow> receipts, long reservedQuantity,long availableQuantity,List<ReceiptRow> receipts,
List<OutboundRow> outbounds,List<FileInfo> files) { } List<OutboundRow> outbounds,List<FileInfo> files,String rejectionReason,boolean photosNeedUpdate) { }
} }
@@ -33,6 +33,8 @@ public class OverseasProcurementOrder {
private Long inboundQuantity; private Long inboundQuantity;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
private String status; private String status;
private String customerRejectionReason;
private String rejectedPhotoIds;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private String idempotencyKey; private String idempotencyKey;
private String requestHash; private String requestHash;
@@ -22,10 +22,16 @@ public interface ProcurementWorkflowMapper {
/** 保存带照片的分批收货。 */ /** 保存带照片的分批收货。 */
@Insert("INSERT INTO ll_procurement_workflow_receipt(id,order_id,quantity,file_ids,received_at,created_by) VALUES(#{id},#{orderId},#{quantity},#{files},CURRENT_TIMESTAMP(3),#{operator})") @Insert("INSERT INTO ll_procurement_workflow_receipt(id,order_id,quantity,file_ids,received_at,created_by) VALUES(#{id},#{orderId},#{quantity},#{files},CURRENT_TIMESTAMP(3),#{operator})")
void receipt(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("files") String files,@Param("operator") String operator); void receipt(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("files") String files,@Param("operator") String operator);
/** 驳回返工只替换本批照片,不重复增加收货数量。 */
@Update("UPDATE ll_procurement_workflow_receipt SET file_ids=#{files} WHERE id=#{id} AND order_id=#{orderId}")
int replacePhotos(@Param("id") long id,@Param("orderId") long orderId,@Param("files") String files);
/** 创建占用可用采购库存的出库单。 */ /** 创建占用可用采购库存的出库单。 */
@Insert("INSERT INTO ll_procurement_workflow_outbound(id,order_id,outbound_no,quantity,tracking_no,status,file_ids,created_at,created_by) VALUES(#{id},#{orderId},#{no},#{quantity},#{tracking},'IN_TRANSIT','[]',CURRENT_TIMESTAMP(3),#{operator})") @Insert("INSERT INTO ll_procurement_workflow_outbound(id,order_id,outbound_no,quantity,tracking_no,status,file_ids,created_at,created_by) VALUES(#{id},#{orderId},#{no},#{quantity},#{tracking},'IN_TRANSIT','[]',CURRENT_TIMESTAMP(3),#{operator})")
void outbound(@Param("id") long id,@Param("orderId") long orderId,@Param("no") String no,@Param("quantity") long quantity,@Param("tracking") String tracking,@Param("operator") String operator); void outbound(@Param("id") long id,@Param("orderId") long orderId,@Param("no") String no,@Param("quantity") long quantity,@Param("tracking") String tracking,@Param("operator") String operator);
/** 派送和签收均要求批次属于已锁定订单。 */ /** 派送和签收均要求批次属于已锁定订单。 */
@Update("UPDATE ll_procurement_workflow_outbound SET status=#{status},file_ids=#{files},completed_at=#{completedAt} WHERE id=#{id} AND order_id=#{orderId}") @Update("UPDATE ll_procurement_workflow_outbound SET status=#{status},file_ids=#{files},completed_at=#{completedAt} WHERE id=#{id} AND order_id=#{orderId}")
void updateOutbound(@Param("id") long id,@Param("orderId") long orderId,@Param("status") String status,@Param("files") String files,@Param("completedAt") java.time.LocalDateTime completedAt); void updateOutbound(@Param("id") long id,@Param("orderId") long orderId,@Param("status") String status,@Param("files") String files,@Param("completedAt") java.time.LocalDateTime completedAt);
/** 修改尚未开始派送的批次。 */
@Update("UPDATE ll_procurement_workflow_outbound SET quantity=#{quantity},tracking_no=#{tracking} WHERE id=#{id} AND order_id=#{orderId} AND status='IN_TRANSIT'")
void editOutbound(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("tracking") String tracking);
} }
@@ -27,10 +27,13 @@ public class ProcurementWorkflowRepository {
public List<Map<String,Object>> outbounds(long id) { return facts.outbounds(id); } public List<Map<String,Object>> outbounds(long id) { return facts.outbounds(id); }
/** 写入收货事实。 */ /** 写入收货事实。 */
public void receipt(long id,long orderId,long quantity,String files,String operator) { facts.receipt(id,orderId,quantity,files,operator); } public void receipt(long id,long orderId,long quantity,String files,String operator) { facts.receipt(id,orderId,quantity,files,operator); }
/** 照片返工保留原始收货数量和时间。 */
public int replacePhotos(long id,long orderId,String files) { return facts.replacePhotos(id,orderId,files); }
/** 写入出库事实。 */ /** 写入出库事实。 */
public void outbound(long id,long orderId,String no,long quantity,String tracking,String operator) { facts.outbound(id,orderId,no,quantity,tracking,operator); } public void outbound(long id,long orderId,String no,long quantity,String tracking,String operator) { facts.outbound(id,orderId,no,quantity,tracking,operator); }
/** 更新出库批次。 */ /** 更新出库批次。 */
public void outbound(long id,long orderId,String status,String files,java.time.LocalDateTime completedAt) { facts.updateOutbound(id,orderId,status,files,completedAt); } public void outbound(long id,long orderId,String status,String files,java.time.LocalDateTime completedAt) { facts.updateOutbound(id,orderId,status,files,completedAt); }
public void editOutbound(long id,long orderId,long quantity,String tracking) { facts.editOutbound(id,orderId,quantity,tracking); }
/** 文件仅按当前订单及客户返回,不能引用其他采购单文件。 */ /** 文件仅按当前订单及客户返回,不能引用其他采购单文件。 */
public List<OverseasProcurementFile> files(OverseasProcurementOrder order) { public List<OverseasProcurementFile> files(OverseasProcurementOrder order) {
return files.selectList(new LambdaQueryWrapper<OverseasProcurementFile>().eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getCustomerId,order.getCustomerId())); return files.selectList(new LambdaQueryWrapper<OverseasProcurementFile>().eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getCustomerId,order.getCustomerId()));
@@ -5,7 +5,6 @@ import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository; import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository;
import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator; import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -23,7 +22,6 @@ public class ProcurementWorkflowService {
private final ProcurementWorkflowRepository repository; private final ProcurementWorkflowRepository repository;
private final LastlegDocumentNumberGenerator numbers; private final LastlegDocumentNumberGenerator numbers;
private final ObjectMapper json; private final ObjectMapper json;
private final OverseasFeeSourceService fees;
/** 客户和平台共享实际批次事实,按客户及单号读取。 */ /** 客户和平台共享实际批次事实,按客户及单号读取。 */
@Transactional(readOnly=true) @Transactional(readOnly=true)
@@ -51,6 +49,7 @@ public class ProcurementWorkflowService {
if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order); if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order);
version(order,request.version());state(order,"RECEIVING"); version(order,request.version());state(order,"RECEIVING");
if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量"); if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量");
if(snapshot(order).photosNeedUpdate()) throw conflict("客户已驳回,请重新上传各收货批次照片后再提交确认");
order.setStatus("WAIT_CUSTOMER_CONFIRM"); order.setStatus("WAIT_CUSTOMER_CONFIRM");
finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request); finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request);
return snapshot(order); return snapshot(order);
@@ -67,13 +66,39 @@ public class ProcurementWorkflowService {
return snapshot(order); return snapshot(order);
} }
/** 客户驳回后回到收货中,冻结本轮照片快照以强制重新上传。 */
@Transactional
public Workflow reject(String customer,String no,String operator,Rejection request) {
var order=order(customer,no,true);
if(replay(order,request.idempotencyKey(),"CUSTOMER_REJECT",request)) return snapshot(order);
version(order,request.version());state(order,"WAIT_CUSTOMER_CONFIRM");
if(request.reason()==null || request.reason().isBlank() || request.reason().length()>1000) throw bad("请填写驳回原因,最多1000字");
order.setCustomerRejectionReason(request.reason().trim());
order.setRejectedPhotoIds(serialize(repository.files(order).stream().map(f->f.getFileId()).distinct().toList()));
order.setStatus("RECEIVING");
finish(order,operator,request.idempotencyKey(),"CUSTOMER_REJECT",request);return snapshot(order);
}
/** 平台逐批重新上传图片,禁止复用本轮被驳回照片或追加虚假收货量。 */
@Transactional
public Workflow replacePhotos(String customer,String no,String operator,ReceiptPhotos request) {
var order=order(customer,no,true);
if(replay(order,request.idempotencyKey(),"REPLACE_PHOTOS",request)) return snapshot(order);
version(order,request.version());state(order,"RECEIVING");
if(order.getCustomerRejectionReason()==null) throw conflict("只有客户驳回后可以重新上传收货照片");
files(order,request.fileIds(),true);
var rejected=ids(order.getRejectedPhotoIds());
if(request.fileIds().stream().anyMatch(rejected::contains)) throw bad("请重新上传照片,不能复用被驳回的图片");
if(repository.replacePhotos(request.receiptId(),order.getId(),serialize(request.fileIds()))!=1) throw bad("收货批次不属于本采购单");
finish(order,operator,request.idempotencyKey(),"REPLACE_PHOTOS",request);return snapshot(order);
}
/** 创建出库先占用库存,避免其他批次重复使用相同数量;完成才实际扣除。 */ /** 创建出库先占用库存,避免其他批次重复使用相同数量;完成才实际扣除。 */
@Transactional @Transactional
public Workflow outbound(String customer,String no,String operator,Outbound request) { public Workflow outbound(String customer,String no,String operator,Outbound request) {
var order=order(customer,no,true); var order=order(customer,no,true);
if(replay(order,request.idempotencyKey(),"OUTBOUND",request)) return snapshot(order); if(replay(order,request.idempotencyKey(),"OUTBOUND",request)) return snapshot(order);
version(order,request.version());state(order,"CONFIRMED","IN_TRANSIT","DELIVERING"); version(order,request.version());state(order,"CONFIRMED","IN_TRANSIT","DELIVERING");
requireReviewedFees(order);
if(request.quantity()<=0 || request.quantity()>snapshot(order).availableQuantity()) throw conflict("出库数量超过采购可用库存"); if(request.quantity()<=0 || request.quantity()>snapshot(order).availableQuantity()) throw conflict("出库数量超过采购可用库存");
if(request.trackingNo()==null || request.trackingNo().isBlank() || request.trackingNo().length()>255) throw bad("请填写物流跟踪号"); if(request.trackingNo()==null || request.trackingNo().isBlank() || request.trackingNo().length()>255) throw bad("请填写物流跟踪号");
repository.outbound(IdWorker.getId(),order.getId(),numbers.next("CK"),request.quantity(),request.trackingNo().trim(),operator); repository.outbound(IdWorker.getId(),order.getId(),numbers.next("CK"),request.quantity(),request.trackingNo().trim(),operator);
@@ -82,15 +107,6 @@ public class ProcurementWorkflowService {
return snapshot(order); return snapshot(order);
} }
/** 本单有效应收须先添加并全部审核通过,作废费用不能替代费用明细。 */
private void requireReviewedFees(OverseasProcurementOrder order) {
var current=fees.listForBusiness(order.getCustomerId(),"OVERSEAS_PROCUREMENT",order.getId()).stream()
.filter(f->"RECEIVABLE".equals(f.getDirection()) && !"VOIDED".equals(f.getSourceStatus())).toList();
if(current.isEmpty()) throw conflict("请先添加费用明细并审核通过后再出库");
if(current.stream().anyMatch(f->!"ACTIVE".equals(f.getSourceStatus()) || !"APPROVED".equals(f.getReviewStatus())))
throw conflict("费用明细尚未全部审核通过,请先完成费用审核后再出库");
}
/** 按具体出库批次推进到派送中。 */ /** 按具体出库批次推进到派送中。 */
@Transactional @Transactional
public Workflow dispatch(String customer,String no,long batchId,String operator,Command request) { public Workflow dispatch(String customer,String no,long batchId,String operator,Command request) {
@@ -103,6 +119,31 @@ public class ProcurementWorkflowService {
return snapshot(order); return snapshot(order);
} }
/** 运输中批次允许调整数量和物流单号,开始派送后立即冻结。 */
@Transactional
public Workflow editOutbound(String customer,String no,long batchId,String operator,OutboundEdit request) {
var order=order(customer,no,true);String action="EDIT_OUTBOUND:"+batchId;
if(replay(order,request.idempotencyKey(),action,request)) return snapshot(order);
version(order,request.version());var batch=batch(order,batchId);
if(!"IN_TRANSIT".equals(batch.status())) throw conflict("开始派送后不能修改出库单");
var current=snapshot(order); long available=current.availableQuantity()+batch.quantity();
if(request.quantity()<=0 || request.quantity()>available) throw conflict("出库数量超过采购可用库存");
if(request.trackingNo()==null || request.trackingNo().isBlank() || request.trackingNo().length()>255) throw bad("请填写物流跟踪号");
repository.editOutbound(batchId,order.getId(),request.quantity(),request.trackingNo().trim());
finish(order,operator,request.idempotencyKey(),action,request); return snapshot(order);
}
/** 运输中批次允许取消;已开始派送的批次不可取消。 */
@Transactional
public Workflow cancelOutbound(String customer,String no,long batchId,String operator,Command request) {
var order=order(customer,no,true);String action="CANCEL_OUTBOUND:"+batchId;
if(replay(order,request.idempotencyKey(),action,request)) return snapshot(order);
version(order,request.version());var batch=batch(order,batchId);
if(!"IN_TRANSIT".equals(batch.status())) throw conflict("开始派送后不能取消出库单");
repository.outbound(batchId,order.getId(),"CANCELLED","[]",null); refreshStatus(order);
finish(order,operator,request.idempotencyKey(),action,request); return snapshot(order);
}
/** 签收凭证关联到本批次;同事务完成批次并扣除采购库存,重试不重复扣减。 */ /** 签收凭证关联到本批次;同事务完成批次并扣除采购库存,重试不重复扣减。 */
@Transactional @Transactional
public Workflow complete(String customer,String no,long batchId,String operator,Completion request) { public Workflow complete(String customer,String no,long batchId,String operator,Completion request) {
@@ -136,10 +177,10 @@ public class ProcurementWorkflowService {
var outbounds=repository.outbounds(order.getId()).stream().map(r->new OutboundRow(number(r,"id"),text(r,"outbound_no"),number(r,"quantity"),text(r,"tracking_no"),text(r,"status"),ids(r.get("file_ids")),date(r.get("created_at")),date(r.get("completed_at")))).toList(); var outbounds=repository.outbounds(order.getId()).stream().map(r->new OutboundRow(number(r,"id"),text(r,"outbound_no"),number(r,"quantity"),text(r,"tracking_no"),text(r,"status"),ids(r.get("file_ids")),date(r.get("created_at")),date(r.get("completed_at")))).toList();
long received=receipts.stream().mapToLong(ReceiptRow::quantity).sum(); long received=receipts.stream().mapToLong(ReceiptRow::quantity).sum();
long completed=outbounds.stream().filter(b->"COMPLETED".equals(b.status())).mapToLong(OutboundRow::quantity).sum(); long completed=outbounds.stream().filter(b->"COMPLETED".equals(b.status())).mapToLong(OutboundRow::quantity).sum();
long reserved=outbounds.stream().filter(b->!"COMPLETED".equals(b.status())).mapToLong(OutboundRow::quantity).sum(); long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum();
Set<Long> visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds())); Set<Long> visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds()));
var files=visible.isEmpty()?List.<FileInfo>of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList(); var files=visible.isEmpty()?List.<FileInfo>of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList();
return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files); return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files,order.getCustomerRejectionReason(),order.getRejectedPhotoIds()!=null && receipts.stream().flatMap(r->r.fileIds().stream()).anyMatch(ids(order.getRejectedPhotoIds())::contains));
} }
/** 文件数量、客户归属、用途和照片类型均由服务端验证。 */ /** 文件数量、客户归属、用途和照片类型均由服务端验证。 */
@@ -16,6 +16,10 @@ import java.util.List;
/** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */ /** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */
@Mapper @Mapper
public interface RelabelDispatchSourceMapper { public interface RelabelDispatchSourceMapper {
/** 已实际废弃箱不再贡献排车库存,原换标明细及作业事实继续保留追溯。 */
@Select("SELECT DISTINCT i.id FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.customer_id=i.customer_id "
+ "AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED' WHERE i.application_id=#{id}")
List<Long> discardedItems(@Param("id") Long id);
String PALLETS = "<foreach collection='palletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>"; String PALLETS = "<foreach collection='palletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
/** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */ /** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */
@@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper @Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> { public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
/** 废弃完成后原明细仅用于追溯,不再参与剩余箱的绑定门禁。 */
@org.apache.ibatis.annotations.Select("SELECT DISTINCT i.id FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc "
+ "ON dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED' WHERE i.application_id=#{id}")
java.util.List<Long> discardedItems(@org.apache.ibatis.annotations.Param("id") Long id);
/** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */ /** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */
@org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT i.return_carton_id FROM ll_relabel_item i " + @org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT i.return_carton_id FROM ll_relabel_item i " +
"JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " + "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " +
@@ -58,10 +58,11 @@ public class RelabelDispatchSourceRepository {
} }
// 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。 // 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。
// 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。 // 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。
if ("CANCELLED".equals(application.getStatus())) continue; if ("CANCELLED".equals(application.getStatus()) || "DISCARDED".equals(application.getFulfillmentStatus())) continue;
List<RelabelItem> items = mapper.items(applicationId); var discarded = new java.util.HashSet<>(mapper.discardedItems(applicationId));
List<RelabelItem> items = mapper.items(applicationId).stream().filter(i -> !discarded.contains(i.getId())).toList();
applications.add(new ApplicationRows(application, items, mapper.tasks(applicationId), applications.add(new ApplicationRows(application, items, mapper.tasks(applicationId),
mapper.histories(applicationId), mapper.facts(applicationId))); mapper.histories(applicationId), mapper.facts(applicationId).stream().filter(f -> !discarded.contains(f.getItemId())).toList()));
for (RelabelItem item : items) { for (RelabelItem item : items) {
if (item.getSourceInventoryPositionId() == null) continue; if (item.getSourceInventoryPositionId() == null) continue;
InventoryPosition position = positions.get(item.getSourceInventoryPositionId()); InventoryPosition position = positions.get(item.getSourceInventoryPositionId());
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.relabel.repository;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.Set;
import java.util.stream.Collectors;
/** 查询贴标、转运共用候选中应排除的一件代发来源。 */
@Repository
public class RelabelSourceRepository {
private final LastlegOrderMapper orders;
public RelabelSourceRepository(LastlegOrderMapper orders) { this.orders = orders; }
/** 按客户及操作仓隔离,使用入仓码关联库存,不按订单号前缀猜测业务类型。 */
public Set<String> dropshipInboundCodes(String customerId, String warehouseId) {
return orders.listDropshipInboundCodes(customerId, warehouseId).stream()
.filter(org.springframework.util.StringUtils::hasText).collect(Collectors.toSet());
}
}
@@ -91,6 +91,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private WalletCommandOutboxService walletCommands; @Resource private WalletCommandOutboxService walletCommands;
@Resource private InventoryCommandService inventoryCommands; @Resource private InventoryCommandService inventoryCommands;
@Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryBalanceMapper inventoryBalances;
@Resource private com.baiyue.lastleg.relabel.repository.RelabelSourceRepository sourceRepository;
@Resource private InventoryPositionMapper inventoryPositions; @Resource private InventoryPositionMapper inventoryPositions;
@Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@@ -257,6 +258,11 @@ public class RelabelApplicationService implements WalletResultHandler {
.gt(InventoryBalance::getAvailablePackages, 0) .gt(InventoryBalance::getAvailablePackages, 0)
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
.orderByDesc(InventoryBalance::getUpdatedAt)); .orderByDesc(InventoryBalance::getUpdatedAt));
// 贴标与仓储转运共用本候选接口,一件代发库存不得进入两处来源下拉。
if (!balances.isEmpty()) {
var dropshipCodes = sourceRepository.dropshipInboundCodes(customerId, operationWarehouseId.trim());
balances = balances.stream().filter(balance -> !dropshipCodes.contains(balance.getInboundCode())).toList();
}
Map<Long, List<InventoryPosition>> positionsByBalance = balances.isEmpty() ? Map.of() Map<Long, List<InventoryPosition>> positionsByBalance = balances.isEmpty() ? Map.of()
: inventoryPositions.listBalances(customerId, balances.stream().map(InventoryBalance::getId).toList()) : inventoryPositions.listBalances(customerId, balances.stream().map(InventoryBalance::getId).toList())
.stream().filter(position -> value(position.getAvailablePackages()) > 0) .stream().filter(position -> value(position.getAvailablePackages()) > 0)
@@ -476,6 +482,11 @@ public class RelabelApplicationService implements WalletResultHandler {
|| !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) { || !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) {
throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order"); throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order");
} }
// 旧页面或直接请求也不能用一件代发库存创建贴标申请。
if (sourceRepository.dropshipInboundCodes(customerId, request.operationWarehouseId().trim())
.contains(balance.getInboundCode())) {
throw conflict("一件代发订单不支持贴标申请");
}
if (!inventoryBalances.cartonBelongsToInbound(customerId, request.operationWarehouseId().trim(), if (!inventoryBalances.cartonBelongsToInbound(customerId, request.operationWarehouseId().trim(),
item.sourceInboundOrderId(), item.cartonMark().trim())) { item.sourceInboundOrderId(), item.cartonMark().trim())) {
throw conflict("当前箱子不存在或不属于当前订单"); throw conflict("当前箱子不存在或不属于当前订单");
@@ -27,9 +27,10 @@ public class RelabelBindingGuard {
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
public void requireBound(RelabelApplication app) { public void requireBound(RelabelApplication app) {
var discarded = new HashSet<>(items.discardedItems(app.getId()));
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>() List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())) .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
.stream().filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") .stream().filter(item -> !discarded.contains(item.getId())).filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
.contains(item.getOperationType())).toList(); .contains(item.getOperationType())).toList();
if (cartons.isEmpty()) return; if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>(); Map<String, Set<String>> bindings = new HashMap<>();
@@ -287,6 +287,10 @@ public class WarehouseTransferService {
if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && !isReturnOrderSource(inboundCode) && selectedOrders.size() != selectedIds.size())) { if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && !isReturnOrderSource(inboundCode) && selectedOrders.size() != selectedIds.size())) {
throw notFound("One or more selected customer orders do not exist in the current tenant"); throw notFound("One or more selected customer orders do not exist in the current tenant");
} }
// 与来源下拉保持一致,旧页面提交的一件代发订单也不得创建仓储转运。
if (selectedOrders.stream().anyMatch(order -> "ONE_CLICK_DROPSHIP".equals(order.getOrderType()))) {
throw conflict("一件代发订单不支持仓储转运");
}
} else { } else {
inboundCode = transferNo; inboundCode = transferNo;
inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now); inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now);
@@ -25,12 +25,14 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders") @RequestMapping("/api/merchant/v1/last-leg/us-truck-orders")
public class MerchantUsTruckOrderController { public class MerchantUsTruckOrderController {
@Resource private UsTruckOrderService service; @Resource private UsTruckOrderService service;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')")
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody UsTruckOrderRequests.Create request) { @Valid @RequestBody UsTruckOrderRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request)); return ApiResponse.success(budgets.submit(customer(user),"trucking",request.idempotencyKey(),request.budgetAmount(),request.currency(),
()->service.create(customer(user),operator(user),request),result->result.summary().orderNo()));
} }
@GetMapping @GetMapping
@@ -51,7 +53,8 @@ public class MerchantUsTruckOrderController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')")
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) { @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request)); return ApiResponse.success(budgets.update(customer(user),"trucking",orderNo,request.budgetAmount(),request.currency(),
()->service.update(customer(user),orderNo,operator(user),request)));
} }
private String customer(AuthenticatedUser user) { private String customer(AuthenticatedUser user) {
@@ -27,10 +27,10 @@ public final class UsTruckOrderRequests {
@Size(max = 500) String originAddress, @Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination, @NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress, @Size(max = 500) String destinationAddress,
@Min(0) Integer packageCount, @NotNull @Positive Integer palletCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount, @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency, @Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 128) String originContactName, @NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone, @NotBlank @Size(max = 128) String originContactPhone,
@@ -67,10 +67,10 @@ public final class UsTruckOrderRequests {
@Size(max = 500) String originAddress, @Size(max = 500) String originAddress,
@Size(max = 255) String destination, @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress, @Size(max = 500) String destinationAddress,
@Min(0) Integer packageCount, @NotNull @Positive Integer palletCount,
@DecimalMin("0.00") BigDecimal totalWeightKg, @DecimalMin("0.00") BigDecimal totalWeightKg,
@DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount, @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency, @Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 128) String originContactName, @NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone, @NotBlank @Size(max = 128) String originContactPhone,
@@ -77,8 +77,10 @@ public class UsTruckOrderService {
order.setVehicleType(request.vehicleType()); order.setVehicleType(request.vehicleType());
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
// 车型只表达用车需求;实际托数由收货记录,不能按车型虚构货量。 // 客户申报托数独立保存,件数由后续实际收货提供。
order.setPalletCount(0); order.setPackageCount(value(request.packageCount())); if(request.palletCount()==null || request.palletCount()<=0) throw badRequest("请输入大于0的托数");
if(request.budgetAmount()==null || request.budgetAmount().signum()<=0) throw badRequest("请输入大于0的预算总价");
order.setPalletCount(request.palletCount()); order.setPackageCount(0);
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
@@ -127,7 +129,7 @@ public class UsTruckOrderService {
order.setVehicleType(request.vehicleType()); order.setVehicleType(request.vehicleType());
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
if (request.packageCount() != null) order.setPackageCount(request.packageCount()); if (request.palletCount() != null) order.setPalletCount(request.palletCount());
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency())); if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim()); order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim());
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
/** 客户下单只使用当前认证客户资金,不接受请求客户ID。 */
@RestController @RequiredArgsConstructor
@RequestMapping("/api/merchant/v1/last-leg/order-budgets")
public class MerchantOrderBudgetController {
private final OrderBudgetService service;
public record Prepare(@NotNull @DecimalMin("0") @Digits(integer=12,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency) { }
/** 按业务创建权限发起预估冻结。 */
@PostMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) {
return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
}
/** 只读资金回执沿同一业务权限。 */
@GetMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
return ApiResponse.success(service.read(customer(user),kind,key));
}
/** 客户身份只取认证上下文,平台身份不可借用。 */
private String customer(AuthenticatedUser user) {
if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(user.tenantId()<=0 || "PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return user.tenantId().toString();
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 下单预估冻结的本地回执投影,不维护余额或授信账本。 */
@Data @TableName("ll_order_budget_reservation")
public class OrderBudget {
@TableId(type=IdType.INPUT) private Long id;
private String customerId;
private String kind;
private String requestKey;
private String orderNo;
private BigDecimal originalAmount;
private String currency;
private BigDecimal amountCny;
private String status;
private String errorMessage;
private String commandEventId;
private Long commandVersion;
private Boolean releaseRequested;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 全部预算冻结查询按客户和业务隔离,订单绑定与资金回执串行更新。 */
@Mapper
public interface OrderBudgetMapper extends BaseMapper<OrderBudget> {
/** 按当前客户、业务与下单键读取并锁定。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE customer_id=#{customer} AND kind=#{kind} AND request_key=#{key} FOR UPDATE")
OrderBudget lock(@Param("customer") String customer,@Param("kind") String kind,@Param("key") String key);
/** 读取已绑定订单的原始预算。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE customer_id=#{customer} AND kind=#{kind} AND order_no=#{no} FOR UPDATE")
OrderBudget lockOrder(@Param("customer") String customer,@Param("kind") String kind,@Param("no") String no);
/** 资金结果只更新当前记录,禁止跨客户凭单号写入。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE")
OrderBudget lockId(@Param("id") long id,@Param("customer") String customer);
/** 清理未下单预占及已经取消、拒绝或履约结束的订单预算,实际费用仍沿共用费用链。 */
@Select("""
SELECT b.* FROM ll_order_budget_reservation b
WHERE (b.status='RELEASE_FAILED' AND b.updated_at < TIMESTAMPADD(MINUTE,-1,CURRENT_TIMESTAMP(3))) OR (b.status IN ('FREEZE_PENDING','FROZEN','FAILED') AND b.release_requested=0 AND (
(b.order_no IS NULL AND b.created_at < TIMESTAMPADD(MINUTE,-15,CURRENT_TIMESTAMP(3)))
OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED')))
OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED')))
)) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
""")
List<OrderBudget> releasable();
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.wallet.budget;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 下单预算的唯一持久化入口。 */
@Repository @RequiredArgsConstructor
public class OrderBudgetRepository {
private final OrderBudgetMapper mapper;
/** 预算写入同业务事务。 */
public void insert(OrderBudget row) { mapper.insert(row); }
/** 保存当前资金指令与状态。 */
public void save(OrderBudget row) { mapper.updateById(row); }
/** 客户及下单键隔离锁定。 */
public OrderBudget lock(String customer,String kind,String key) { return mapper.lock(customer,kind,key); }
/** 已下单后的预估预算不可通过普通编辑绕过冻结。 */
public OrderBudget lockOrder(String customer,String kind,String no) { return mapper.lockOrder(customer,kind,no); }
/** 资金回执按原始记录及客户锁定。 */
public OrderBudget lockId(long id,String customer) { return mapper.lockId(id,customer); }
/** 有界读取待释放记录。 */
public List<OrderBudget> releasable() { return mapper.releasable(); }
}
@@ -0,0 +1,140 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.wallet.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.math.BigDecimal;
import java.time.*;
import java.util.Objects;
import java.util.Set;
import java.util.function.*;
/** Finance 冻结回执成功后才能提交客户订单;不足或超时均不能绕过。 */
@Service @RequiredArgsConstructor
public class OrderBudgetService implements WalletResultHandler {
public static final String BUSINESS_TYPE="OVERSEAS_ORDER_BUDGET";
private final OrderBudgetRepository repository;
private final OverseasFeeCurrencyConverter converter;
private final WalletCommandOutboxService wallet;
public record View(String status,BigDecimal amountCny,String message,String orderNo,boolean usable) { }
/** 预估按当月有效汇率折人民币;余额优先及授信拆分由Finance原子完成。 */
@Transactional
public View prepare(String customer,String kind,String key,BigDecimal amount,String currency) {
requireKind(kind);
if(key==null || key.isBlank() || key.length()>128 || amount==null || (amount.signum()<0 || ("trucking".equals(kind) && amount.signum()==0)) || amount.scale()>2 || !Set.of("CNY","USD").contains(currency)) throw bad("请填写有效的预估金额(卡车须大于0),最多两位小数");
var row=repository.lock(customer,kind,key);
if(row!=null) {
requireAmount(row,amount,currency);
if(!Boolean.TRUE.equals(row.getReleaseRequested()) && "FAILED".equals(row.getStatus())) sendFreeze(row);
return view(row);
}
row=new OrderBudget();row.setId(IdWorker.getId());row.setCustomerId(customer);row.setKind(kind);row.setRequestKey(key);
row.setOriginalAmount(amount);row.setCurrency(currency);
row.setAmountCny(amount.signum()==0 ? BigDecimal.ZERO : converter.convert(currency,amount,LocalDate.now(ZoneOffset.UTC)).settlementAmount());
if(amount.signum()>0 && row.getAmountCny().signum()<=0) throw bad("预估折算人民币金额须大于0");
row.setCommandVersion(0L);row.setReleaseRequested(false);row.setCreatedAt(now());row.setUpdatedAt(now());
row.setStatus("FREEZE_PENDING");repository.insert(row);if(amount.signum()==0) { row.setStatus("FROZEN");repository.save(row); } else sendFreeze(row);return view(row);
}
/** 仅返回当前客户的预占状态,不暴露资金账户或内部事件。 */
@Transactional
public View read(String customer,String kind,String key) {
requireKind(kind);var row=repository.lock(customer,kind,key);
if(row==null) throw bad("未找到本次下单的资金校验记录");return view(row);
}
/** 预算锁、创建订单及绑定在同一事务;失败保留预占供相同下单键重试。 */
@Transactional
public <T> T submit(String customer,String kind,String key,BigDecimal amount,String currency,Supplier<T> create,Function<T,String> number) {
var row=repository.lock(customer,kind,key);
if(row==null) throw bad("请先完成预估金额及余额授信校验");
requireAmount(row,amount,currency);
if(row.getOrderNo()!=null) {
var prior=create.get();if(!row.getOrderNo().equals(number.apply(prior))) throw bad("本次冻结已用于其他订单");return prior;
}
if(!"FROZEN".equals(row.getStatus()) || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad(view(row).message());
if(row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))) throw bad("本次资金预占已过期,请重新下单");
var result=create.get();String no=number.apply(result);
if(row.getOrderNo()!=null && !row.getOrderNo().equals(no)) throw bad("本次冻结已用于其他订单");
row.setOrderNo(no);row.setUpdatedAt(now());repository.save(row);return result;
}
/** 原订单预算锁与普通编辑同事务,金额变化须重新下单冻结。 */
@Transactional
public <T> T update(String customer,String kind,String no,BigDecimal amount,String currency,Supplier<T> change) {
var row=repository.lockOrder(customer,kind,no);
if(row!=null) requireAmount(row,amount,currency);
return change.get();
}
/** 仅接收本服务独立预算资金身份。 */
@Override public boolean supports(String type) { return BUSINESS_TYPE.equals(type); }
/** 相关指令ID、客户、操作、金额均匹配后才接受最终回执。 */
@Override @Transactional
public void handle(WalletCommandCompletedV1 result) {
var row=repository.lockId(Long.parseLong(result.businessNo()),String.valueOf(result.customerId()));
if(row==null || !Objects.equals(row.getCommandEventId(),result.commandEventId())) return;
String operation="FREEZE_PENDING".equals(row.getStatus())?"FREEZE":"RELEASE_PENDING".equals(row.getStatus())?"RELEASE":null;
if(operation==null) return;
if(!operation.equals(result.operation()) || result.amount()==null || row.getAmountCny().compareTo(result.amount())!=0) throw bad("预算资金回执与原指令不一致");
if("SUCCEEDED".equals(result.result())) {
String expected="FREEZE".equals(operation)?"FROZEN":"RELEASED";
if(!expected.equals(result.holdStatus())) throw bad("预算资金回执状态无效");
row.setStatus(expected);row.setErrorMessage("");
if("FROZEN".equals(expected) && Boolean.TRUE.equals(row.getReleaseRequested())) sendRelease(row);
} else if("FAILED".equals(result.result())) {
row.setStatus("FREEZE".equals(operation)?"FAILED":"RELEASE_FAILED");
row.setErrorMessage(result.errorMessage()==null?"资金处理失败,请联系平台处理":result.errorMessage());
} else throw bad("预算资金结果无效");
row.setUpdatedAt(now());repository.save(row);
}
/** 未提交预占到期释放;终态订单不再占用预算;冻结在途时先登记释放意图。 */
@Scheduled(fixedDelay=30000) @Transactional
public void releaseFinished() {
for(var row:repository.releasable()) {
row.setReleaseRequested(true);
if(Set.of("FROZEN","RELEASE_FAILED").contains(row.getStatus())) { if(row.getAmountCny().signum()==0) row.setStatus("RELEASED"); else sendRelease(row); }
else if("FAILED".equals(row.getStatus())) row.setStatus("RELEASED");
row.setUpdatedAt(now());repository.save(row);
}
}
/** 生成单调版本指令,失败重试不能冒充旧回执成功。 */
private void sendFreeze(OrderBudget row) {
row.setCommandVersion(row.getCommandVersion()+1);row.setStatus("FREEZE_PENDING");row.setErrorMessage("");
row.setCommandEventId(wallet.publish("FREEZE",Long.parseLong(row.getCustomerId()),BUSINESS_TYPE,row.getId().toString(),row.getAmountCny(),"海外订单预估金额冻结",row.getCommandVersion()));
row.setUpdatedAt(now());repository.save(row);
}
/** 释放仍由Finance处理,发出指令不能当作已解冻。 */
private void sendRelease(OrderBudget row) {
row.setCommandVersion(row.getCommandVersion()+1);row.setStatus("RELEASE_PENDING");
row.setCommandEventId(wallet.publish("RELEASE",Long.parseLong(row.getCustomerId()),BUSINESS_TYPE,row.getId().toString(),row.getAmountCny(),"海外订单预算预占释放",row.getCommandVersion()));
}
/** 预算、币种和真实下单值必须一致,禁止以小额冻结提交大额订单。 */
private void requireAmount(OrderBudget row,BigDecimal amount,String currency) {
if(amount==null || row.getOriginalAmount().compareTo(amount)!=0 || !row.getCurrency().equals(currency)) throw bad("本次预估金额已变化,请重新校验资金");
}
/** 资金状态使用确定性文案,失败透传Finance不足原因。 */
private View view(OrderBudget row) {
String message=switch(row.getStatus()) {
case "FROZEN" -> (Boolean.TRUE.equals(row.getReleaseRequested()) || (row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))))?"本次资金预占已过期,请重新下单":"预估金额冻结成功";
case "FAILED","RELEASE_FAILED" -> row.getErrorMessage();
case "RELEASED","RELEASE_PENDING" -> "本次资金预占已释放,请重新下单";
default -> "余额及授信校验处理中,请稍后再次提交";
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
}
/** 仅开放本轮客户下单业务。 */
private void requireKind(String kind) { if(!Set.of("procurement","trucking").contains(kind)) throw bad("不支持的预算业务"); }
/** 时间按UTC保存以匹配资金事件。 */
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
/** 返回可恢复的业务校验失败。 */
private ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
}
@@ -916,7 +916,7 @@ PREPARE ll_finance_fee_ddl FROM @ddl; EXECUTE ll_finance_fee_ddl; DEALLOCATE PRE
CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( CREATE TABLE IF NOT EXISTS ll_finance_fee_command (
id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', action VARCHAR(32) NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID',
source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key),
@@ -2898,6 +2898,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID', inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组', carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子', carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
actual_carton_marks_json JSON NULL COMMENT 'PDA现场实际废弃箱唛清单,与客户选箱快照独立保存',
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组', completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
@@ -2913,7 +2914,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER、换标箱RELABEL及结案标记',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
@@ -2945,6 +2946,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no),
UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key),
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), KEY idx_ll_overseas_procurement_list(customer_id,status,created_at),
@@ -4589,3 +4592,24 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
PRIMARY KEY (order_id,command_key) PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
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='海外客户下单预估资金冻结投影';
@@ -20,9 +20,10 @@ info:
RETURN_CARTON selections consume every SKU in the selected physical carton. RETURN_CARTON selections consume every SKU in the selected physical carton.
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason. Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
Stock without complete carton/pallet ownership remains available for full disposal only. Stock without complete carton/pallet ownership remains available for full disposal only.
PDA tasks return scanMode PALLET or CARTON and the real cartonMarks for loose stock. PDA tasks with real cartonMarks use CARTON scanMode, including full bound-pallet tasks.
Bound pallets require their pallet number; loose stock requires every distinct carton mark scanned, Every distinct selected carton mark must be scanned; confirmAllCartons cannot bypass this check.
or confirmAllCartons=true. The loose group uses 散箱 as its API path key, never as a scan code. Ordinary stock without carton marks uses PALLET mode and actual carton quantities.
The loose group uses 散箱 as its API path key, never as a scan code.
The application may select a subset of available cartons. PDA must finish every selected carton; The application may select a subset of available cartons. PDA must finish every selected carton;
short actual quantities are rejected. At least one photo remains required. short actual quantities are rejected. At least one photo remains required.
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks. Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
@@ -86,7 +87,7 @@ paths:
- $ref: '#/components/parameters/PalletNo' - $ref: '#/components/parameters/PalletNo'
post: post:
summary: Confirm actual disposal with owned pallet photos summary: Confirm actual disposal with owned pallet photos
description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true,PDA展示相关托盘并要求填写实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。 description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true(表示现场扫码计数,不能手填数量),PDA展示备注和相关托盘,扫描现场箱唛自动累计实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。
x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR] x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR]
requestBody: requestBody:
required: true required: true
@@ -104,16 +105,18 @@ paths:
required: [itemId, actualPackages] required: [itemId, actualPackages]
properties: properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' } itemId: { type: string, pattern: '^[1-9][0-9]*$' }
scannedCartonMarks: { type: array, uniqueItems: true, items: { type: string, minLength: 1, maxLength: 128 }, description: 无关联库存现场箱唛清单,数量须与actualPackages一致,整单跨托盘不重复,详情以actualCartonMarks回显 }
actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages } actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages }
scannedCartonMarks: scannedCartonMarks:
type: array type: array
uniqueItems: true uniqueItems: true
items: { type: string } items: { type: string }
description: All task cartonMarks must be scanned unless confirmAllCartons is true. description: All task cartonMarks must be scanned exactly once, regardless of confirmAllCartons.
confirmAllCartons: confirmAllCartons:
type: boolean type: boolean
default: false default: false
description: Explicitly confirm all loose cartons without individual scans; photos remain mandatory. deprecated: true
description: Compatibility field only; does not bypass individual carton scans. Photos remain mandatory.
photoFileIds: photoFileIds:
type: array type: array
minItems: 1 minItems: 1
@@ -42,7 +42,7 @@ paths:
- { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 } - { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 }
get: get:
summary: 排车运输详情(list) summary: 排车运输详情(list)
description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(取货车辆司机照片快照;其他类型为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。 description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(车辆司机照片快照)、cargo(本土卡车原始托数、箱数、重量kg、体积m³、起讫地地址及双方联系人电话;非本土卡车为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。
responses: *response responses: *response
/{number}/{action}: /{number}/{action}:
parameters: parameters:
@@ -28,6 +28,7 @@ paths:
- {name: region, in: query, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}} - {name: region, in: query, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}}
- {name: licenseState, in: query, schema: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]}, description: 仅驾驶证日期状态,与审核和附件状态分别展示} - {name: licenseState, in: query, schema: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]}, description: 仅驾驶证日期状态,与审核和附件状态分别展示}
- {name: expectedEnd, in: query, schema: {type: string, format: date-time}} - {name: expectedEnd, in: query, schema: {type: string, format: date-time}}
- {name: dispatchNo, in: query, schema: {type: string, maxLength: 64}, description: 排车候选上下文;在分页和统计前过滤其他车次占用,保留当前排车绑定司机}
responses: responses:
'200': '200':
description: 业务响应,分页数据 description: 业务响应,分页数据
@@ -11,7 +11,7 @@ info:
车辆和司机资格、容量与实际占用由服务端复验;候选可用不表示已预留,临时外配证据依排车主单规则核验。 车辆和司机资格、容量与实际占用由服务端复验;候选可用不表示已预留,临时外配证据依排车主单规则核验。
调度审核后经理货审核才可真实扫码;PDA拣货核平后,平台和PDA均可确认装车完成及实际发车。 调度审核后经理货审核才可真实扫码;PDA拣货核平后,平台和PDA均可确认装车完成及实际发车。
发车前整车派送安排须完整,正常签收汇总推进排车派送完成;既有异常业务和附加资源能力保留。 发车前整车派送安排须完整,正常签收汇总推进排车派送完成;既有异常业务和附加资源能力保留。
货物结案不自动释放车辆或司机;各资源任务分别提交实际结束时间、地点和原因,车辆另确认结束状态。 全车派送完成自动结案并释放车辆和司机,不需要二次确认;提货全部到仓及本土卡车完成同事务释放,保留车辆主档状态。
版本、幂等、客户隔离及事务要求以各实际端点及排车公共契约为准;历史数量不授权当前分配。 版本、幂等、客户隔离及事务要求以各实际端点及排车公共契约为准;历史数量不授权当前分配。
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -290,14 +290,14 @@ paths:
parameters: [{$ref: '#/components/parameters/TripNo'}] parameters: [{$ref: '#/components/parameters/TripNo'}]
post: post:
summary: 核对全部货物并结案车次 summary: 核对全部货物并结案车次
description: '权限:platform:fleet:trip:complete;仅IN_TRANSIT,实发数量由全部已签收或已真实退回派送单覆盖;拒收、待退回、取消不算终态完成。不改写客户单、不新增收费、不释放资源;同键同内容重放,旧版本或不同内容拒绝。' description: '权限:platform:fleet:trip:complete;仅IN_TRANSIT,实发数量由全部已签收或已真实退回派送单覆盖;拒收、待退回、取消不算终态完成。不改写客户单、不新增收费、同事务自动释放车辆和司机;同键同内容重放,旧版本或不同内容拒绝。'
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Complete'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Complete'}}}}
responses: {'200': {$ref: '#/components/responses/Detail'}} responses: {'200': {$ref: '#/components/responses/Detail'}}
/api/platform/v1/last-leg/fleet/trips/{tripNo}/resources/release: /api/platform/v1/last-leg/fleet/trips/{tripNo}/resources/release:
parameters: [{$ref: '#/components/parameters/TripNo'}] parameters: [{$ref: '#/components/parameters/TripNo'}]
post: post:
summary: 单独结束车辆或司机本次任务并释放占用 summary: 单独结束车辆或司机本次任务并释放占用
description: '权限:platform:fleet:trip:release;货物结案后才允许;实际结束时间不早于货物完成且不晚于当前时间,地点及原因必填。车辆状态与释放同事务更新;司机可继续返程而保持占用。原请求重放不释放后来另一个车次的占用。' description: '权限:platform:fleet:trip:release;货物结案后才允许;实际结束时间不早于货物完成且不晚于当前时间,地点及原因必填。该入口仅保留处理未释放记录,当前完成流程自动释放,不要求二次确认。原请求重放不释放后来另一个车次的占用。'
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Release'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Release'}}}}
responses: {'200': {$ref: '#/components/responses/Detail'}} responses: {'200': {$ref: '#/components/responses/Detail'}}
components: components:
@@ -0,0 +1,56 @@
openapi: 3.0.3
info:
title: 海外客户下单预估资金校验
version: 1.0.0
description: Finance异步冻结成功后才能创建客户采购/本土卡车订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。创建订单须使用相同幂等键、金额及币种。未绑定预占15分钟过期;订单终态释放,释放失败重试。预占独立于最终人工费用结算。
security:
- bearerAuth: []
paths:
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post:
summary: 发起冻结或读取原请求,失败后可用原键重试
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, currency]
properties:
amount: {type: number, minimum: 0, multipleOf: 0.01}
currency: {type: string, enum: [CNY, USD]}
responses:
'200': {$ref: '#/components/responses/Result'}
'403': {description: 身份或业务创建权限不足}
get:
summary: 读取当前客户本次下单的权威资金回执
description: 与POST相同创建权限;只有usable=true才允许继续下单。FREEZE_PENDING处理期间应轮询,超时保留原键重试;失败显示Finance返回原因。
responses:
'200': {$ref: '#/components/responses/Result'}
'403': {description: 身份或业务创建权限不足}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
responses:
Result:
description: 统一业务响应;code为字符串200表示查询或发起成功,不等于资金冻结成功。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
required: [status, amountCny, message, usable]
properties:
status: {type: string, enum: [FREEZE_PENDING, FROZEN, FAILED, RELEASE_PENDING, RELEASE_FAILED, RELEASED]}
amountCny: {type: number, minimum: 0}
message: {type: string}
orderNo: {type: string, nullable: true}
usable: {type: boolean}
@@ -5172,17 +5172,17 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
TruckCreate: TruckCreate:
type: object type: object
required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, idempotencyKey] required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount, idempotencyKey]
properties: properties:
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' } vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 } origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } destinationAddress: { type: string, nullable: true, maxLength: 500 }
packageCount: { type: integer, nullable: true, minimum: 0 } palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 } budgetAmount: { type: number, minimum: 0, exclusiveMinimum: true }
currency: { type: string, minLength: 3, maxLength: 3, default: USD } currency: { type: string, minLength: 3, maxLength: 3, default: USD }
originContactName: { type: string, maxLength: 128 } originContactName: { type: string, maxLength: 128 }
originContactPhone: { type: string, maxLength: 128 } originContactPhone: { type: string, maxLength: 128 }
@@ -5194,7 +5194,7 @@ components:
idempotencyKey: { type: string, maxLength: 255 } idempotencyKey: { type: string, maxLength: 255 }
TruckUpdate: TruckUpdate:
type: object type: object
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt] required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount]
properties: properties:
version: { type: integer, format: int64, minimum: 0 } version: { type: integer, format: int64, minimum: 0 }
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' } vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
@@ -5202,10 +5202,10 @@ components:
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } destinationAddress: { type: string, nullable: true, maxLength: 500 }
packageCount: { type: integer, minimum: 0 } palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 } budgetAmount: { type: number, minimum: 0, exclusiveMinimum: true }
currency: { type: string, minLength: 3, maxLength: 3 } currency: { type: string, minLength: 3, maxLength: 3 }
originContactName: { type: string, maxLength: 128 } originContactName: { type: string, maxLength: 128 }
originContactPhone: { type: string, maxLength: 128 } originContactPhone: { type: string, maxLength: 128 }
@@ -6,6 +6,33 @@ info:
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}/reject:
post:
summary: 客户驳回采购结果并填写原因
description: 仅当前认证客户,权限 merchant:cross-border:procurement:create。仅 WAIT_CUSTOMER_CONFIRM,成功回到 RECEIVING;保存原因及当前已上传照片快照,平台须重新上传各批收货照片才能再次提交确认。
parameters:
- $ref: '#/components/parameters/OrderNo'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RejectionCommand' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/receipt-photos:
post:
summary: 驳回后替换指定批次的收货照片
description: 权限 platform:overseas-business:purchase:receive-overseas。仅客户驳回后的 RECEIVING;文件须为本单新上传图片,不接受驳回前的文件;仅替换照片,不改变实收数量及时间。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ReceiptPhotosCommand' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/workflow: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/workflow:
get: get:
summary: 读取采购实收、库存和出库批次 summary: 读取采购实收、库存和出库批次
@@ -43,7 +70,7 @@ paths:
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/request-confirmation: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/request-confirmation:
post: post:
summary: 采购完成并请求客户确认 summary: 采购完成并请求客户确认
description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;提交后不再追加收货。 description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;客户驳回后必须替换各批旧照片。提交后不再追加收货。
parameters: parameters:
- $ref: '#/components/parameters/OrderNo' - $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId' - $ref: '#/components/parameters/CustomerId'
@@ -63,7 +90,7 @@ paths:
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds:
post: post:
summary: 从采购库存创建出库单 summary: 从采购库存创建出库单
description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。必须存在本采购单未作废应收费用且全部有效并审核通过;缺少费用、待审核或驳回时拒绝出库。创建后占用数量增加,不实际扣库存。 description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。费用明细缺失或尚未审核仅作提示,不阻止创建出库单。创建后占用数量增加,不实际扣库存。
parameters: parameters:
- $ref: '#/components/parameters/OrderNo' - $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId' - $ref: '#/components/parameters/CustomerId'
@@ -101,6 +128,36 @@ paths:
description: 未认证 description: 未认证
'403': '403':
description: 身份或操作权限不允许 description: 身份或操作权限不允许
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}:
put:
summary: 修改尚未开始派送的出库单
description: 仅 IN_TRANSIT 批次可修改数量和物流跟踪号;开始派送后拒绝修改。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
- $ref: '#/components/parameters/BatchId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/OutboundCommand' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/cancel:
post:
summary: 取消尚未开始派送的出库单
description: 仅 IN_TRANSIT 批次可取消;取消后释放采购库存占用,开始派送后拒绝取消。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
- $ref: '#/components/parameters/BatchId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Command' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/complete: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/complete:
post: post:
summary: 上传凭证并完成本批出库 summary: 上传凭证并完成本批出库
@@ -235,6 +292,21 @@ components:
uniqueItems: true uniqueItems: true
items: items:
$ref: '#/components/schemas/Id' $ref: '#/components/schemas/Id'
RejectionCommand:
allOf:
- $ref: '#/components/schemas/Command'
- type: object
required: [reason]
properties:
reason: {type: string, minLength: 1, maxLength: 1000}
ReceiptPhotosCommand:
allOf:
- $ref: '#/components/schemas/Command'
- type: object
required: [receiptId, fileIds]
properties:
receiptId: { $ref: '#/components/schemas/Id' }
fileIds: { $ref: '#/components/schemas/FileIds' }
ReceiptCommand: ReceiptCommand:
allOf: allOf:
- $ref: '#/components/schemas/Command' - $ref: '#/components/schemas/Command'
@@ -301,6 +373,8 @@ components:
required: [orderNo, version, status, receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files] required: [orderNo, version, status, receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files]
description: stockQuantity = receivedQuantity - 已完成出库数量;reservedQuantity 为未完成出库占用;availableQuantity = stockQuantity - reservedQuantity。主状态优先显示运输中批次,其次派送中;全部批次完成但仍有库存时回到已确认,无剩余库存才签收完成。 description: stockQuantity = receivedQuantity - 已完成出库数量;reservedQuantity 为未完成出库占用;availableQuantity = stockQuantity - reservedQuantity。主状态优先显示运输中批次,其次派送中;全部批次完成但仍有库存时回到已确认,无剩余库存才签收完成。
properties: properties:
rejectionReason: {type: string, nullable: true, description: 客户驳回原因}
photosNeedUpdate: {type: boolean, description: 存在尚未替换的被驳回照片}
orderNo: {type: string} orderNo: {type: string}
version: {type: integer, format: int64, minimum: 0} version: {type: integer, format: int64, minimum: 0}
status: status:
@@ -211,7 +211,7 @@ components:
actualPackages: {type: integer, minimum: 0} actualPackages: {type: integer, minimum: 0}
photoFileIds: {type: array, items: {type: string}} photoFileIds: {type: array, items: {type: string}}
scannedCartonMarks: {type: array, items: {type: string}} scannedCartonMarks: {type: array, items: {type: string}}
confirmAllCartons: {type: boolean} confirmAllCartons: {type: boolean, deprecated: true, description: 仅兼容旧请求,不能绕过逐箱扫码校验}
Preparation: Preparation:
type: object type: object
properties: properties:
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 执行真实 MyBatis SQL,验证仅已确认待发换标箱进入可选来源。 */
class CargoDiscardRelabelMapperTest {
@Test
void selectsConfirmedHeldBoxesAndExcludesClaimedOrShippedSources() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,overseas_warehouse_id VARCHAR(20),hold_packages BIGINT,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),hold_packages BIGINT,inbound_code VARCHAR(80))");
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR(20),relabel_no VARCHAR(80),overseas_warehouse_id VARCHAR(20),status VARCHAR(40),fulfillment_status VARCHAR(40),customer_confirmed_at TIMESTAMP,outbound_order_no VARCHAR(80),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_relabel_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(20),source_inventory_position_id BIGINT,return_carton_id BIGINT,notified_package_count INT)");
db.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(20),carton_id BIGINT,source_type VARCHAR(32))");
db.execute("CREATE TABLE ll_warehouse_transfer_inventory_allocation(customer_id VARCHAR(20),inventory_position_id BIGINT,status VARCHAR(30))");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,carton_mark VARCHAR(80),customer_carton_mark VARCHAR(80))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT)");
db.execute("INSERT INTO ll_inventory_position VALUES(10,'C',20,'W',2,30)");
db.execute("INSERT INTO ll_inventory_balance VALUES(20,'C',2,'ZY-1')");
db.execute("INSERT INTO ll_relabel_application VALUES(1,'C','TB-1','W','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH',CURRENT_TIMESTAMP,NULL,NULL),(2,'C','TB-2','W','READY_FOR_OUTBOUND','PRE_DISPATCH_CREATED',CURRENT_TIMESTAMP,NULL,NULL)");
db.execute("INSERT INTO ll_relabel_item VALUES(101,1,'C',10,NULL,1),(102,1,'C',10,NULL,1),(201,2,'C',10,NULL,1)");
db.execute("INSERT INTO ll_cargo_discard_carton VALUES('C',101,'RELABEL_DISCARDED')");
db.execute("ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT");
db.execute("ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(80)");
db.execute("UPDATE ll_relabel_item SET source_inbound_order_id=50,carton_mark='CUSTOMER-BOX-1' WHERE id=102");
db.execute("INSERT INTO ll_warehouse_transfer_order VALUES(60,'C',50),(61,'OTHER',50),(62,'C',51)");
db.execute("INSERT INTO ll_warehouse_transfer_carton VALUES(70,'C',60,'BX-SYSTEM-1','CUSTOMER-BOX-1'),"
+ "(71,'OTHER',61,'BX-OTHER-CUSTOMER','CUSTOMER-BOX-1'),(72,'C',62,'BX-OTHER-INBOUND','CUSTOMER-BOX-1')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(CargoDiscardRelabelMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession(true)) {
var mapper = session.getMapper(CargoDiscardRelabelMapper.class);
assertEquals(java.util.List.of(102L), mapper.cartons("C", 10L).stream().map(i -> i.getId()).toList());
assertEquals(java.util.List.of(10L), mapper.positions("C", "ZY-1").stream().map(p -> p.getId()).toList());
assertTrue(mapper.lockEligible("C", 102L) != null);
assertNull(mapper.lockEligible("C", 101L));
assertTrue(mapper.cartons("OTHER", 10L).isEmpty());
assertTrue(mapper.cartons("C", 999L).isEmpty());
assertEquals(java.util.List.of("BX-SYSTEM-1"), mapper.systemCartonMarks("C", 102L));
assertTrue(mapper.systemCartonMarks("OTHER", 102L).isEmpty());
db.update("UPDATE ll_relabel_item SET carton_mark='BX-SYSTEM-1' WHERE id=102");
session.clearCache();
assertEquals(java.util.List.of("BX-SYSTEM-1"), mapper.systemCartonMarks("C", 102L));
assertEquals(1, mapper.refresh("C", "TB-1"));
}
}
}
@@ -38,7 +38,7 @@ class PdaCargoDiscardServiceTest {
private CargoDiscardItem item; private CargoDiscardItem item;
private final PdaWarehouseIdentity identity = new PdaWarehouseIdentity("OP", "operator", 99L); private final PdaWarehouseIdentity identity = new PdaWarehouseIdentity("OP", "operator", 99L);
private final com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService = mock(com.baiyue.lastleg.discard.service.CargoDiscardPhotoService.class); private final com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService = mock(com.baiyue.lastleg.discard.service.CargoDiscardPhotoService.class);
private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual)), List.of(101L)); } private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual, marks(11L, actual))), List.of(101L)); }
@BeforeEach void setup() { @BeforeEach void setup() {
service = new PdaCargoDiscardService(); service = new PdaCargoDiscardService();
@@ -162,9 +162,55 @@ class PdaCargoDiscardServiceTest {
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual))); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual)));
noWrites(); verifyNoInteractions(photoService); noWrites(); verifyNoInteractions(photoService);
} }
/** 数量申请可仅使用一个托盘,也可跨托盘完成,剩余预留只释放一次。 */
@ParameterizedTest @ValueSource(ints = {0, 2})
void quantityTargetUsesChosenPalletsAndReleasesUnusedReservations(int firstActual) {
var stock = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stock); scannedStock(stock);
application.setTotalPackages(5); application.setRemark("只废弃破损外箱"); application.setStatus(CargoDiscardService.APPROVED);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(12);
var second = new CargoDiscardItem(); second.setId(12L); second.setCustomerId("CUSTOMER"); second.setApplicationId(1L);
second.setSourceType("INVENTORY"); second.setInventoryPositionId(43L); second.setSkuCode("SKU"); second.setDiscardPackages(10); second.setPdaStatus("PENDING");
when(items.listApplication("CUSTOMER", 1L)).thenAnswer(call -> List.of(item, second));
when(items.selectById(12L)).thenReturn(second);
for (var line : List.of(item, second)) {
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99");
position.setPalletNo(line == item ? "PALLET-1" : "PALLET-2");
when(stock.position("CUSTOMER", line.getInventoryPositionId())).thenReturn(position);
when(stock.actualQuantityEditable(line)).thenReturn(true);
}
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-2", "CD-1");
assertEquals("只废弃破损外箱", task.remark()); assertEquals(5, task.requestedPackages()); assertEquals(5, task.remainingPackages());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(6)));
verifyNoInteractions(photoService);
if (firstActual > 0) {
service.confirm(identity, "CD-1", "PALLET-1", request(firstActual));
assertEquals(CargoDiscardService.APPROVED, application.getStatus());
assertEquals(3, service.resolve(identity, "PALLET-2", "CD-1").remainingPackages());
}
var secondRequest = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 5-firstActual, marks(12L, 5-firstActual))), List.of(101L));
if (firstActual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-2",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 3, List.of("FIELD-11-0", "SECOND-1", "SECOND-2"))), List.of(101L))));
service.confirm(identity, "CD-1", "PALLET-2", secondRequest);
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus()); assertEquals(5, application.getTotalPackages());
verify(stock).discard("CUSTOMER", "OP", application, second, 5-firstActual);
verify(stock).discard("CUSTOMER", "OP", application, item, firstActual);
service.confirm(identity, "CD-1", "PALLET-2", secondRequest);
verify(stock, times(1)).discard("CUSTOMER", "OP", application, second, 5-firstActual);
verifyNoInteractions(adjustments);
}
/** 生成现场扫码样本,各托盘箱唛独立。 */
private List<String> marks(long itemId, int count) { return java.util.stream.IntStream.range(0, count).mapToObj(i -> "FIELD-" + itemId + "-" + i).toList(); }
/** Mock仍执行真实JSON序列化,验证重放时箱唛不可篡改。 */
private void scannedStock(com.baiyue.lastleg.discard.service.CargoDiscardStockService stock) {
ReflectionTestUtils.setField(stock, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
doCallRealMethod().when(stock).saveActualCartonMarks(any(), anyList());
when(stock.actualCartonMarks(any())).thenCallRealMethod();
}
private void looseTask() { private void looseTask() {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class); var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService); ReflectionTestUtils.setField(service, "stockService", stockService); scannedStock(stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(2); item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(2);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setId(42L); position.setCustomerId("CUSTOMER"); position.setOverseasWarehouseId("99"); position.setId(42L); position.setCustomerId("CUSTOMER"); position.setOverseasWarehouseId("99");
@@ -180,6 +226,7 @@ class PdaCargoDiscardServiceTest {
void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) { void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class); var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService); ReflectionTestUtils.setField(service, "stockService", stockService);
scannedStock(stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3); item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1"); position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1");
@@ -190,13 +237,21 @@ class PdaCargoDiscardServiceTest {
var task = service.resolve(identity, "PALLET-1", "CD-1"); var task = service.resolve(identity, "PALLET-1", "CD-1");
assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode()); assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4))); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4)));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 1)), List.of(101L))));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2, List.of("SAME", "SAME"))), List.of(101L))));
verifyNoInteractions(photoService); verifyNoInteractions(photoService);
var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual)); var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual));
assertEquals(actual, completed.skus().get(0).actualPackages()); assertEquals(actual, completed.skus().get(0).actualPackages());
assertEquals(actual, application.getTotalPackages()); assertEquals(actual, application.getTotalPackages());
assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee()); assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee());
assertEquals(marks(11L, actual), stockService.actualCartonMarks(item));
service.confirm(identity, "CD-1", "PALLET-1", request(actual)); service.confirm(identity, "CD-1", "PALLET-1", request(actual));
verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual); verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual);
if (actual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual,
java.util.stream.IntStream.range(0, actual).mapToObj(i -> "CHANGED-" + i).toList())), List.of(101L))));
verifyNoInteractions(adjustments); verifyNoInteractions(adjustments);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3))); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3)));
verify(items, times(1)).updateById(item); verify(items, times(1)).updateById(item);
@@ -210,8 +265,11 @@ class PdaCargoDiscardServiceTest {
noWrites(); verifyNoInteractions(photoService); noWrites(); verifyNoInteractions(photoService);
} }
@ParameterizedTest @ValueSource(booleans = {false, true}) @ParameterizedTest @ValueSource(booleans = {false, true})
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) { void looseTaskRequiresAllScansEvenWithBulkFlagAndReplays(boolean bulk) {
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk); looseTask();
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "散箱", looseRequest(List.of(), bulk)));
noWrites(); verifyNoInteractions(photoService);
var request = looseRequest(List.of("BOX-2", "BOX-1"), bulk);
assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status()); assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status());
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus()); assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
service.confirm(identity, "CD-1", "散箱", request); service.confirm(identity, "CD-1", "散箱", request);
@@ -242,8 +300,9 @@ class PdaCargoDiscardServiceTest {
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any()); verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
} }
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() { @ParameterizedTest @ValueSource(booleans = {false, true})
looseTask(); item.setCartonScanRequired(true); void boundPalletExposesSelectedBoxesAndRequiresTheirScan(boolean partial) {
looseTask(); item.setCartonScanRequired(partial);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService"); var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position); when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 废弃实箱扫码身份保持系统箱唛,商品仍使用换标后的 SKU。 */
class CargoDiscardRelabelStockServiceTest {
@Test
void keepsSystemCartonMarkInsteadOfCustomerOrFbaLabel() {
var repository = mock(CargoDiscardRelabelRepository.class);
var sources = mock(RelabelDispatchSourcePort.class);
var service = new CargoDiscardRelabelStockService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "sources", sources);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
var position = new InventoryPosition(); position.setId(10L); position.setPalletId(30L);
position.setOverseasWarehouseId("W"); position.setHoldPackages(1L);
var item = new RelabelItem(); item.setId(102L); item.setCartonMark("CUSTOMER-BOX-1");
item.setFbaCartonMark("FBA-BOX-1"); item.setSkuCode("OLD-SKU"); item.setNewSkuCode("NEW-SKU");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-SKU\",\"newSkuCode\":\"NEW-SKU\",\"quantity\":5}]");
when(repository.cartons("C", 10L)).thenReturn(List.of(item));
var bound = new RelabelDispatchSourcePort.BoundSource(1L, "TB-1", 102L, "C", "W", 30L,
10L, 20L, 20L, 50L, "ZY-1", "ZY-1", 1, 1, 5L, "hash", List.of());
when(sources.snapshot("W", List.of(30L))).thenReturn(new RelabelDispatchSourcePort.Snapshot(
List.of(), List.of(bound), List.of(), "version", true, List.of()));
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of("BX-SYSTEM-1"));
var box = service.cartons("C", position).get(0);
assertEquals("BX-SYSTEM-1", box.getCartonMark());
assertEquals("NEW-SKU", box.getSkuCode());
assertTrue(box.getSkuItemsJson().contains("NEW-SKU"));
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of("BX-SYSTEM-1", "BX-SYSTEM-2"));
assertThrows(ResponseStatusException.class, () -> service.cartons("C", position));
// 普通非转运来源沿用原我方箱唛,也不采用 FBA 标签。
item.setCartonMark("BX-NORMAL");
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of());
assertEquals("BX-NORMAL", service.cartons("C", position).get(0).getCartonMark());
}
}
@@ -32,6 +32,7 @@ class CargoDiscardStockServiceTest {
final CargoDiscardApplication application = new CargoDiscardApplication(); final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); } CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() { @BeforeEach void setup() {
ReflectionTestUtils.setField(service, "relabelStock", mock(CargoDiscardRelabelStockService.class));
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository(); var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository();
ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "stock", stock); ReflectionTestUtils.setField(repository, "stock", stock);
@@ -28,6 +28,7 @@ class DispatchCompletionPersistenceTest {
assertThat(changed.changed()).isTrue();assertThat(changed.stage()).isEqualTo(Stage.DELIVERED); assertThat(changed.changed()).isTrue();assertThat(changed.stage()).isEqualTo(Stage.DELIVERED);
assertThat(repeated.changed()).isFalse();assertThat(repeated.recordVersion()).isEqualTo(changed.recordVersion()); assertThat(repeated.changed()).isFalse();assertThat(repeated.recordVersion()).isEqualTo(changed.recordVersion());
assertThat(fixture.completedHistory()).isOne(); assertThat(fixture.completedHistory()).isOne();
verify(fixture.completion,atLeastOnce()).completeDelivered(anyString(),eq("SYSTEM_DELIVERY_RECONCILE"));
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
} }
/** 部分签收只确认本信号,不把异常或返回数凑作全签收;后续版本独立触发完成。 */ /** 部分签收只确认本信号,不把异常或返回数凑作全签收;后续版本独立触发完成。 */
@@ -44,6 +45,7 @@ class DispatchCompletionPersistenceTest {
private static final class CompletionFixture { private static final class CompletionFixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchSignalPort signals=mock(DispatchSignalPort.class); final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchSignalPort signals=mock(DispatchSignalPort.class);
final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion=mock(com.baiyue.lastleg.fleet.trip.FleetTripCompletionService.class);
final DispatchCompletionPort port;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5); final DispatchCompletionPort port;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5);
/** 使用真实Spring事务代理,REQUIRES_NEW与回滚均按生产注解执行。 */ /** 使用真实Spring事务代理,REQUIRES_NEW与回滚均按生产注解执行。 */
CompletionFixture() throws Exception { CompletionFixture() throws Exception {
@@ -51,7 +53,7 @@ class DispatchCompletionPersistenceTest {
f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.ofInstant(signedAt.minusSeconds(60),ZoneOffset.UTC)); f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.ofInstant(signedAt.minusSeconds(60),ZoneOffset.UTC));
f.db.execute("CREATE TABLE completion_test_signal(id BIGINT PRIMARY KEY,state VARCHAR(32))");f.db.update("INSERT INTO completion_test_signal VALUES(1,'PENDING')"); f.db.execute("CREATE TABLE completion_test_signal(id BIGINT PRIMARY KEY,state VARCHAR(32))");f.db.update("INSERT INTO completion_test_signal VALUES(1,'PENDING')");
signal=new Signal(1L,number,"DELIVERY-A",1,0,Instant.now()); signal=new Signal(1L,number,"DELIVERY-A",1,0,Instant.now());
var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json); var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json,completion);
var proxy=new ProxyFactory(target);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));port=(DispatchCompletionPort)proxy.getProxy(); var proxy=new ProxyFactory(target);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));port=(DispatchCompletionPort)proxy.getProxy();
} }
/** 父事务中的信号SQL参与同一个DataSource事务。 */ /** 父事务中的信号SQL参与同一个DataSource事务。 */
@@ -229,7 +229,7 @@ class DispatchMergedRelabelPersistenceTest {
var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_cargo_discard_carton","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate", "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) { "ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.financefee;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
class OverseasFeeCommandSchemaContractTest {
private static final String MIGRATION = "../deploy/sql/migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql";
/** 两份初始化入口均须容纳完整重试动作,避免新环境再次截断幂等命令。 */
@Test void initializationEntriesSupportFundingRetryAction() throws Exception {
for (String path : List.of("src/main/resources/db/lastleg.sql", "../deploy/sql/海外区域统一增量-MySQL8.sql")) {
String ddl = commandTable(Files.readString(Path.of(path)));
var action = Pattern.compile("\\baction\\s+varchar\\((\\d+)\\)", Pattern.CASE_INSENSITIVE).matcher(ddl);
assertThat(action.find()).as(path).isTrue();
assertThat(Integer.parseInt(action.group(1))).as(path).isGreaterThanOrEqualTo(32);
assertThat(ddl).contains("uk_ll_finance_fee_command");
}
}
/** 真实 Mapper 复现旧列截断,迁移后重试动作可落库回读且幂等唯一约束仍生效。 */
@Test void migrationAllowsFundingRetryAndPreservesIdempotency() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE SCHEMA baiyue_lastleg");
jdbc.execute("SET SCHEMA baiyue_lastleg");
// 每次 Mapper 连接都使用相同服务库,不依赖单个连接的当前 schema。
dataSource.setUrl(dataSource.getUrl() + ";SCHEMA=baiyue_lastleg");
String ddl = commandTable(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
jdbc.execute(ddl.replace("VARCHAR(32)", "VARCHAR(16)"));
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(OverseasFeeCommandMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setConfiguration(configuration);
factory.afterPropertiesSet();
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(OverseasFeeCommandMapper.class);
var command = new OverseasFeeCommand();
command.setId(1L);
command.setOperatorId("operator");
command.setAction("MANUAL_FUNDING_RETRY");
command.setIdempotencyKey("retry-1");
command.setRequestHash("a".repeat(64));
command.setSourceFeeId(701L);
command.setSourceVersion(1);
command.setCreatedAt(LocalDateTime.now());
assertThatThrownBy(() -> mapper.insert(command)).hasStackTraceContaining("Value too long");
String migration = Files.readString(Path.of(MIGRATION));
assertThat(migration).contains("ALTER TABLE baiyue_lastleg.ll_finance_fee_command", "MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT");
jdbc.execute(migration);
jdbc.execute(migration);
assertThat(mapper.insert(command)).isEqualTo(1);
var stored = mapper.find("operator", "MANUAL_FUNDING_RETRY", "retry-1");
assertThat(stored.getAction()).isEqualTo("MANUAL_FUNDING_RETRY");
assertThat(stored.getSourceFeeId()).isEqualTo(701L);
command.setId(2L);
assertThatThrownBy(() -> mapper.insert(command)).hasStackTraceContaining("UK_LL_FINANCE_FEE_COMMAND");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_finance_fee_command", Integer.class)).isEqualTo(1);
}
/** 仅提取费用命令实际建表定义,避免手写测试结构与正式基线脱节。 */
private static String commandTable(String sql) {
var table = Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? ll_finance_fee_command \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
assertThat(table.find()).isTrue();
return table.group();
}
}
@@ -55,6 +55,7 @@ class DriverAuthorizationTest {
@Configuration @Configuration
@EnableMethodSecurity @EnableMethodSecurity
static class Config { static class Config {
@Bean com.baiyue.lastleg.fleet.port.DriverAvailability availability() { return mock(com.baiyue.lastleg.fleet.port.DriverAvailability.class); }
@Bean DriverService service() { return mock(DriverService.class); } @Bean DriverService service() { return mock(DriverService.class); }
@Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); } @Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); }
} }
@@ -46,6 +46,9 @@ class DriverPersistenceTest {
configuration.addInterceptor(pagination); configuration.addInterceptor(pagination);
configuration.addMapper(DriverMapper.class); configuration.addMapper(DriverMapper.class);
configuration.addMapper(DriverHistoryMapper.class); configuration.addMapper(DriverHistoryMapper.class);
configuration.addMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class);
jdbc.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(16),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(trip_id BIGINT,dispatch_no VARCHAR(64))");
var factory = new MybatisSqlSessionFactoryBean(); var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setDataSource(ds);
factory.setConfiguration(configuration); factory.setConfiguration(configuration);
@@ -53,12 +56,25 @@ class DriverPersistenceTest {
var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class),
mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()), mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()),
Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class)); Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class));
org.springframework.test.util.ReflectionTestUtils.setField(target, "availability", new com.baiyue.lastleg.fleet.trip.DriverAvailabilityRepository(session.getMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class)));
var proxy = new ProxyFactory(target); var proxy = new ProxyFactory(target);
proxy.setProxyTargetClass(true); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (DriverService) proxy.getProxy(); service = (DriverService) proxy.getProxy();
} }
@Test void dispatchCandidatesExcludeOtherTripsBeforePagingButKeepOwnDriver() {
var own = service.create("operator", draft("本车司机"));
var occupied = service.create("operator", draft("其他车司机"));
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',?,1,'TR1')", Long.parseLong(own.id()));
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',?,2,'TR2')", Long.parseLong(occupied.id()));
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(1,'PC1')");
var result = service.page(new DriverModels.Query(1,1,null,null,null,null,null,null,null,"PC1"));
assertThat(result.total()).isEqualTo(1);
assertThat(result.records()).extracting(DriverModels.Response::id).containsExactly(own.id());
assertThat(service.page(new DriverModels.Query(1,20,null,null,null,null,null,null,null, null)).total()).isEqualTo(2);
}
@Test void historyFailureRollsBackDriverStatusAndVersion() { @Test void historyFailureRollsBackDriverStatusAndVersion() {
var driver = service.create("operator", draft("初始备注")); var driver = service.create("operator", draft("初始备注"));
jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')"); jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')");
@@ -110,16 +126,16 @@ class DriverPersistenceTest {
service.create("operator", eastern); service.create("operator", eastern);
service.create("operator", western); service.create("operator", western);
service.create("operator", draft(null)); service.create("operator", draft(null));
var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null)); var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null, null));
assertThat(all.total()).isEqualTo(3); assertThat(all.total()).isEqualTo(3);
assertThat(all.records()).hasSize(1); assertThat(all.records()).hasSize(1);
assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1)); assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1));
var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null)); var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null, null));
assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期"); assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期");
assertThat(expired.summary().expired()).isEqualTo(1); assertThat(expired.summary().expired()).isEqualTo(1);
var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null)); var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null, null));
assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING"); assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING");
var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null)); var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null, null));
assertThat(missing.total()).isEqualTo(1); assertThat(missing.total()).isEqualTo(1);
assertThat(missing.summary().expiringSoon()).isZero(); assertThat(missing.summary().expiringSoon()).isZero();
} }
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** 经真实 MVC 参数绑定验证列表入口,不能用直接调用 Service 代替。 */
class DriverQueryBindingTest {
@Test void emptyListQueryBindsDefaults() throws Exception {
var service = mock(DriverService.class);
var mvc = MockMvcBuilders.standaloneSetup(new PlatformDriverController(service)).build();
mvc.perform(get("/api/platform/v1/last-leg/fleet/drivers")).andExpect(status().isOk());
var argument = ArgumentCaptor.forClass(DriverModels.Query.class);
verify(service).page(argument.capture());
assertThat(argument.getValue().page()).isEqualTo(1);
assertThat(argument.getValue().size()).isEqualTo(20);
assertThat(argument.getValue().dispatchNo()).isNull();
}
@Test void dispatchCandidatesBindFiltersAndExpectedEnd() throws Exception {
var service = mock(DriverService.class);
var mvc = MockMvcBuilders.standaloneSetup(new PlatformDriverController(service)).build();
mvc.perform(get("/api/platform/v1/last-leg/fleet/drivers")
.param("page", "2").param("size", "100").param("keyword", "司机")
.param("active", "true").param("reviewStatus", "APPROVED")
.param("region", "OVERSEAS").param("dispatchNo", "PC1")
.param("expectedEnd", "2026-10-30T12:00:00Z")).andExpect(status().isOk());
var argument = ArgumentCaptor.forClass(DriverModels.Query.class);
verify(service).page(argument.capture());
var query = argument.getValue();
assertThat(query.page()).isEqualTo(2);
assertThat(query.size()).isEqualTo(100);
assertThat(query.keyword()).isEqualTo("司机");
assertThat(query.active()).isTrue();
assertThat(query.reviewStatus()).isEqualTo("APPROVED");
assertThat(query.region()).isEqualTo("OVERSEAS");
assertThat(query.dispatchNo()).isEqualTo("PC1");
assertThat(query.expectedEnd()).isEqualTo(Instant.parse("2026-10-30T12:00:00Z"));
}
}
@@ -48,21 +48,34 @@ class FleetTripPersistenceTest {
assertThatThrownBy(()->f.tx.execute(s->f.nativeFleetService.updatePlan(trip(f),"operator",new FleetTripModels.Plan(detail.trip().recordVersion(),Instant.now().plusSeconds(60),Instant.now().plusSeconds(120),List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("终点",null,"FBA:ONT8")),null,"改计划")))).hasMessageContaining("排车主单"); assertThatThrownBy(()->f.tx.execute(s->f.nativeFleetService.updatePlan(trip(f),"operator",new FleetTripModels.Plan(detail.trip().recordVersion(),Instant.now().plusSeconds(60),Instant.now().plusSeconds(120),List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("终点",null,"FBA:ONT8")),null,"改计划")))).hasMessageContaining("排车主单");
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero(); assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero();
} }
@Test void externalCargoCompletionAndTwoTaskEndsAreIndependentAndReplayable() throws Exception { @Test void completionReleasesBothResourcesAndReplayKeepsLaterOccupancy() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isTrue(); var scenario=new CompletedCargo();var f=scenario.f;var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isTrue();
var request=new FleetCompletionModels.Complete(inspection.recordVersion(),"complete-original","全部货物实际履约"); var request=new FleetCompletionModels.Complete(inspection.recordVersion(),"complete-original","全部货物实际履约");
var complete=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(complete.trip().status()).isEqualTo("COMPLETED"); var complete=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(complete.trip().status()).isEqualTo("COMPLETED");
FleetTripModels.Detail recovered=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(recovered).isEqualTo(complete); FleetTripModels.Detail recovered=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(recovered).isEqualTo(complete);
assertThat(complete.trip().vehicleRelease()).isNull();assertThat(complete.trip().driverRelease()).isNull(); assertThat(complete.trip().vehicleRelease()).isNotNull();assertThat(complete.trip().driverRelease()).isNotNull();
var end=Instant.now().minusSeconds(10);var vehicle=new FleetCompletionModels.Release(complete.trip().recordVersion(),"VEHICLE",end,"实际交车点","RETURNED","vehicle-end-original","外配车实际结束"); assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();
var released=f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle));assertThat(released.trip().vehicleRelease()).isNotNull();assertThat(released.trip().driverRelease()).isNull(); f.db.update("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES('DRIVER',2,999,'NEXT-TRIP')");
var driver=new FleetCompletionModels.Release(released.trip().recordVersion(),"DRIVER",end,"实际任务终点",null,"driver-end-original","司机实际结束"); f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
var finished=f.tx.execute(s->scenario.completion.release(trip(f),"operator",driver));assertThat(finished.trip().driverRelease()).isNotNull(); assertThat(f.db.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='DRIVER' AND resource_id=2",String.class)).isEqualTo("NEXT-TRIP");
assertThat(f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle)).trip().recordVersion()).isEqualTo(finished.trip().recordVersion()); verifyNoInteractions(scenario.vehicleService);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();verifyNoInteractions(scenario.vehicleService); }
/** 自动完成与资源占用、历史共同回滚,重试才真正释放。 */
@Test void automaticDeliveryCompletionRollsBackWithItsCallerAndRetries() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;
assertThatThrownBy(()->f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");throw new IllegalStateException("模拟后续失败");})).isInstanceOf(IllegalStateException.class);
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2);
f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
var result=f.nativeFleetService.detail(trip(f));
assertThat(result.trip().status()).isEqualTo("COMPLETED");
assertThat(result.trip().vehicleRelease()).isNotNull();assertThat(result.trip().driverRelease()).isNotNull();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();
} }
@Test void partialSignatureCannotCloseCargoOrEndExternalTasks() throws Exception { @Test void partialSignatureCannotCloseCargoOrEndExternalTasks() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;f.db.update("UPDATE ll_delivery_order SET signed_at=NULL,signed_packages=NULL,status='DELIVERING'"); var scenario=new CompletedCargo();var f=scenario.f;f.db.update("UPDATE ll_delivery_order SET signed_at=NULL,signed_packages=NULL,status='DELIVERING'");
f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2);
var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isFalse(); var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isFalse();
assertThatThrownBy(()->f.tx.execute(s->scenario.completion.complete(trip(f),"operator",new FleetCompletionModels.Complete(inspection.recordVersion(),"partial-cannot-close","未全签")))).isInstanceOf(RuntimeException.class); assertThatThrownBy(()->f.tx.execute(s->scenario.completion.complete(trip(f),"operator",new FleetCompletionModels.Complete(inspection.recordVersion(),"partial-cannot-close","未全签")))).isInstanceOf(RuntimeException.class);
assertThatThrownBy(()->f.tx.execute(s->scenario.completion.release(trip(f),"operator",new FleetCompletionModels.Release(inspection.recordVersion(),"DRIVER",Instant.now().minusSeconds(1),"未结束",null,"cannot-end-driver","尚未履约")))).isInstanceOf(RuntimeException.class); assertThatThrownBy(()->f.tx.execute(s->scenario.completion.release(trip(f),"operator",new FleetCompletionModels.Release(inspection.recordVersion(),"DRIVER",Instant.now().minusSeconds(1),"未结束",null,"cannot-end-driver","尚未履约")))).isInstanceOf(RuntimeException.class);
@@ -81,6 +94,12 @@ class FleetTripPersistenceTest {
f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-completion",List.of(f.source(1,"101",501,"ONT8",3)))));f.approve(created);f.picked(1,"101",501,3); f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-completion",List.of(f.source(1,"101",501,"ONT8",3)))));f.approve(created);f.picked(1,"101",501,3);
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10)); f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10));
f.db.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT',departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10)); f.db.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT',departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10));
// 为自动释放建立真实占用行,避免只验证没有资源的外配车次。
var drivers=(com.baiyue.lastleg.fleet.driver.DriverService)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"drivers");
when(drivers.detail(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.any())).thenReturn(mock(com.baiyue.lastleg.fleet.driver.DriverModels.Response.class));
f.db.update("UPDATE ll_fleet_trip SET vehicle_id=1,driver_id=2");
f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'VEHICLE',1,id,trip_no FROM ll_fleet_trip");
f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'DRIVER',2,id,trip_no FROM ll_fleet_trip");
var root=f.repository.find(created.dispatchNo());var outbound=new OutboundOrder();outbound.setId(600L);outbound.setCustomerId("101");outbound.setOutboundNo("OB-REAL");outbound.setDispatchOrderId(root.getId());outbound.setOverseasWarehouseId("9");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);f.session.getMapper(OutboundOrderMapper.class).insert(outbound); var root=f.repository.find(created.dispatchNo());var outbound=new OutboundOrder();outbound.setId(600L);outbound.setCustomerId("101");outbound.setOutboundNo("OB-REAL");outbound.setDispatchOrderId(root.getId());outbound.setOverseasWarehouseId("9");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);f.session.getMapper(OutboundOrderMapper.class).insert(outbound);
var delivery=new DeliveryOrder();delivery.setId(700L);delivery.setCustomerId("101");delivery.setDeliveryNo("DL-REAL");delivery.setDispatchOrderId(root.getId());delivery.setOutboundOrderId(600L);delivery.setOverseasWarehouseId("9");delivery.setStatus("DELIVERED");delivery.setPackageCount(3);delivery.setSignedPackages(3);delivery.setSignedAt(LocalDateTime.now().minusMinutes(5));delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");f.session.getMapper(DeliveryOrderMapper.class).insert(delivery); var delivery=new DeliveryOrder();delivery.setId(700L);delivery.setCustomerId("101");delivery.setDeliveryNo("DL-REAL");delivery.setDispatchOrderId(root.getId());delivery.setOutboundOrderId(600L);delivery.setOverseasWarehouseId("9");delivery.setStatus("DELIVERED");delivery.setPackageCount(3);delivery.setSignedPackages(3);delivery.setSignedAt(LocalDateTime.now().minusMinutes(5));delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");f.session.getMapper(DeliveryOrderMapper.class).insert(delivery);
completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,mock(VehicleRepository.class),vehicleService,f.json); completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,mock(VehicleRepository.class),vehicleService,f.json);
@@ -17,7 +17,8 @@ class OverseasProcurementOpenApiContractTest {
} }
String platform = "/api/platform/v1/last-leg/overseas-procurements/{orderNo}"; String platform = "/api/platform/v1/last-leg/overseas-procurements/{orderNo}";
for (String suffix : new String[]{"/workflow:", "/receipts:", "/request-confirmation:", for (String suffix : new String[]{"/workflow:", "/receipts:", "/request-confirmation:",
"/outbounds:", "/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) { "/outbounds:", "/outbounds/{batchId}:", "/outbounds/{batchId}/cancel:",
"/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) {
assertTrue(yaml.contains(platform + suffix), suffix); assertTrue(yaml.contains(platform + suffix), suffix);
} }
String merchant = "/api/merchant/v1/last-leg/overseas-procurements/{orderNo}"; String merchant = "/api/merchant/v1/last-leg/overseas-procurements/{orderNo}";
@@ -35,6 +35,8 @@ class ProcurementWorkflowAuthorizationTest {
controller.read(user,"9","CG-1");verify(service).read("9","CG-1"); controller.read(user,"9","CG-1");verify(service).read("9","CG-1");
assertThatThrownBy(()->controller.receive(user,"9","CG-1",new Receipt(0,"r",1,List.of(1L)))).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(()->controller.receive(user,"9","CG-1",new Receipt(0,"r",1,List.of(1L)))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.outbound(user,"9","CG-1",new Outbound(0,"o",1,"TN"))).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(()->controller.outbound(user,"9","CG-1",new Outbound(0,"o",1,"TN"))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.editOutbound(user,"9","CG-1",1,new OutboundEdit(0,"edit",1,"TN"))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.cancelOutbound(user,"9","CG-1",1,new Command(0,"cancel"))).isInstanceOf(AccessDeniedException.class);
} }
@Test void receiverCanSubmitConfirmationButCannotShip() { @Test void receiverCanSubmitConfirmationButCannotShip() {
var controller=context.getBean(ProcurementWorkflowController.class); var controller=context.getBean(ProcurementWorkflowController.class);
@@ -56,6 +58,7 @@ class ProcurementWorkflowAuthorizationTest {
var user=login(9,"MERCHANT","merchant:cross-border:procurement:list"); var user=login(9,"MERCHANT","merchant:cross-border:procurement:list");
merchant.read(user,"CG-1");verify(service).read("9","CG-1"); merchant.read(user,"CG-1");verify(service).read("9","CG-1");
assertThatThrownBy(()->merchant.confirm(user,"CG-1",new Command(0,"c"))).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(()->merchant.confirm(user,"CG-1",new Command(0,"c"))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->merchant.reject(user,"CG-1",new Rejection(0,"r","照片不符合要求"))).isInstanceOf(AccessDeniedException.class);
} }
private AuthenticatedUser login(long customer,String app,String permission) { private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission)); var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
@@ -7,8 +7,6 @@ import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*; import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository; import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository;
import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator; import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*; import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate; import org.mybatis.spring.SqlSessionTemplate;
@@ -33,7 +31,6 @@ class ProcurementWorkflowPersistenceTest {
OverseasProcurementFileMapper files; OverseasProcurementFileMapper files;
OverseasProcurementStatusHistoryMapper histories; OverseasProcurementStatusHistoryMapper histories;
ProcurementWorkflowRepository repository; ProcurementWorkflowRepository repository;
OverseasFeeSourceService fees;
@BeforeEach void setup() throws Exception { @BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
@@ -49,13 +46,32 @@ class ProcurementWorkflowPersistenceTest {
histories=session.getMapper(OverseasProcurementStatusHistoryMapper.class); histories=session.getMapper(OverseasProcurementStatusHistoryMapper.class);
repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files); repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files);
var numbers=mock(LastlegDocumentNumberGenerator.class);var sequence=new AtomicInteger();when(numbers.next("CK")).thenAnswer(i->"CK-"+sequence.incrementAndGet()); var numbers=mock(LastlegDocumentNumberGenerator.class);var sequence=new AtomicInteger();when(numbers.next("CK")).thenAnswer(i->"CK-"+sequence.incrementAndGet());
fees=mock(OverseasFeeSourceService.class); service=new ProcurementWorkflowService(repository,numbers,new ObjectMapper().findAndRegisterModules());
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED")));
service=new ProcurementWorkflowService(repository,numbers,new ObjectMapper().findAndRegisterModules(),fees);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setOrderNo("CG-1");order.setProductName("采购商品");order.setPurchaseCategory("");order.setPurchaseQuantity(10L);order.setCurrency("USD");order.setStatus("RECEIVING");order.setVersion(0L);order.setInboundQuantity(0L);order.setContactName("buyer");order.setContactPhone("123456789");order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));orders.insert(order); var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setOrderNo("CG-1");order.setProductName("采购商品");order.setPurchaseCategory("");order.setPurchaseQuantity(10L);order.setCurrency("USD");order.setStatus("RECEIVING");order.setVersion(0L);order.setInboundQuantity(0L);order.setContactName("buyer");order.setContactPhone("123456789");order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));orders.insert(order);
file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png"); file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png");
} }
/** 驳回后必须全批换新照片,返工不能重复增加库存。 */
@Test void rejectionRequiresNewPhotosWithoutChangingStock() {
tx.execute(t->service.receive("9","CG-1","7",new Receipt(0,"receipt",10,List.of(101L))));
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
assertThatThrownBy(()->tx.execute(t->service.reject("9","CG-1","8",new Rejection(2,"empty"," ")))).hasMessageContaining("原因");
assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于");
var request=new Rejection(2,"reject","照片不清晰");
var rejected=tx.execute(t->service.reject("9","CG-1","8",request));
assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isTrue();
assertThat(tx.execute(t->service.reject("9","CG-1","8",request)).version()).isEqualTo(3);
assertThatThrownBy(()->tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(3,"early")))).hasMessageContaining("重新上传");
long receipt=rejected.receipts().get(0).id();
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old",receipt,List.of(101L))))).hasMessageContaining("不能复用");
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old-upload",receipt,List.of(104L))))).hasMessageContaining("重新上传");
file(105L,11L,"9","image/png");
var current=tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"new",receipt,List.of(105L))));
assertThat(current.photosNeedUpdate()).isFalse();assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.receipts()).hasSize(1);
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(4,"again")));
assertThat(tx.execute(t->service.confirm("9","CG-1","8",new Command(5,"confirm"))).status()).isEqualTo("CONFIRMED");
}
@Test void twoReceiptsCustomerConfirmationAndTwoOutboundsDeductOnlyAtCompletion() { @Test void twoReceiptsCustomerConfirmationAndTwoOutboundsDeductOnlyAtCompletion() {
var first=new Receipt(0,"receive1",4,List.of(101L)); var first=new Receipt(0,"receive1",4,List.of(101L));
var current=tx.execute(s->service.receive("9","CG-1","7",first)); var current=tx.execute(s->service.receive("9","CG-1","7",first));
@@ -131,34 +147,29 @@ class ProcurementWorkflowPersistenceTest {
assertThat(service.read("9","CG-1").stockQuantity()).isEqualTo(10); assertThat(service.read("9","CG-1").stockQuantity()).isEqualTo(10);
} }
@Test void outboundRequiresCurrentReviewedReceivablesAndRejectedAttemptsLeaveNoInventoryChanges() { @Test void outboundCanBeEditedOrCancelledBeforeDispatchButNotAfter() {
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L)))); tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L))));
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask"))); tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok"))); tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok")));
var request=new Outbound(3,"out",3,"TRACK"); var request=new Outbound(3,"out",3,"TRACK");
for(var rows:List.of(List.<OverseasFeeSource>of(),List.of(fee("VOIDED","APPROVED")))) {
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(rows);
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细");
}
var payable=fee("ACTIVE","APPROVED");payable.setDirection("PAYABLE");
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(payable));
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细");
for(String review:List.of("PENDING","REJECTED")) {
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("ACTIVE",review)));
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("尚未全部审核通过");
}
assertThat(service.read("9","CG-1").version()).isEqualTo(3);
assertThat(service.read("9","CG-1").reservedQuantity()).isZero();
assertThat(service.read("9","CG-1").outbounds()).isEmpty();
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("VOIDED","REJECTED")));
var result=tx.execute(s->service.outbound("9","CG-1","7",request)); var result=tx.execute(s->service.outbound("9","CG-1","7",request));
assertThat(result.reservedQuantity()).isEqualTo(3); assertThat(result.reservedQuantity()).isEqualTo(3);
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of()); assertThat(result.outbounds()).hasSize(1);
assertThat(tx.execute(s->service.outbound("9","CG-1","7",request)).version()).isEqualTo(4); long batch=result.outbounds().get(0).id();
} var edited=tx.execute(s->service.editOutbound("9","CG-1",batch,"7",new OutboundEdit(4,"edit",4,"TRACK-EDIT")));
assertThat(edited.outbounds().get(0).quantity()).isEqualTo(4);
private OverseasFeeSource fee(String status,String review) { assertThat(edited.outbounds().get(0).trackingNo()).isEqualTo("TRACK-EDIT");
var fee=new OverseasFeeSource();fee.setDirection("RECEIVABLE");fee.setSourceStatus(status);fee.setReviewStatus(review);return fee; var cancelled=tx.execute(s->service.cancelOutbound("9","CG-1",batch,"7",new Command(5,"cancel")));
assertThat(cancelled.outbounds().get(0).status()).isEqualTo("CANCELLED");
assertThat(cancelled.reservedQuantity()).isZero();
assertThat(cancelled.availableQuantity()).isEqualTo(10);
var next=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(6,"out2",3,"TRACK-2")));
long activeBatch=next.outbounds().stream().filter(row->"IN_TRANSIT".equals(row.status())).findFirst().orElseThrow().id();
tx.execute(s->service.dispatch("9","CG-1",activeBatch,"7",new Command(7,"dispatch")));
assertThatThrownBy(()->tx.execute(s->service.editOutbound("9","CG-1",activeBatch,"7",new OutboundEdit(8,"edit-late",2,"TRACK-LATE"))))
.hasMessageContaining("开始派送后");
assertThatThrownBy(()->tx.execute(s->service.cancelOutbound("9","CG-1",activeBatch,"7",new Command(8,"cancel-late"))))
.hasMessageContaining("开始派送后");
} }
private void file(long id,long orderId,String customer,String type) { private void file(long id,long orderId,String customer,String type) {
@@ -142,6 +142,31 @@ class RelabelApplicationServiceTest {
assertEquals(0, occupied.cartonAvailablePackages()); assertEquals(0, occupied.cartonAvailablePackages());
assertEquals(2, occupied.skuItems().size(), "处理中库存保留占用信息,但不可再次选择"); assertEquals(2, occupied.skuItems().size(), "处理中库存保留占用信息,但不可再次选择");
} }
@Test void sharedCandidatesExcludeDropshipButKeepOrdinaryInbound() {
ReflectionTestUtils.setField(service, "returnCartons", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class));
when(orders.listDropshipInboundCodes("10001", "WH-1")).thenReturn(List.of("DROP-1"));
var excluded = new InventoryBalance(); excluded.setId(1L); excluded.setInboundCode("DROP-1");
var ordinary = new InventoryBalance(); ordinary.setId(2L); ordinary.setInboundCode("NORMAL-1");
when(inventoryBalances.selectList(any())).thenReturn(List.of(excluded, ordinary));
var position = new InventoryPosition(); position.setId(3L); position.setBalanceId(2L); position.setAvailablePackages(4L);
when(inventoryPositions.listBalances("10001", List.of(2L))).thenReturn(List.of(position));
var result = service.candidates("10001", "WH-1", null);
assertEquals(List.of("NORMAL-1"), result.stream().map(RelabelResponses.InventoryCandidate::inboundCode).toList());
}
@Test void staleDropshipSelectionCannotCreateRelabelApplication() {
when(orders.listDropshipInboundCodes("10001", "WH-1")).thenReturn(List.of("CODE-1"));
var error = assertThrows(ResponseStatusException.class,
() -> service.create("10001", "operator-1", request("dropship", 1, 1)));
assertEquals("一件代发订单不支持贴标申请", error.getReason());
verify(applications, never()).insert(any(RelabelApplication.class));
verifyNoInteractions(inventoryCommands);
}
@Test void convertedSkuRemainsAvailableForCartonLabelOnly() { @Test void convertedSkuRemainsAvailableForCartonLabelOnly() {
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
@@ -245,6 +270,7 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "walletCommands", walletCommands); ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances); ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "sourceRepository", new com.baiyue.lastleg.relabel.repository.RelabelSourceRepository(orders));
ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions); ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundItems", outboundItems); ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
@@ -60,6 +60,7 @@ class RelabelDispatchSourcePersistenceTest {
source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT, source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT,
source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80)) source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80))
"""); """);
jdbc.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(40),carton_id BIGINT,source_type VARCHAR(32))");
jdbc.execute(""" jdbc.execute("""
CREATE TABLE ll_inventory_position ( CREATE TABLE ll_inventory_position (
id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT, id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT,
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.relabel.repository.RelabelSourceRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import static org.assertj.core.api.Assertions.assertThat;
/** 用真实 Mapper 验证业务类型、客户和仓库隔离,避免跨来源误隐藏。 */
class RelabelSourceRepositoryTest {
@Test void onlyDropshipSourcesInCurrentCustomerAndWarehouseAreExcluded() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:relabel_sources;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),order_type VARCHAR(64),inbound_code VARCHAR(64))");
jdbc.execute("INSERT INTO ll_order VALUES (1,'C1','W1','ONE_CLICK_DROPSHIP','DROP'),(2,'C1','W1','WAREHOUSE_TRANSFER','NORMAL'),(3,'C2','W1','ONE_CLICK_DROPSHIP','FOREIGN'),(4,'C1','W2','ONE_CLICK_DROPSHIP','OTHER-WAREHOUSE')");
var configuration = new MybatisConfiguration();
configuration.addMapper(LastlegOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(configuration);
var repository = new RelabelSourceRepository(new SqlSessionTemplate(factory.getObject()).getMapper(LastlegOrderMapper.class));
assertThat(repository.dropshipInboundCodes("C1", "W1")).containsExactly("DROP");
assertThat(repository.dropshipInboundCodes("C2", "W1")).containsExactly("FOREIGN");
assertThat(repository.dropshipInboundCodes("C1", "W2")).containsExactly("OTHER-WAREHOUSE");
}
}
@@ -1283,6 +1283,20 @@ class WarehouseTransferServiceTest {
verify(orders, never()).insert(any(LastlegOrder.class)); verify(orders, never()).insert(any(LastlegOrder.class));
} }
@Test
void staleDropshipSelectionCannotCreateWarehouseTransfer() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
var dropship = historicalOrder(); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectList(any())).thenReturn(List.of(dropship));
var error = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request(false)));
assertEquals("一件代发订单不支持仓储转运", error.getReason());
verify(transfers, never()).insert(any(WarehouseTransferOrder.class));
verifyNoInteractions(inventory);
}
@Test @Test
void missingOrForeignCustomerNameStopsCreationBeforeAnyBusinessWrites() { void missingOrForeignCustomerNameStopsCreationBeforeAnyBusinessWrites() {
for (var customer : List.of( for (var customer : List.of(
@@ -16,7 +16,7 @@ class UsTruckOrderRequestsTest {
var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0, var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required"); BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required");
var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet()); var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet());
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt"), fields); assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount"), fields);
} }
} }
@@ -25,8 +25,8 @@ class UsTruckOrderRequestsTest {
void onlyCurrentVehicleTypesAreAccepted() { void onlyCurrentVehicleTypesAreAccepted() {
try (var factory = Validation.buildDefaultValidatorFactory()) { try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) { for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) {
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 0, var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 1,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101", BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ONE, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle"); LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle");
assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty()); assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty());
} }
@@ -91,7 +91,7 @@ class UsTruckOrderServiceTest {
assertEquals("+1-555-0101", detail.summary().destinationContactPhone()); assertEquals("+1-555-0101", detail.summary().destinationContactPhone());
assertEquals(request.pickupAt(), detail.summary().pickupAt()); assertEquals(request.pickupAt(), detail.summary().pickupAt());
assertEquals(request.deliveryAt(), detail.summary().deliveryAt()); assertEquals(request.deliveryAt(), detail.summary().deliveryAt());
assertEquals(0, detail.summary().palletCount()); assertEquals(3, detail.summary().palletCount());
assertTrue(detail.pallets().isEmpty()); assertTrue(detail.pallets().isEmpty());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid"))); assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid")));
} }
@@ -108,7 +108,7 @@ class UsTruckOrderServiceTest {
@Test @Test
void reversedTimesAreRejectedBeforeCreatingAnOrder() { void reversedTimesAreRejectedBeforeCreatingAnOrder() {
var request = create("PALLET_30", "reversed"); var request = create("PALLET_30", "reversed");
var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey()); var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.palletCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid)); assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid));
} }
@@ -169,7 +169,7 @@ class UsTruckOrderServiceTest {
private UsTruckOrderRequests.Create create(String vehicleType, String key) { private UsTruckOrderRequests.Create create(String vehicleType, String key) {
return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null, return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null,
0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", 3, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.TEN, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key); LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key);
} }
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 预算入口不允许跨业务借用权限或由平台身份指定客户资金。 */
class OrderBudgetAuthorizationTest {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
@Bean OrderBudgetService service() { return mock(OrderBudgetService.class); }
@Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); }
}
@AfterEach void cleanup() { SecurityContextHolder.clearContext();context.close(); }
@Test void procurementPermissionCannotFreezeTruckAndOnlyUsesAuthenticatedCustomer() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var user=login(9,"MERCHANT","merchant:cross-border:procurement:create");
var request=new MerchantOrderBudgetController.Prepare(BigDecimal.TEN,"USD");
controller.prepare(user,"procurement","key",request);
verify(context.getBean(OrderBudgetService.class)).prepare("9","procurement","key",BigDecimal.TEN,"USD");
assertThatThrownBy(()->controller.prepare(user,"trucking","key",request)).isInstanceOf(AccessDeniedException.class);
}
@Test void readPermissionCannotReserveAndPlatformCannotImpersonateCustomer() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var reader=login(9,"MERCHANT","merchant:cross-border:procurement:list");
assertThatThrownBy(()->controller.read(reader,"procurement","key")).isInstanceOf(AccessDeniedException.class);
var platform=login(9,"PLATFORM","merchant:cross-border:procurement:create");
assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(context.getBean(OrderBudgetService.class));
}
private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
class OrderBudgetPersistenceTest {
OrderBudgetRepository repository;
JdbcTemplate jdbc;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql);
assertThat(match.find()).isTrue();jdbc.execute(match.group());
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class);
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
repository = new OrderBudgetRepository(new SqlSessionTemplate(factory.getObject()).getMapper(OrderBudgetMapper.class));
}
@Test void isolatesCustomerAndKeepsOriginalMoneyOnBinding() {
var row = row(1,"FROZEN");repository.insert(row);
assertThat(repository.lock("10","procurement","key1")).isNull();
assertThat(repository.lockId(1,"10")).isNull();
var loaded = repository.lock("9","procurement","key1");
loaded.setOrderNo("CG1");repository.save(loaded);
assertThat(repository.lockOrder("9","procurement","CG1").getOriginalAmount()).isEqualByComparingTo("100");
assertThat(repository.lockOrder("10","procurement","CG1")).isNull();
}
@Test void selectsExpiredTerminalAndFailedReleaseButKeepsLiveOrders() {
var expired = row(1,"FREEZE_PENDING");expired.setCreatedAt(LocalDateTime.now().minusMinutes(16));repository.insert(expired);
var terminal = row(2,"FROZEN");terminal.setOrderNo("CG2");repository.insert(terminal);
jdbc.update("INSERT INTO ll_overseas_procurement_order VALUES('9','CG2','COMPLETED')");
var failed = row(3,"RELEASE_FAILED");failed.setReleaseRequested(true);failed.setUpdatedAt(LocalDateTime.now().minusMinutes(2));repository.insert(failed);
repository.insert(row(4,"FROZEN"));
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L);
}
private OrderBudget row(long id,String status) {
var row=new OrderBudget();row.setId(id);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key"+id);
row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));
row.setStatus(status);row.setCommandVersion(1L);row.setReleaseRequested(false);
row.setCreatedAt(LocalDateTime.now());row.setUpdatedAt(LocalDateTime.now());return row;
}
}

Some files were not shown because too many files have changed in this diff Show More