feat(procurement): implement sample fulfillment and settled fee workflow

This commit is contained in:
2026-10-10 02:19:38 +08:00
parent 040f3bbd93
commit a973be2103
104 changed files with 9823 additions and 714 deletions
@@ -56,6 +56,7 @@ public record BusinessFeeSourceChangedV1(
String reviewStatus,
String publicationStatus,
String sourceStatus,
String sourceHash
String sourceHash,
WalletSettlementReference walletSettlement
) {
}
@@ -15,7 +15,9 @@ public record WalletCommandCompletedV1(
BigDecimal amount,
String errorCode,
String errorMessage,
Instant completedAt
Instant completedAt,
String walletHoldId,
String walletLedgerId
) {
public static final int VERSION = 1;
}
@@ -3,7 +3,13 @@ package com.baiyue.common.event;
import java.math.BigDecimal;
import java.time.Instant;
/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */
/**
* 海外业务向 Finance 发起的客户资金指令。
* FREEZE 的 amount 为预留金额;SETTLE 全额结算;RELEASE 释放原冻结。
* SETTLE_ACTUAL 的 amount 为最终正额 CNY 实扣,原子补差或释放余款,原冻结金额保留。
* 实际零元使用 RELEASE,不发送零元结算。结算和释放按原业务冻结身份关联。
* REFUND 使用独立业务退款号并携带 originalLedgerId,关联原成功消费,不创建充值。
*/
public record WalletCommandRequestedV1(
String operation,
long customerId,
@@ -12,7 +18,8 @@ public record WalletCommandRequestedV1(
BigDecimal amount,
String remark,
String idempotencyKey,
Instant requestedAt
Instant requestedAt,
String originalLedgerId
) {
public static final int VERSION = 1;
}
@@ -0,0 +1,4 @@
package com.baiyue.common.event;
/** Finance-issued identity of a successful consumption, carried by its immutable fee source. */
public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId) { }
+16 -2
View File
@@ -1,5 +1,19 @@
# 单测试环境数据库空库初始化
`migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。
`migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。
`migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql` 保存费用链修正目标、原汇率及确认前净实扣,并关联逐笔正差额/退款费用。同步进入 Lastleg 源基线与海外统一空库基线,不新增资金账本,不重算或回填旧消费;只有匹配版本能够发送本次费用链请求。本文件存在不代表已执行。
采购实物与运输结构分别见 `migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql` 和 `V18__procurement_transport_facts.sql`:V17 保存分次实收、差异、装箱及两段整批交寄七表;V18 保存面单版本、逐箱贴单、预估核对和一对一换包装四表,并补齐采购附件的 LABEL/PURCHASE/BUDGET/EXCEPTION 分类约束。源基线及海外统一空库基线同步,节点权限统一维护于采购 V5 权限脚本。File 同时发布对应文件类型约束;不补写历史收货、装箱或发运事实,不代表已执行或已验收。
采购购买节点结构使用 `migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql`,新增单批履约事实、节点幂等审计及预算授权历史三表。独立购买/取消权限使用 `migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql`,源种子为 IAM `procurement-permissions.sql`,同步进入国内统一空库基线;只给现有平台管理员角色默认授权,不新增岗位。上述结构与权限必须随采购节点匹配版本发布,不补造旧订单购买事实,不代表已执行或已验收。
`migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql` 为采购费用增加用途、确认版本、原币汇率、原冻结及实际流水引用,并建立 `ll_procurement_wallet_attempt` 保存每次资金请求及最终结果。零元费用可以落核定事实但不生成资金命令;字段和约束与源基线、海外统一空库基线同步。执行前核对新增列、索引和表均未存在,先发布准备完整匹配版本;不回填旧费用、不释放或重扣测试存量。该脚本仅完成费用结构,后续履约表按相应变更单独核对。
`migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql` 为采购已实扣费用来源新增 `wallet_settlement` JSON 引用,保存 Finance 原资金命令、冻结及消费流水身份。源基线与海外统一空库基线同步维护;指定测试环境执行前核对列不存在,不回填历史费用,不重新收费。需与 Finance 资金回执、费用关联及 Lastleg 采购生产端的匹配版本一起发布;本文件存在不表示已经执行。
`reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接并取得对这两张表的具体重置授权;不得作为自动启动脚本或用于生产。结构必须与匹配的 Lastleg 新版一同发布,失败立即停止,不对旧数据回填。权限从 IAM 种子核对;本文件存在不表示已执行。
`migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_unicode_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。
@@ -15,7 +29,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 159 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 181 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -37,7 +51,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/157`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/181`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -0,0 +1,28 @@
-- 采购节点动作独立授权;不新建岗位,不以通用编辑权限代替购买与取消。
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5117103,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:buy','采购购买及货款核定',NULL,'POST',803,'ACTIVE',1,0),
(5117104,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:cancel','购买前取消采购订单',NULL,'POST',804,'ACTIVE',1,0),
(5117105,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-overseas','采购海外收货',NULL,'POST',805,'ACTIVE',1,0),
(5117106,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-domestic','采购国内收货',NULL,'POST',806,'ACTIVE',1,0),
(5117107,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:pack','采购装箱与确认',NULL,'POST',807,'ACTIVE',1,0),
(5117108,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:manage','采购差异处置与事实更正',NULL,'POST',808,'ACTIVE',1,0),
(5117109,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:label','采购面单维护',NULL,'POST',809,'ACTIVE',1,0),
(5117110,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:charge','采购国内核费',NULL,'POST',810,'ACTIVE',1,0),
(5117111,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:finance-adjust','采购财务调整',NULL,'POST',811,'ACTIVE',1,0),
(5117112,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:estimate','采购预估运费核对',NULL,'POST',812,'ACTIVE',1,0),
(5117113,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:apply-label','采购逐箱贴单',NULL,'POST',813,'ACTIVE',1,0),
(5117114,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-overseas','采购海外整批交寄',NULL,'POST',814,'ACTIVE',1,0),
(5117115,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-customer','采购国内寄客户',NULL,'POST',815,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN
('platform:overseas-business:purchase:buy','platform:overseas-business:purchase:cancel',
'platform:overseas-business:purchase:receive-overseas','platform:overseas-business:purchase:receive-domestic',
'platform:overseas-business:purchase:pack','platform:overseas-business:purchase:manage',
'platform:overseas-business:purchase:label','platform:overseas-business:purchase:charge','platform:overseas-business:purchase:finance-adjust',
'platform:overseas-business:purchase:estimate','platform:overseas-business:purchase:apply-label',
'platform:overseas-business:purchase:ship-overseas','platform:overseas-business:purchase:ship-customer')
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND r.status='ACTIVE' AND r.deleted=0;
@@ -0,0 +1,5 @@
-- 采购实扣费用来源关联;仅补充结构,不回填历史费用,不修改资金或业务记录。
-- 执行前核对指定测试环境、baiyue_lastleg schema 与该列尚不存在。
ALTER TABLE ll_finance_fee_source
ADD COLUMN wallet_settlement JSON NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用'
AFTER payment_voucher_file_ids;
@@ -0,0 +1,48 @@
-- 采购费用确认版本及资金尝试。仅变更结构,不回填、重扣或清理测试存量。
ALTER TABLE ll_overseas_procurement_fee
ADD COLUMN purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定',
ADD COLUMN fee_version INT NULL COMMENT '同用途费用确认版本',
ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本',
ADD COLUMN determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用',
ADD COLUMN original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD',
ADD COLUMN original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额',
ADD COLUMN exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照',
ADD COLUMN exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份',
ADD COLUMN business_date DATE NULL COMMENT '实际费用业务日期',
ADD COLUMN wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录',
ADD COLUMN wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估',
ADD COLUMN wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
ADD COLUMN wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用',
ADD COLUMN settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
ADD COLUMN source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
ADD COLUMN parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
ADD COLUMN request_hash CHAR(64) NULL COMMENT '确认请求摘要',
MODIFY COLUMN voucher_file_id BIGINT NULL COMMENT '凭证文件ID;零元可空',
MODIFY COLUMN voucher_file_name VARCHAR(255) NULL COMMENT '凭证文件名;零元可空',
DROP CHECK chk_ll_procurement_fee_amount,
ADD CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0),
ADD UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version);
CREATE TABLE ll_procurement_wallet_attempt (
id BIGINT NOT NULL COMMENT '请求尝试ID',
order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID',
attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号',
operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款',
business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号',
amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空',
command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用',
status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败',
result_hash CHAR(64) NULL COMMENT '原业务回执摘要',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用',
error_code VARCHAR(64) NULL COMMENT '业务失败码',
error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间',
completed_at DATETIME(3) NULL COMMENT '最终结果时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id),
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
KEY idx_procurement_attempt_order(customer_id,order_id,status),
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
@@ -0,0 +1,57 @@
-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。
CREATE TABLE ll_procurement_flow (
order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
purchased_at DATETIME(3) NULL COMMENT '真实购买时间',
purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品',
purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格',
purchased_quantity BIGINT NULL COMMENT '实际购买件数',
purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额',
purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD',
purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用',
purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明',
purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功',
actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID',
actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照',
actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照',
overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间',
packing_version INT NULL COMMENT '当前确认装箱清单版本',
overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间',
domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间',
charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间',
customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间',
domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人',
domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话',
domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址',
cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间',
closed_at DATETIME(3) NULL COMMENT '异常关闭时间',
control_reason VARCHAR(1000) NULL 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 '更新时间',
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
CREATE TABLE ll_procurement_command (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作',
idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据',
detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
CREATE TABLE ll_procurement_budget_authorization (
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种',
authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种',
voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据',
reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间',
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
@@ -0,0 +1,65 @@
-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。
CREATE TABLE ll_procurement_receipt (
id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内',
source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓',
good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量',
received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实',
supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
CREATE TABLE ll_procurement_disposition (
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源',
receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货',
quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实',
voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
CREATE TABLE ll_procurement_manifest (
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代',
confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
CREATE TABLE ll_procurement_carton (
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米',
width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米',
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
CREATE TABLE ll_procurement_carton_source (
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
CREATE TABLE ll_procurement_shipment (
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户',
manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间',
address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
CREATE TABLE ll_procurement_shipment_item (
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
@@ -0,0 +1,40 @@
-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。
CREATE TABLE ll_procurement_label_set (
id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本',
manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要',
sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照',
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
CREATE TABLE ll_procurement_label_item (
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始',
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
CREATE TABLE ll_procurement_estimate_review (
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
CREATE TABLE ll_procurement_repack (
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米',
height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克',
voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据',
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
ALTER TABLE ll_overseas_procurement_file
DROP CHECK chk_ll_procurement_file_category,
MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证',
ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'));
@@ -0,0 +1,20 @@
-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。
CREATE TABLE ll_procurement_fee_correction (
id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根',
idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要',
target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种',
exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份',
target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY',
difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录',
voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据',
created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
CREATE TABLE ll_procurement_fee_correction_item (
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
@@ -0,0 +1,31 @@
-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。
CREATE TABLE ll_procurement_control_note (
id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型',
target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约',
voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明',
client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容',
original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
CREATE TABLE ll_procurement_closure (
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证',
financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实',
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
CREATE TABLE ll_procurement_closure_item (
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空',
quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置',
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
@@ -0,0 +1,4 @@
-- 采购费用核定入口。仅结构变更,不回填旧财务事实,不重试或补扣测试存量。
ALTER TABLE ll_overseas_procurement_fee
ADD COLUMN confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作',
ADD CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL'));
@@ -466,6 +466,8 @@ CREATE TABLE sys_audit_outbox (
-- 服务 schema:baiyue_order
CREATE DATABASE IF NOT EXISTS baiyue_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_order;
CREATE TABLE ord_customer_address (
@@ -8284,4 +8286,33 @@ CREATE TABLE event_delivery_attempt (
KEY idx_delivery_attempt_outcome (outcome,started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
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,deleted)
VALUES
(5117103,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:buy','采购购买及货款核定',NULL,'POST',803,'ACTIVE',1,0),
(5117104,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:cancel','购买前取消采购订单',NULL,'POST',804,'ACTIVE',1,0),
(5117105,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-overseas','采购海外收货',NULL,'POST',805,'ACTIVE',1,0),
(5117106,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-domestic','采购国内收货',NULL,'POST',806,'ACTIVE',1,0),
(5117107,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:pack','采购装箱与确认',NULL,'POST',807,'ACTIVE',1,0),
(5117108,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:manage','采购差异处置与事实更正',NULL,'POST',808,'ACTIVE',1,0),
(5117109,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:label','采购面单维护',NULL,'POST',809,'ACTIVE',1,0),
(5117110,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:charge','采购国内核费',NULL,'POST',810,'ACTIVE',1,0),
(5117111,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:finance-adjust','采购财务调整',NULL,'POST',811,'ACTIVE',1,0),
(5117112,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:estimate','采购预估运费核对',NULL,'POST',812,'ACTIVE',1,0),
(5117113,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:apply-label','采购逐箱贴单',NULL,'POST',813,'ACTIVE',1,0),
(5117114,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-overseas','采购海外整批交寄',NULL,'POST',814,'ACTIVE',1,0),
(5117115,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-customer','采购国内寄客户',NULL,'POST',815,'ACTIVE',1,0);
INSERT INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN
('platform:overseas-business:purchase:buy','platform:overseas-business:purchase:cancel',
'platform:overseas-business:purchase:receive-overseas','platform:overseas-business:purchase:receive-domestic',
'platform:overseas-business:purchase:pack','platform:overseas-business:purchase:manage',
'platform:overseas-business:purchase:label','platform:overseas-business:purchase:charge','platform:overseas-business:purchase:finance-adjust',
'platform:overseas-business:purchase:estimate','platform:overseas-business:purchase:apply-label',
'platform:overseas-business:purchase:ship-overseas','platform:overseas-business:purchase:ship-customer')
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND r.status='ACTIVE' AND r.deleted=0;
SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables;
+267 -11
View File
@@ -805,6 +805,7 @@ CREATE TABLE ll_finance_fee_source (
paid_at datetime(3) DEFAULT NULL COMMENT '直接已付时间',
payment_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付方式',
payment_reference_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付流水号',
wallet_settlement json DEFAULT NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用',
payment_voucher_file_ids json DEFAULT NULL COMMENT '直接已付凭证文件ID快照',
business_date date NOT NULL COMMENT '费用业务日期',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY',
@@ -1740,6 +1741,24 @@ CREATE TABLE ll_outbound_pda_task (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务';
CREATE TABLE ll_overseas_procurement_fee (
purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定',
confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作',
fee_version INT NULL COMMENT '同用途费用确认版本',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本',
determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用',
original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD',
original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额',
exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照',
exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份',
business_date DATE NULL COMMENT '实际费用业务日期',
wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录',
wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用',
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
id bigint NOT NULL COMMENT '主键ID',
order_id bigint NOT NULL COMMENT '采购订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
@@ -1748,8 +1767,8 @@ CREATE TABLE ll_overseas_procurement_fee (
fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称',
amount decimal(20,2) NOT NULL COMMENT 'CNY金额',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种',
voucher_file_id bigint NOT NULL COMMENT '凭证文件ID',
voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '凭证文件名',
voucher_file_id bigint NULL COMMENT '凭证文件ID',
voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NULL COMMENT '凭证文件名',
remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明',
wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态',
wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码',
@@ -1758,10 +1777,12 @@ CREATE TABLE ll_overseas_procurement_fee (
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL 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 '更新时间',
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount > 0)),
CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount >= 0)),
CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期';
@@ -1769,7 +1790,7 @@ CREATE TABLE ll_overseas_procurement_file (
id bigint NOT NULL COMMENT '主键ID',
order_id bigint NOT NULL COMMENT '采购订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
category varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证',
category varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证',
file_id bigint NOT NULL COMMENT 'File服务文件ID',
file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名快照',
content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '媒体类型',
@@ -1779,7 +1800,7 @@ CREATE TABLE ll_overseas_procurement_file (
PRIMARY KEY (id),
UNIQUE KEY uk_ll_procurement_file (order_id,file_id),
KEY idx_ll_procurement_file_order (customer_id,order_id,category,created_at),
CONSTRAINT chk_ll_procurement_file_category CHECK ((category in (_utf8mb4'RECEIPT',_utf8mb4'VOUCHER')))
CONSTRAINT chk_ll_procurement_file_category CHECK (category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证';
CREATE TABLE ll_overseas_procurement_order (
@@ -1799,12 +1820,7 @@ CREATE TABLE ll_overseas_procurement_order (
contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话',
purchase_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库ID',
purchase_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库名称',
outbound_details json DEFAULT NULL COMMENT '出库明细',
inbound_quantity bigint NOT NULL DEFAULT '0' COMMENT '采购单据累计入库数量',
domestic_carrier varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '境内承运商',
domestic_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '境内物流单号',
overseas_carrier varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外承运商',
overseas_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外物流单号',
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
@@ -3625,4 +3641,244 @@ CREATE TABLE ll_return_carton_count_session (
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 161 AS initialized_tables;
CREATE TABLE ll_procurement_wallet_attempt (
id BIGINT NOT NULL COMMENT '请求尝试ID',
order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID',
attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号',
operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款',
business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号',
amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空',
command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用',
status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败',
result_hash CHAR(64) NULL COMMENT '原业务回执摘要',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用',
error_code VARCHAR(64) NULL COMMENT '业务失败码',
error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间',
completed_at DATETIME(3) NULL COMMENT '最终结果时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id),
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
KEY idx_procurement_attempt_order(customer_id,order_id,status),
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。
CREATE TABLE ll_procurement_flow (
order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
purchased_at DATETIME(3) NULL COMMENT '真实购买时间',
purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品',
purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格',
purchased_quantity BIGINT NULL COMMENT '实际购买件数',
purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额',
purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD',
purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用',
purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明',
purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功',
actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID',
actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照',
actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照',
overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间',
packing_version INT NULL COMMENT '当前确认装箱清单版本',
overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间',
domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间',
charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间',
customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间',
domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人',
domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话',
domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址',
cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间',
closed_at DATETIME(3) NULL COMMENT '异常关闭时间',
control_reason VARCHAR(1000) NULL 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 '更新时间',
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
CREATE TABLE ll_procurement_command (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作',
idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据',
detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
CREATE TABLE ll_procurement_budget_authorization (
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种',
authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种',
voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据',
reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间',
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。
CREATE TABLE ll_procurement_receipt (
id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内',
source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓',
good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量',
received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实',
supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
CREATE TABLE ll_procurement_disposition (
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源',
receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货',
quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实',
voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
CREATE TABLE ll_procurement_manifest (
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代',
confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
CREATE TABLE ll_procurement_carton (
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米',
width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米',
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
CREATE TABLE ll_procurement_carton_source (
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
CREATE TABLE ll_procurement_shipment (
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户',
manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间',
address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
CREATE TABLE ll_procurement_shipment_item (
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
USE baiyue_lastleg;
-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。
CREATE TABLE ll_procurement_label_set (
id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本',
manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要',
sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照',
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
CREATE TABLE ll_procurement_label_item (
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始',
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
CREATE TABLE ll_procurement_estimate_review (
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
CREATE TABLE ll_procurement_repack (
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米',
height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克',
voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据',
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
USE baiyue_lastleg;
-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。
CREATE TABLE ll_procurement_fee_correction (
id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根',
idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要',
target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种',
exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份',
target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY',
difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录',
voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据',
created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
CREATE TABLE ll_procurement_fee_correction_item (
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
USE baiyue_lastleg;
-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。
CREATE TABLE ll_procurement_control_note (
id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型',
target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约',
voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明',
client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容',
original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
CREATE TABLE ll_procurement_closure (
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证',
financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实',
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
CREATE TABLE ll_procurement_closure_item (
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空',
quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置',
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 181 AS initialized_tables;
@@ -22,6 +22,10 @@ enum BusinessFileCategory {
INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_PROCUREMENT_LABEL(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_PROCUREMENT_PURCHASE(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_PROCUREMENT_BUDGET(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_PROCUREMENT_EXCEPTION(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -0,0 +1,36 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class ProcurementFilePolicyTest {
@TempDir Path root;
@Test void procurementLabelAndPrivateProofUsePdfImagesAndPositiveCustomerOwnership() {
var storage=new BusinessFileStorage();ReflectionTestUtils.setField(storage,"configuredRoot",root);storage.initializeRoot();
for(var category:List.of(BusinessFileCategory.OVERSEAS_PROCUREMENT_LABEL,BusinessFileCategory.OVERSEAS_PROCUREMENT_PURCHASE,
BusinessFileCategory.OVERSEAS_PROCUREMENT_BUDGET,BusinessFileCategory.OVERSEAS_PROCUREMENT_EXCEPTION)) {
category.validateOwner(9);
assertThatThrownBy(()->category.validateOwner(0)).isInstanceOf(BizException.class);
assertThatThrownBy(()->category.storageBusinessId("0")).isInstanceOf(BizException.class);
byte[] pdf="%PDF-1.4\nprocurement test".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var saved=storage.store(9,"11",new MockMultipartFile("file","label.pdf","application/pdf",pdf),category);
assertThat(storage.read(saved.objectKey())).isEqualTo(pdf);
assertThatThrownBy(()->storage.store(9,"11",new MockMultipartFile("file","fake.pdf","application/pdf","<html>fake</html>".getBytes()),category)).isInstanceOf(BizException.class);
assertThatThrownBy(()->storage.store(9,"11",new MockMultipartFile("file","notes.txt","text/plain","notes".getBytes()),category)).isInstanceOf(BizException.class);
}
}
@Test void labelOverTenMibIsRejectedBeforeReadingBody()throws Exception {
var storage=new BusinessFileStorage();ReflectionTestUtils.setField(storage,"configuredRoot",root);storage.initializeRoot();
MultipartFile file=mock(MultipartFile.class);when(file.isEmpty()).thenReturn(false);when(file.getSize()).thenReturn(10L*1024*1024+1);
assertThatThrownBy(()->storage.store(9,"11",file,BusinessFileCategory.OVERSEAS_PROCUREMENT_LABEL)).isInstanceOf(BizException.class);
verify(file,never()).getBytes();verify(file,never()).getInputStream();
}
}
@@ -130,6 +130,14 @@ public class BusinessFeeSourceEventService {
}
positive(value.sourceFeeId(), "sourceFeeId");
positive(value.customerId(), "customerId");
if (value.walletSettlement() != null) {
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
}
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType()))
throw error("FIN-EVT-012", "海外柜费来源不一致");
@@ -160,7 +168,7 @@ public class BusinessFeeSourceEventService {
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(),
value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null, value.walletSettlement());
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
@@ -189,7 +197,40 @@ public class BusinessFeeSourceEventService {
private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) {
if (!"RECEIVABLE".equals(source.direction())) return;
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND h.amount=f.amount",
if (source.walletSettlement() != null) {
var reference = source.walletSettlement();
long customerId = positive(source.customerId(), "customerId");
long holdId = positive(reference.holdId(), "holdId");
long ledgerId = positive(reference.ledgerId(), "ledgerId");
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT l.amount,c.result_json FROM fin_wallet_hold h JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id JOIN fin_wallet_command_inbox c ON c.event_id=? AND c.status='SUCCEEDED' WHERE h.id=? AND l.id=? AND h.customer_id=? AND h.business_type=? AND h.business_no=? AND h.status='SETTLED' AND l.change_type='CONSUMPTION' FOR UPDATE",
reference.commandEventId(), holdId, ledgerId, customerId, source.businessType(), source.businessNo());
if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount)
|| amount.negate().compareTo(source.totalAmount()) != 0) {
throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配");
}
com.fasterxml.jackson.databind.JsonNode result;
try { result = objectMapper.readTree(String.valueOf(rows.get(0).get("result_json"))); }
catch (JsonProcessingException exception) { throw error("FIN-EVT-017", "原资金结果无法核对"); }
if (!"SUCCEEDED".equals(result.path("result").asText())
|| !Set.of("SETTLE", "SETTLE_ACTUAL").contains(result.path("operation").asText())
|| !reference.commandEventId().equals(result.path("commandEventId").asText())
|| !reference.holdId().equals(result.path("walletHoldId").asText())
|| !reference.ledgerId().equals(result.path("walletLedgerId").asText())
|| result.path("customerId").asLong() != customerId
|| !source.businessType().equals(result.path("businessType").asText())
|| !source.businessNo().equals(result.path("businessNo").asText())
|| result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) {
throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配");
}
List<Long> used = jdbc.queryForList("SELECT id FROM fin_fee_source WHERE wallet_hold_id=? AND id<>? FOR UPDATE",
Long.class, holdId, feeSourceId);
if (!used.isEmpty()) throw error("FIN-EVT-017", "同一成功扣款已关联其他费用来源");
jdbc.update("UPDATE fin_fee_source SET wallet_hold_id=?,status='SETTLED' WHERE id=? AND customer_id=? AND status='AVAILABLE'",
holdId, feeSourceId, customerId);
return;
}
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id AND l.change_type='CONSUMPTION' SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND f.amount=-l.amount",
feeSourceId);
}
@@ -300,6 +300,113 @@ public class FinanceWalletService {
return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false);
}
/** Settles one estimate without releasing its protection before checking any shortfall. */
@Transactional
public WalletHold settleActual(long customerId, String rawBusinessType, String rawBusinessNo,
String idempotencyKey, BigDecimal actualAmount, String remark) {
requirePositive(customerId, "客户");
BigDecimal amount = money(actualAmount);
String businessType = businessType(rawBusinessType);
String businessNo = businessNo(rawBusinessNo);
String operation = "WALLET_HOLD_SETTLE_ACTUAL";
String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType,
"businessNo", businessNo, "amount", amount, "remark", String.valueOf(remark)));
Long replay = reserve(customerId, 0, operation, idempotencyKey, requestHash);
if (replay != null) return requiredHold(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
WalletHold hold = findHold(customerId, businessType, businessNo, true);
if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
WalletAccount after = settleActualAccount(before, hold, amount);
updateAccount(customerId, after, before.version(), 0);
WalletLedgerEntry ledger = insertLedger(customerId, "CONSUMPTION", amount.negate(), after,
businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", remark);
// The hold retains the original estimate; its action ledger records the actual charge.
int updated = jdbc.update("UPDATE fin_wallet_hold SET status='SETTLED',action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'",
ledger.ledgerId(), hold.holdId());
if (updated != 1) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
audit(customerId, "WALLET_HOLD", hold.holdId(), "SETTLE_ACTUAL", remark, before, after, 0, "业务系统");
complete(customerId, 0, operation, idempotencyKey, hold.holdId());
return requiredHold(hold.holdId(), customerId);
}
/** Refunds a successful consumption; recharge allocation rules apply without creating a recharge. */
@Transactional
public WalletLedgerEntry refundConsumption(long customerId, long originalLedgerId, String refundNo,
String idempotencyKey, BigDecimal refundAmount,
long operatorId, String operatorName, String remark) {
requirePositive(customerId, "客户");
requirePositive(originalLedgerId, "原扣款流水");
String reference = businessNo(refundNo);
BigDecimal amount = money(refundAmount);
if (!StringUtils.hasText(remark)) throw bad("退款原因不能为空");
String requestHash = hash(Map.of("customerId", customerId, "originalLedgerId", originalLedgerId,
"refundNo", reference, "amount", amount, "remark", remark));
Long replay = reserve(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, requestHash);
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
List<WalletLedgerEntry> originals = jdbc.query(
"SELECT * FROM fin_wallet_ledger WHERE id=? AND customer_id=? FOR UPDATE",
(rs, n) -> ledgerRow(rs), originalLedgerId, customerId);
if (originals.isEmpty()) throw error("FIN-WALLET-404", "原扣款流水不存在");
WalletLedgerEntry original = originals.get(0);
if (!"CONSUMPTION".equals(original.changeType()) || original.amount().signum() >= 0) {
throw bad("仅可对成功业务扣款发起关联退款");
}
List<WalletLedgerEntry> existing = jdbc.query(
"SELECT * FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND source_no=? FOR UPDATE",
(rs, n) -> ledgerRow(rs), customerId, reference);
if (!existing.isEmpty()) {
WalletLedgerEntry refunded = existing.get(0);
if (refunded.amount().compareTo(amount) != 0 || !original.businessNo().equals(refunded.referenceNo())) {
throw error("FIN-IDEMPOTENCY-CONFLICT", "同一退款单对应的原扣款或金额不一致");
}
complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, refunded.ledgerId());
return refunded;
}
// Locking reads see refunds committed while this transaction waited for the account lock,
// even under MySQL REPEATABLE READ after the idempotency lookup established a snapshot.
BigDecimal refunded = jdbc.queryForList(
"SELECT amount FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND reference_no=? FOR UPDATE",
BigDecimal.class, customerId, original.businessNo()).stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (refunded.add(amount).compareTo(original.amount().negate()) > 0) {
throw error("FIN-WALLET-REFUND-EXCEEDED", "退款金额超过原扣款剩余可退金额");
}
WalletAccount after = rechargeAccount(before, amount);
updateAccount(customerId, after, before.version(), operatorId);
WalletLedgerEntry result = insertLedger(customerId, "REFUND", amount, after,
"BUSINESS_REFUND", reference, LocalDate.now(), null, original.businessNo(),
operatorId, operatorName, remark);
audit(customerId, "WALLET_LEDGER", originalLedgerId, "REFUND", remark, before, after, operatorId, operatorName);
complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, result.ledgerId());
return result;
}
WalletAccount settleActualAccount(WalletAccount before, WalletHold hold, BigDecimal actualAmount) {
BigDecimal amount = money(actualAmount);
BigDecimal extra = amount.subtract(hold.amount()).max(BigDecimal.ZERO);
if (extra.signum() > 0 && before.availableBalance().compareTo(extra) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "实际费用补差所需的可用余额和授信额度不足");
}
BigDecimal actual = before.actualBalance();
BigDecimal creditUsed = before.creditUsed();
if (before.creditActive()) {
BigDecimal reservedCharge = amount.min(hold.amount());
BigDecimal cashCharge = reservedCharge.min(hold.actualFrozenAmount());
BigDecimal freeCash = actual.subtract(before.frozenBalance().subtract(before.creditFrozen()))
.max(BigDecimal.ZERO);
cashCharge = cashCharge.add(extra.min(freeCash));
actual = actual.subtract(cashCharge);
creditUsed = creditUsed.add(amount.subtract(cashCharge));
} else {
// Previously reserved credit still settles after expiry, without granting new credit.
actual = actual.subtract(amount);
}
return accountState(actual, before.frozenBalance().subtract(hold.amount()), before.creditLimit(),
creditUsed, before.creditFrozen().subtract(hold.creditFrozenAmount()),
before.creditExpiresOn(), before.version());
}
private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo,
String idempotencyKey, WalletHoldActionRequest request, boolean settle) {
requirePositive(customerId, "客户");
@@ -29,9 +29,10 @@ import java.util.Set;
@Service
public class WalletCommandEventService {
private static final Set<String> OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE");
private static final Set<String> OPERATIONS = Set.of("FREEZE", "SETTLE", "SETTLE_ACTUAL", "RELEASE", "REFUND");
private static final Set<String> FINAL_REJECTIONS = Set.of(
"FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400");
"FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400",
"FIN-WALLET-REFUND-EXCEEDED", "FIN-WALLET-404");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceWalletService walletService;
@@ -56,10 +57,9 @@ public class WalletCommandEventService {
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
// Wallet methods join this transaction: money, the Inbox outcome and
// the result Outbox commit together, or all roll back.
WalletHold hold = execute(command);
if (hold == null) throw new IllegalStateException("资金操作未返回处理结果");
ExecutionResult execution = execute(command);
finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED",
hold.status(), hold.amount(), null, null));
execution.status(), execution.amount(), null, null, execution.holdId(), execution.ledgerId()));
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
});
} catch (FinanceEventException exception) {
@@ -70,7 +70,7 @@ public class WalletCommandEventService {
requireLockedIdentity(envelope);
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED",
null, command.amount(), exception.code(), exception.getMessage()));
null, command.amount(), exception.code(), exception.getMessage(), null, null));
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
});
}
@@ -86,21 +86,33 @@ public class WalletCommandEventService {
}
}
private WalletHold execute(WalletCommandRequestedV1 command) {
private record ExecutionResult(String status, BigDecimal amount, String holdId, String ledgerId) { }
private ExecutionResult execute(WalletCommandRequestedV1 command) {
if ("REFUND".equals(command.operation())) {
var refund = walletService.refundConsumption(command.customerId(), Long.parseLong(command.originalLedgerId()),
command.businessNo(), command.idempotencyKey(), command.amount(), 0, "业务系统", command.remark());
return new ExecutionResult("REFUNDED", refund.amount(), null, String.valueOf(refund.ledgerId()));
}
WalletHold hold = switch (command.operation()) {
case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(),
new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark()));
case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(),
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
case "SETTLE_ACTUAL" -> walletService.settleActual(command.customerId(), command.businessType(), command.businessNo(),
command.idempotencyKey(), command.amount(), command.remark());
case "RELEASE" -> walletService.release(command.customerId(), command.businessType(), command.businessNo(),
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效");
};
if ("SETTLE".equals(command.operation())) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=h.amount",
if (hold == null) throw new IllegalStateException("资金操作未返回处理结果");
if (Set.of("SETTLE", "SETTLE_ACTUAL").contains(command.operation())) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id AND l.change_type='CONSUMPTION' SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=-l.amount",
hold.holdId());
}
return hold;
Long ledgerId = "FREEZE".equals(command.operation()) ? hold.freezeLedgerId() : hold.actionLedgerId();
return new ExecutionResult(hold.status(), "SETTLE_ACTUAL".equals(command.operation()) ? command.amount() : hold.amount(),
String.valueOf(hold.holdId()), ledgerId == null ? null : String.valueOf(ledgerId));
}
private WalletCommandRequestedV1 validate(EventEnvelope envelope) {
@@ -118,14 +130,22 @@ public class WalletCommandEventService {
String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT);
if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType())
|| blank(value.businessNo()) || blank(value.idempotencyKey())
|| ("FREEZE".equals(operation) && (value.amount() == null || value.amount().signum() <= 0))) {
|| (Set.of("FREEZE", "SETTLE_ACTUAL", "REFUND").contains(operation)
&& (value.amount() == null || value.amount().signum() <= 0 || value.amount().scale() > 2))) {
throw new FinanceEventException("FIN-EVT-001", "资金指令字段无效");
}
if ("REFUND".equals(operation)) {
if (blank(value.originalLedgerId()) || Long.parseLong(value.originalLedgerId()) <= 0 || blank(value.remark())) {
throw new FinanceEventException("FIN-EVT-001", "退款必须关联原扣款并填写原因");
}
} else if (value.originalLedgerId() != null) {
throw new FinanceEventException("FIN-EVT-001", "非退款指令不能指定原扣款");
}
if (!String.valueOf(value.customerId()).equals(envelope.tenantId())) {
throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致");
}
return new WalletCommandRequestedV1(operation, value.customerId(), value.businessType(), value.businessNo(),
value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt());
value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt(), value.originalLedgerId());
} catch (FinanceEventException exception) { throw exception; }
catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); }
}
@@ -159,9 +179,10 @@ public class WalletCommandEventService {
return count != null && count > 0;
}
private WalletCommandCompletedV1 completed(String eventId, WalletCommandRequestedV1 command, String result,
String holdStatus, BigDecimal amount, String errorCode, String errorMessage) {
String holdStatus, BigDecimal amount, String errorCode, String errorMessage,
String holdId, String ledgerId) {
return new WalletCommandCompletedV1(eventId, command.operation(), command.customerId(), command.businessType(),
command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now());
command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now(), holdId, ledgerId);
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
@@ -45,11 +45,11 @@ class BusinessFeeSourceEventServiceTest {
when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"),
eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of());
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"),
eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0);
eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L))).thenReturn(0);
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
@@ -65,21 +65,21 @@ class BusinessFeeSourceEventServiceTest {
private EventEnvelope eventWithoutInbound() {
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG",
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
"OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001",
"WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null);
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null, null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
"OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001",
"WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash, null);
JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
@@ -237,4 +237,53 @@ class BusinessFeeSourceEventServiceTest {
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
}
@Test
void procurementWithoutConsumptionReferenceNeverEntersReceivablePool() {
EventEnvelope event = procurementEvent(false);
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
@Test
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
EventEnvelope event = procurementEvent(true);
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
eq(10001L), eq("PROCUREMENT_FEE"), eq("PO-US-001"))).thenReturn(List.of(Map.of(
"amount", new BigDecimal("-120.00"), "result_json", """
{"result":"SUCCEEDED","operation":"SETTLE_ACTUAL","commandEventId":"command-1",
"walletHoldId":"9","walletLedgerId":"72","customerId":10001,
"businessType":"PROCUREMENT_FEE","businessNo":"PO-US-001","amount":120.00}
""")));
service.consume(event);
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L));
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), any(Object[].class));
}
@Test
void incorrectOrCrossCustomerConsumptionIsRejectedInsteadOfBecomingAvailable() {
EventEnvelope event = procurementEvent(true);
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("不匹配");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
}
private EventEnvelope procurementEvent(boolean reference) {
EventEnvelope base = eventWithoutInbound();
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("sourceType", "OVERSEAS_PROCUREMENT_FEE");
payload.put("businessType", "PROCUREMENT_FEE");
if (reference) {
payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9").put("ledgerId", "72");
}
payload.putNull("action"); payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", "PUBLISHED");
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
}
@@ -101,6 +101,38 @@ class WalletCommandEventServiceTest {
verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class));
}
@Test
void actualSettlementReportsActualChargeInsteadOfOriginalEstimate() throws Exception {
EventEnvelope event = envelope("SETTLE_ACTUAL", 31L, new BigDecimal("60.00"));
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId())))
.thenReturn(List.of(event.payloadHash()));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
when(wallet.settleActual(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"),
eq("freeze-procurement-1"), argThat((BigDecimal amount) -> amount.compareTo(new BigDecimal("60.00")) == 0), any())).thenReturn(
new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("100.00"),
new BigDecimal("100.00"), BigDecimal.ZERO, "SETTLED", 71L, 72L, null, null, null));
service.consume(event);
var resultJson = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"),
resultJson.capture(), eq(event.eventId()));
JsonNode result = mapper.readTree(resultJson.getValue());
assertThat(result.path("amount").decimalValue()).isEqualByComparingTo("60.00");
assertThat(result.path("holdStatus").asText()).isEqualTo("SETTLED");
assertThat(result.path("walletHoldId").asText()).isEqualTo("9");
assertThat(result.path("walletLedgerId").asText()).isEqualTo("72");
verify(jdbc).update(contains("f.amount=-l.amount"), eq(9L));
verify(wallet, never()).release(anyLong(), anyString(), anyString(), anyString(), any());
}
@Test
void actualSettlementRejectsZeroAndMissingAmountsBeforeOpeningTransaction() {
for (BigDecimal amount : new BigDecimal[]{null, BigDecimal.ZERO, new BigDecimal("0.001")}) {
assertThatThrownBy(() -> service.consume(envelope("SETTLE_ACTUAL", 31L, amount)))
.isInstanceOf(FinanceEventException.class);
}
verifyNoInteractions(jdbc, wallet, transactions);
}
@Test
void losingInboxInsertMustRecheckThePersistedHash() {
EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00"));
@@ -133,9 +165,44 @@ class WalletCommandEventServiceTest {
}
private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) {
return envelope(operation, customerId, amount, null);
}
@Test
void refundRequiresOriginalConsumptionBeforeAnyWalletCall() {
assertThatThrownBy(() -> service.consume(envelope("REFUND", 31L, BigDecimal.TEN)))
.isInstanceOf(FinanceEventException.class);
verifyNoInteractions(jdbc, wallet);
}
@Test
void refundHasItsOwnLedgerReferenceAndNeverPretendsToSettleAHold() throws Exception {
EventEnvelope event = envelope("REFUND", 31L, BigDecimal.TEN, "72");
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId())))
.thenReturn(List.of(event.payloadHash()));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
var refunded = new com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry(73L, "AC-TEST", "REFUND",
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, null, "BUSINESS_REFUND", "PO-1", java.time.LocalDate.now(), null,
"AC-ORIGINAL", "业务系统", "采购快递费", null);
when(wallet.refundConsumption(eq(31L), eq(72L), eq("PO-1"), eq("freeze-procurement-1"),
any(), eq(0L), anyString(), anyString())).thenReturn(refunded);
service.consume(event);
var capture = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"),
capture.capture(), eq(event.eventId()));
JsonNode result = mapper.readTree(capture.getValue());
assertThat(result.path("walletLedgerId").asText()).isEqualTo("73");
assertThat(result.path("walletHoldId").isNull()).isTrue();
assertThat(result.path("holdStatus").asText()).isEqualTo("REFUNDED");
verify(wallet, never()).settle(anyLong(), anyString(), anyString(), anyString(), any());
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
}
private EventEnvelope envelope(String operation, long customerId, BigDecimal amount, String originalLedgerId) {
WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId,
"OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费",
"freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"));
"freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"), originalLedgerId);
JsonNode payload = mapper.valueToTree(command);
return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED,
WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1,
@@ -190,7 +190,7 @@ class WalletCommandTransactionTest {
private EventEnvelope event(String id, String amount) {
var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1",
new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z"));
new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z"), null);
var payload = mapper.valueToTree(command);
return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION,
Instant.now(), "31", "WALLET_HOLD", "PO-1", 1,
@@ -0,0 +1,91 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.mock;
class ProcurementActualSettlementTest {
private final FinanceWalletService service = new FinanceWalletService(mock(JdbcTemplate.class),
new ObjectMapper().findAndRegisterModules(), mock(FinanceFileClient.class),
mock(FinanceDocumentNumberGenerator.class), "token", "", "test", "test");
@Test
void smallerActualChargeReleasesRemainderAndKeepsOtherReservations() {
WalletAccount before = account("200", "150", "0", "0", "0", null);
WalletAccount after = service.settleActualAccount(before, hold("100", "100", "0"), n("60"));
assertThat(after.actualBalance()).isEqualByComparingTo("140");
assertThat(after.frozenBalance()).isEqualByComparingTo("50");
assertThat(after.availableBalance()).isEqualByComparingTo("90");
}
@Test
void excessUsesAvailableCashThenActiveCreditWithoutTouchingOtherHolds() {
WalletAccount before = account("120", "130", "100", "0", "30", LocalDate.now().plusDays(1));
WalletAccount after = service.settleActualAccount(before, hold("100", "70", "30"), n("150"));
assertThat(after.actualBalance()).isEqualByComparingTo("30");
assertThat(after.creditUsed()).isEqualByComparingTo("60");
assertThat(after.frozenBalance()).isEqualByComparingTo("30");
assertThat(after.creditFrozen()).isZero();
assertThat(after.availableBalance()).isEqualByComparingTo("40");
}
@Test
void insufficientExcessRejectsWithoutChangingOriginalAccount() {
WalletAccount before = account("120", "100", "0", "0", "0", null);
assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "100", "0"), n("120.01")))
.isInstanceOf(FinanceEventException.class)
.extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-WALLET-INSUFFICIENT");
assertThat(before.actualBalance()).isEqualByComparingTo("120");
assertThat(before.frozenBalance()).isEqualByComparingTo("100");
}
@Test
void partialChargeUsesReservedCashBeforeCredit() {
WalletAccount before = account("70", "100", "100", "0", "30", LocalDate.now().plusDays(1));
WalletAccount after = service.settleActualAccount(before, hold("100", "70", "30"), n("60"));
assertThat(after.actualBalance()).isEqualByComparingTo("10");
assertThat(after.creditUsed()).isZero();
assertThat(after.creditFrozen()).isZero();
assertThat(after.frozenBalance()).isZero();
}
@Test
void expiredCreditAllowsOriginalReservationButNeverNewCredit() {
WalletAccount before = account("20", "100", "100", "0", "80", LocalDate.now().minusDays(1));
WalletAccount after = service.settleActualAccount(before, hold("100", "20", "80"), n("100"));
assertThat(after.actualBalance()).isEqualByComparingTo("-80");
assertThat(after.creditUsed()).isZero();
assertThat(after.frozenBalance()).isZero();
assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "20", "80"), n("100.01")))
.isInstanceOf(FinanceEventException.class);
}
@Test
void invalidActualAmountMustUseReleaseOrCorrectPrecision() {
WalletAccount before = account("200", "100", "0", "0", "0", null);
for (String value : new String[]{"0", "-1", "0.001"}) {
assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "100", "0"), n(value)))
.isInstanceOf(FinanceEventException.class);
}
}
private WalletAccount account(String actual, String frozen, String limit, String used, String creditFrozen, LocalDate expiry) {
return service.accountState(n(actual), n(frozen), n(limit), n(used), n(creditFrozen), expiry, 1);
}
private WalletHold hold(String amount, String cash, String credit) {
return new WalletHold(1L, 9L, "PROCUREMENT_FEE", "PF-TEST", n(amount), n(cash), n(credit),
"FROZEN", 1L, null, null, null, null);
}
private static BigDecimal n(String value) { return new BigDecimal(value); }
}
@@ -0,0 +1,200 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.TransientDataAccessResourceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Executes the real wallet service against transactional JDBC; no mocked money mutations. */
class ProcurementSettlementTransactionTest {
private JdbcTemplate jdbc;
private TransactionTemplate tx;
private FinanceWalletService wallet;
@BeforeEach
void setup() {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
jdbc = spy(new JdbcTemplate(source));
tx = new TransactionTemplate(new DataSourceTransactionManager(source));
jdbc.execute("CREATE TABLE fin_wallet_account(customer_id BIGINT PRIMARY KEY,currency_code VARCHAR(3),actual_balance DECIMAL(18,2),frozen_balance DECIMAL(18,2),available_balance DECIMAL(18,2),credit_limit DECIMAL(18,2),credit_used DECIMAL(18,2),credit_frozen DECIMAL(18,2),credit_expires_on DATE,version INT,created_by BIGINT,updated_by BIGINT,updated_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fin_wallet_hold(id BIGINT AUTO_INCREMENT PRIMARY KEY,customer_id BIGINT,business_type VARCHAR(32),business_no VARCHAR(128),amount DECIMAL(18,2),actual_frozen_amount DECIMAL(18,2),credit_frozen_amount DECIMAL(18,2),status VARCHAR(32),freeze_ledger_id BIGINT,action_ledger_id BIGINT,created_at TIMESTAMP,completed_at TIMESTAMP,remark VARCHAR(500),UNIQUE(customer_id,business_type,business_no))");
jdbc.execute("CREATE TABLE fin_idempotency_record(tenant_id BIGINT,operator_id BIGINT,operation VARCHAR(64),idempotency_key VARCHAR(128),request_hash VARCHAR(128),status VARCHAR(32),result_resource_id BIGINT,error_code VARCHAR(128),UNIQUE(tenant_id,operator_id,operation,idempotency_key))");
jdbc.execute("CREATE TABLE fin_wallet_ledger(id BIGINT AUTO_INCREMENT PRIMARY KEY,customer_id BIGINT,business_no VARCHAR(128),change_type VARCHAR(32),amount DECIMAL(18,2),actual_balance_after DECIMAL(18,2),frozen_balance_after DECIMAL(18,2),available_balance_after DECIMAL(18,2),credit_limit_after DECIMAL(18,2),credit_used_after DECIMAL(18,2),available_credit_after DECIMAL(18,2),credit_expires_on_after DATE,source_type VARCHAR(32),source_no VARCHAR(128),occurred_date DATE,payment_method VARCHAR(32),reference_no VARCHAR(128),operator_id BIGINT,operator_name VARCHAR(128),remark VARCHAR(500),created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fin_audit_log(tenant_id BIGINT,resource_type VARCHAR(64),resource_id BIGINT,action VARCHAR(64),reason VARCHAR(500),before_data TEXT,after_data TEXT,operator_id BIGINT,operator_name_snapshot VARCHAR(128),request_id VARCHAR(128),trace_id VARCHAR(128))");
jdbc.update("INSERT INTO fin_wallet_account VALUES(9,'CNY',200,100,100,0,0,0,NULL,0,0,0,CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO fin_wallet_hold(id,customer_id,business_type,business_no,amount,actual_frozen_amount,credit_frozen_amount,status) VALUES(1,9,'PROCUREMENT_FEE','PF-1',100,100,0,'FROZEN')");
FinanceDocumentNumberGenerator numbers = mock(FinanceDocumentNumberGenerator.class);
AtomicInteger sequence = new AtomicInteger();
when(numbers.next("AC")).thenAnswer(i -> "AC-TEST-" + sequence.incrementAndGet());
wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), numbers, "token", "", "test", "test");
}
@Test
void partialSettlementKeepsEstimateAndRecordsActualExactlyOnce() {
WalletHold first = settle("request-1", "60");
WalletHold replay = settle("request-1", "60");
assertThat(replay.actionLedgerId()).isEqualTo(first.actionLedgerId());
assertThat(first.amount()).isEqualByComparingTo("100");
assertThat(first.account().actualBalance()).isEqualByComparingTo("140");
assertThat(first.account().frozenBalance()).isZero();
assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger", BigDecimal.class)).isEqualByComparingTo("-60");
assertThatThrownBy(() -> settle("different-request", "60")).isInstanceOf(FinanceEventException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isEqualTo(1);
}
@Test
void sameKeyWithDifferentAmountCannotChangeCharge() {
settle("request-1", "60");
assertThatThrownBy(() -> settle("request-1", "70")).isInstanceOf(FinanceEventException.class)
.extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-IDEMPOTENCY-CONFLICT");
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140");
}
@Test
void insufficientDifferenceLeavesOriginalHoldAndAccountUntouched() {
assertThatThrownBy(() -> settle("request-1", "201")).isInstanceOf(FinanceEventException.class);
assertUntouched();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record", Integer.class)).isZero();
}
@Test
void auditFailureRollsBackDebitLedgerHoldAndIdempotencyReservation() {
doThrow(new TransientDataAccessResourceException("audit storage unavailable"))
.when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class));
assertThatThrownBy(() -> settle("request-1", "150"))
.isInstanceOf(TransientDataAccessResourceException.class);
assertUntouched();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record", Integer.class)).isZero();
}
@Test
void concurrentDifferentRequestsCannotSettleTheSameGoodsTwice() throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
try {
Future<Boolean> one = executor.submit(() -> attempt(start, "one"));
Future<Boolean> two = executor.submit(() -> attempt(start, "two"));
start.countDown();
assertThat((one.get(10, TimeUnit.SECONDS) ? 1 : 0) + (two.get(10, TimeUnit.SECONDS) ? 1 : 0)).isEqualTo(1);
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("50");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isEqualTo(1);
} finally {
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
private boolean attempt(CountDownLatch start, String key) throws InterruptedException {
if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("start timeout");
try { settle(key, "150"); return true; }
catch (FinanceEventException expected) {
assertThat(expected.code()).isEqualTo("FIN-WALLET-HOLD-409");
return false;
}
}
@Test
void refundsAreBoundedByOriginalConsumptionAndCannotReplayWithNewKey() {
WalletHold settled = settle("settle", "60");
var refund = tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1",
new BigDecimal("40"), 7, "finance", "confirmed correction"));
var replay = tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "new-key",
new BigDecimal("40"), 7, "finance", "confirmed correction"));
assertThat(replay.ledgerId()).isEqualTo(refund.ledgerId());
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("180");
assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-2", "refund-2",
new BigDecimal("20.01"), 7, "finance", "too much")))
.isInstanceOf(FinanceEventException.class)
.extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-2", "refund-2",
new BigDecimal("20"), 7, "finance", "remaining refund"));
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("200");
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_hold", String.class)).isEqualTo("SETTLED");
assertThat(jdbc.queryForObject("SELECT SUM(amount) FROM fin_wallet_ledger WHERE source_type='BUSINESS_REFUND'", BigDecimal.class))
.isEqualByComparingTo("60");
}
@Test
void refundCannotReferenceAnotherCustomerOrAnUnsettledReservation() {
WalletHold settled = settle("settle", "60");
assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(10, settled.actionLedgerId(), "RF-1", "refund-1",
BigDecimal.ONE, 7, "finance", "wrong customer"))).isInstanceOf(FinanceEventException.class);
assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1",
BigDecimal.ZERO, 7, "finance", "zero refund"))).isInstanceOf(FinanceEventException.class);
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140");
}
@Test
void refundFailureRollsBackBalanceAndCanRetryOriginalRequest() {
WalletHold settled = settle("settle", "60");
doThrow(new TransientDataAccessResourceException("audit unavailable"))
.when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class));
assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1",
new BigDecimal("20"), 7, "finance", "correction"))).isInstanceOf(TransientDataAccessResourceException.class);
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE change_type='REFUND'", Integer.class)).isZero();
doCallRealMethod().when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class));
tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1",
new BigDecimal("20"), 7, "finance", "correction"));
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("160");
}
private WalletHold settle(String key, String amount) {
return tx.execute(s -> wallet.settleActual(9, "PROCUREMENT_FEE", "PF-1", key, new BigDecimal(amount), "actual freight"));
}
@Test
void concurrentRefundsCannotExceedTheOriginalConsumption() throws Exception {
WalletHold original = settle("settle", "60");
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
try {
Future<Boolean> one = executor.submit(() -> refundAttempt(start, original.actionLedgerId(), "RF-A"));
Future<Boolean> two = executor.submit(() -> refundAttempt(start, original.actionLedgerId(), "RF-B"));
start.countDown();
assertThat((one.get(10, TimeUnit.SECONDS) ? 1 : 0) + (two.get(10, TimeUnit.SECONDS) ? 1 : 0)).isEqualTo(1);
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("180");
assertThat(jdbc.queryForObject("SELECT SUM(amount) FROM fin_wallet_ledger WHERE change_type='REFUND'", BigDecimal.class))
.isEqualByComparingTo("40");
} finally {
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
private boolean refundAttempt(CountDownLatch start, long originalId, String number) throws InterruptedException {
if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("start timeout");
try {
tx.execute(s -> wallet.refundConsumption(9, originalId, number, number, new BigDecimal("40"),
7, "finance", "correction"));
return true;
} catch (FinanceEventException expected) {
assertThat(expected.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
return false;
}
}
private void assertUntouched() {
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("200");
assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("100");
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_hold", String.class)).isEqualTo("FROZEN");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isZero();
}
}
@@ -0,0 +1,28 @@
-- 采购节点动作独立授权;不新建岗位,不以通用编辑权限代替购买与取消。
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5117103,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:buy','采购购买及货款核定',NULL,'POST',803,'ACTIVE',1,0),
(5117104,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:cancel','购买前取消采购订单',NULL,'POST',804,'ACTIVE',1,0),
(5117105,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-overseas','采购海外收货',NULL,'POST',805,'ACTIVE',1,0),
(5117106,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-domestic','采购国内收货',NULL,'POST',806,'ACTIVE',1,0),
(5117107,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:pack','采购装箱与确认',NULL,'POST',807,'ACTIVE',1,0),
(5117108,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:manage','采购差异处置与事实更正',NULL,'POST',808,'ACTIVE',1,0),
(5117109,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:label','采购面单维护',NULL,'POST',809,'ACTIVE',1,0),
(5117110,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:charge','采购国内核费',NULL,'POST',810,'ACTIVE',1,0),
(5117111,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:finance-adjust','采购财务调整',NULL,'POST',811,'ACTIVE',1,0),
(5117112,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:estimate','采购预估运费核对',NULL,'POST',812,'ACTIVE',1,0),
(5117113,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:apply-label','采购逐箱贴单',NULL,'POST',813,'ACTIVE',1,0),
(5117114,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-overseas','采购海外整批交寄',NULL,'POST',814,'ACTIVE',1,0),
(5117115,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-customer','采购国内寄客户',NULL,'POST',815,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN
('platform:overseas-business:purchase:buy','platform:overseas-business:purchase:cancel',
'platform:overseas-business:purchase:receive-overseas','platform:overseas-business:purchase:receive-domestic',
'platform:overseas-business:purchase:pack','platform:overseas-business:purchase:manage',
'platform:overseas-business:purchase:label','platform:overseas-business:purchase:charge','platform:overseas-business:purchase:finance-adjust',
'platform:overseas-business:purchase:estimate','platform:overseas-business:purchase:apply-label',
'platform:overseas-business:purchase:ship-overseas','platform:overseas-business:purchase:ship-customer')
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND r.status='ACTIVE' AND r.deleted=0;
+1 -1
View File
@@ -146,7 +146,7 @@
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购编辑可维护入库数量和采购出库明细,出库量为明细数量汇总,审核通过且有入库量后按 `PENDING_OUTBOUND`、`PARTIALLY_OUTBOUND`、`OUTBOUNDED` 派生展示状态。采购出库只记录采购单据流转,不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
@@ -12,6 +12,15 @@ import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient",
url = "${baiyue.clients.domestic-warehouse.url:}")
public interface DomesticWarehouseDirectoryClient {
@GetMapping("/internal/warehouses/shared-overseas")
ApiResponse<List<ReceivingWarehouse>> receivingWarehouses(@RequestHeader("X-Internal-Service-Token") String internalToken);
@GetMapping("/internal/warehouses/shared-overseas/{warehouseId}")
ApiResponse<ReceivingWarehouse> receivingWarehouse(@PathVariable("warehouseId") long warehouseId,
@RequestHeader("X-Internal-Service-Token") String internalToken);
record ReceivingWarehouse(Long warehouseId, String warehouseCode, String warehouseName, String countryCode, String status) { }
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
ApiResponse<Detail> activeWarehouse(
@PathVariable("warehouseId") long warehouseId,
@@ -20,7 +29,7 @@ public interface DomesticWarehouseDirectoryClient {
record Detail(Warehouse warehouse, List<Address> addresses) { }
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String countryCode, String status) { }
String warehouseType, String countryCode, String status, String contactName, String contactPhone) { }
record Address(String addressType, String countryCode, String stateProvince, String city,
String district, String postalCode, String addressLine1, String addressLine2) { }
@@ -43,6 +43,8 @@ public class OverseasFeeSource {
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class) private List<String> paymentVoucherFileIds;
@TableField(typeHandler = JacksonTypeHandler.class)
private com.baiyue.common.event.WalletSettlementReference walletSettlement;
private LocalDate businessDate;
private String currency;
private BigDecimal amount;
@@ -9,5 +9,6 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface OverseasFeeSourceMapper extends BaseMapper<OverseasFeeSource> {
@Select("SELECT * FROM ll_finance_fee_source WHERE source_fee_id=#{sourceFeeId} ORDER BY source_version DESC LIMIT 1 FOR UPDATE")
@org.apache.ibatis.annotations.ResultMap("mybatis-plus_OverseasFeeSource")
OverseasFeeSource lockCurrent(@Param("sourceFeeId") long sourceFeeId);
}
@@ -69,7 +69,7 @@ public class OverseasFeeOutboxService {
value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
value.getReviewStatus(),
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash, value.getWalletSettlement());
}
private static String text(Long value) { return value == null ? null : String.valueOf(value); }
@@ -79,14 +79,20 @@ public class OverseasFeeSourceService {
if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送");
}
private static void requireNonProcurement(String sourceType,String businessType) {
if("OVERSEAS_PROCUREMENT_FEE".equals(sourceType) || "OVERSEAS_PROCUREMENT".equals(businessType) || "PROCUREMENT_FEE".equals(businessType))
throw conflict("采购费用必须通过采购履约或财务节点确认,实扣成功后自动关联来源");
}
@Transactional
public OverseasFeeSource create(String operatorId, String key, SaveRequest request) {
requireNonProcurement(request.sourceType(),request.businessType());
OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request);
if (replay != null) return current(replay.getSourceFeeId());
validate(request);
businessGuard.requireOwned(request);
long sourceFeeId = IdWorker.getId();
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request);
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, null);
mapper.insert(value);
command(operatorId, "CREATE", key, request, sourceFeeId, 1);
return value;
@@ -108,11 +114,45 @@ public class OverseasFeeSourceService {
return value;
}
private record SettledRequest(SaveRequest fee, OverseasFeeCurrencyConverter.Conversion conversion,
com.baiyue.common.event.WalletSettlementReference settlement) { }
/** Internal business entry: money has already succeeded and its confirmed FX must not be recalculated. */
@Transactional
public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion conversion,
com.baiyue.common.event.WalletSettlementReference settlement) {
if (!"RECEIVABLE".equals(request.direction()) || conversion == null || settlement == null
|| text(settlement.commandEventId()) == null || text(settlement.holdId()) == null || text(settlement.ledgerId()) == null
|| !request.currency().equals(conversion.originalCurrency())
|| request.amount().compareTo(conversion.originalAmount()) != 0
|| conversion.settlementAmount().signum() <= 0 || conversion.exchangeRate().signum() <= 0
|| !request.businessDate().toString().substring(0, 7).equals(conversion.exchangeRateMonth())) {
throw bad("已扣费用的资金引用或确认汇率快照不完整");
}
var confirmed = new SettledRequest(request, conversion, settlement);
OverseasFeeCommand replay = replay(operatorId, "PUBLISH_SETTLED", key, confirmed);
if (replay != null) return current(replay.getSourceFeeId());
validate(request);
businessGuard.requireOwned(request);
long sourceFeeId = IdWorker.getId();
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion);
value.setWalletSettlement(settlement);
value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED");
value.setReviewerId(operatorId); value.setReviewedAt(now());
value.setReviewReason("已核定业务费用扣款成功后发布");
mapper.insert(value);
outbox.publish(value, "PUBLISHED");
command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1);
return value;
}
@Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus()))
throw conflict("只有草稿或已驳回费用可以提交");
value.setReviewStatus("PENDING"); value.setReviewReason(null);
@@ -126,6 +166,7 @@ public class OverseasFeeSourceService {
OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
throw conflict("只有待审核的有效费用可以审核");
boolean approved = "APPROVE".equals(request.decision());
@@ -143,6 +184,8 @@ public class OverseasFeeSourceService {
OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource old = lock(sourceFeeId);
if (old.getWalletSettlement() != null) throw conflict("已扣费用只能通过关联的财务差额或退款调整");
requireNonProcurement(old.getSourceType(),old.getBusinessType());
if (cancellation.requested(old)) throw conflict("该费用已申请作废,请先处理财务结果");
if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus())
|| old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整");
@@ -157,7 +200,7 @@ public class OverseasFeeSourceService {
|| !Objects.equals(old.getContainerNo(), text(request.fee().containerNo())))
throw conflict("调整不得改变费用来源或业务归属");
old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old);
OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee());
OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee(), null);
next.setReviewStatus("PENDING");
mapper.insert(next);
command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion());
@@ -169,6 +212,8 @@ public class OverseasFeeSourceService {
OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (value.getWalletSettlement() != null) throw conflict("已扣费用不能作废,请使用关联退款");
if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())
|| value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废");
if (cancellation.requested(value)) return cancellation.describe(List.of(value)).get(0);
@@ -183,7 +228,8 @@ public class OverseasFeeSourceService {
return value;
}
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request) {
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion confirmedConversion) {
OverseasFeeSource value = new OverseasFeeSource();
value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version);
value.setCustomerId(request.customerId().trim()); value.setCustomerName(text(request.customerName()));
@@ -196,7 +242,8 @@ public class OverseasFeeSourceService {
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
OverseasFeeCurrencyConverter.Conversion conversion = confirmedConversion != null ? confirmedConversion
: currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount());
value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());
@@ -24,13 +24,15 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service;
@Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')")
public ApiResponse<OverseasProcurementResponses.Detail> create(
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> create(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
var created=service.create(customer(user),operator(user),request);
return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo()));
}
@GetMapping
@@ -42,9 +44,9 @@ public class MerchantOverseasProcurementController {
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')")
public ApiResponse<OverseasProcurementResponses.Detail> detail(
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> detail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
return ApiResponse.success(queryService.customer(customer(user),orderNo));
}
private String customer(AuthenticatedUser user) {
@@ -32,6 +32,7 @@ import java.util.List;
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class PlatformOverseasProcurementController {
@Resource private OverseasProcurementService service;
@Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
@@ -67,11 +68,11 @@ public class PlatformOverseasProcurementController {
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
@Operation(summary = "Get an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
public ApiResponse<OverseasProcurementResponses.Detail> detail(
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.PlatformView> detail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
return ApiResponse.success(queryService.platform(requiredCustomer(customerId), orderNo,user.permissions()));
}
@PostMapping("/{orderNo}/review")
@@ -94,25 +95,6 @@ public class PlatformOverseasProcurementController {
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping(value = "/{orderNo}/files/{category}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')")
public ApiResponse<OverseasProcurementResponses.FileInfo> uploadFile(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @PathVariable String category, @RequestPart("file") MultipartFile file) {
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
return ApiResponse.success(service.uploadFile(requiredCustomer(customerId), orderNo,
String.valueOf(user.userId()), category.trim().toUpperCase(), file));
}
@PostMapping("/{orderNo}/fees")
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')")
public ApiResponse<OverseasProcurementResponses.Fee> createFee(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.CreateFee request) {
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
return ApiResponse.success(service.createFee(requiredCustomer(customerId), orderNo, operator(user), request));
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
@@ -0,0 +1,77 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import com.baiyue.lastleg.procurement.service.ProcurementChargeService;
import com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementChargeController {
private final ProcurementChargeService service;
private final ProcurementAdjustmentService adjustments;
@GetMapping("/{orderNo}/charges")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<ProcurementChargeService.Snapshot> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
@GetMapping("/{orderNo}/fee-corrections")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<java.util.List<ProcurementAdjustmentService.Result>> corrections(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(adjustments.history(customerId,orderNo));
}
@PostMapping("/{orderNo}/charges/confirm")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_CHARGE+"')")
public ApiResponse<ProcurementChargeService.Snapshot> confirm(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Confirm request) {
return ApiResponse.success(service.confirm(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/fees/additional")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<OverseasProcurementFee> additional(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Additional request) {
return ApiResponse.success(adjustments.additional(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/fees/{feeId}/retry")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ProcurementChargeService.Snapshot> retry(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) {
String operator=platform(user,customerId);String permission=service.retryPermission(customerId,orderNo,feeId);
if(user.permissions()==null || !user.permissions().contains(permission))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该费用用途的重试权限");
return ApiResponse.success(service.retry(customerId,orderNo,operator,feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/terminate")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<ProcurementChargeService.Snapshot> terminate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.terminate(customerId,orderNo,platform(user,customerId),feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/release-estimate")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<ProcurementChargeService.Snapshot> releaseEstimate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.releaseEstimate(customerId,orderNo,platform(user,customerId),feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/correct-target")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<ProcurementAdjustmentService.Result> correct(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Correct request) {
return ApiResponse.success(adjustments.correct(customerId,orderNo,platform(user,customerId),feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/replace-failed")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<ProcurementAdjustmentService.Result> replaceFailed(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Correct request) {
return ApiResponse.success(adjustments.replaceFailed(customerId,orderNo,platform(user,customerId),feeId,request));
}
@PostMapping("/{orderNo}/fees/{feeId}/refund")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')")
public ApiResponse<OverseasProcurementFee> refund(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Refund request) {
return ApiResponse.success(adjustments.refund(customerId,orderNo,platform(user,customerId),feeId,request));
}
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementControlService;
import com.baiyue.lastleg.procurement.service.ProcurementControlService.Snapshot;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementControlController {
private final ProcurementControlService service;
@GetMapping("/{orderNo}/controls")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Snapshot> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
@PostMapping("/{orderNo}/control-notes")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> note(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Note request) {
return ApiResponse.success(service.note(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/control-notes/{noteId}/resolve")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> resolve(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long noteId,@Valid @RequestBody Resolve request) {
return ApiResponse.success(service.resolve(customerId,orderNo,platform(user,customerId),noteId,request));
}
@PostMapping("/{orderNo}/actual-warehouse/correct")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> warehouse(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody WarehouseCorrection request) {
return ApiResponse.success(service.correctWarehouse(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/exception-close")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> close(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Close request) {
return ApiResponse.success(service.close(customerId,orderNo,platform(user,customerId),request));
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses.FileInfo;
import com.baiyue.lastleg.procurement.gateway.ProcurementFileCategory;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
import com.baiyue.lastleg.security.OverseasPermissions;
import lombok.RequiredArgsConstructor;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementFileController {
private final OverseasProcurementService service;
@PostMapping(value="/{orderNo}/files/{category}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("isAuthenticated()")
public ApiResponse<FileInfo> upload(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@PathVariable String category,@RequestPart("file") MultipartFile file) {
String operator=platform(user,customerId);var kind=ProcurementFileCategory.parse(category);
Set<String> allowed=switch(kind) {
case PURCHASE->Set.of(OverseasPermissions.PURCHASE_BUY);
case BUDGET->Set.of(OverseasPermissions.PURCHASE_REVIEW);
case LABEL->Set.of(OverseasPermissions.PURCHASE_LABEL);
case EXCEPTION->Set.of(OverseasPermissions.PURCHASE_MANAGE);
case RECEIPT->Set.of(OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS,OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC);
case VOUCHER->Set.of(OverseasPermissions.PURCHASE_CHARGE,OverseasPermissions.PURCHASE_FINANCE_ADJUST);
};
if(user.permissions()==null || allowed.stream().noneMatch(user.permissions()::contains))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该采购文件用途的上传权限");
return ApiResponse.success(service.uploadFile(customerId,orderNo,operator,kind.name(),file));
}
@GetMapping("/{orderNo}/files/{fileId}/content")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@PathVariable long fileId) {
platform(user,customerId);var file=service.downloadFile(customerId,orderNo,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(file.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.fileName(),StandardCharsets.UTF_8).build().toString())
.contentLength(file.content().length).body(file.content());
}
}
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService;
import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.Outcome;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementLifecycleController {
private final ProcurementLifecycleService service;
@GetMapping("/{orderNo}/fulfillment")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Outcome> detail(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId); return ApiResponse.success(service.read(customerId,orderNo));
}
@PostMapping("/{orderNo}/purchase")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_BUY+"')")
public ApiResponse<Outcome> purchase(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Purchase request) {
return ApiResponse.success(service.recordPurchase(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/purchase/confirm")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_BUY+"')")
public ApiResponse<Outcome> confirmPurchase(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(service.confirmPurchase(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/budget-authorizations")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_REVIEW+"')")
public ApiResponse<Outcome> budget(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Budget request) {
return ApiResponse.success(service.authorizeBudget(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/planned-warehouse")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_REVIEW+"')")
public ApiResponse<Outcome> warehouse(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Warehouse request) {
return ApiResponse.success(service.selectWarehouse(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/cancel")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_CANCEL+"')")
public ApiResponse<Outcome> cancel(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,
@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(service.cancel(customerId,orderNo,platform(user,customerId),request));
}
static String platform(AuthenticatedUser user,String customerId) {
if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(user.tenantId()!=0 || !"PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
if(customerId==null || !customerId.matches("[1-9][0-9]*")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请指定采购订单客户");
return user.userId()==null?user.username():String.valueOf(user.userId());
}
}
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService;
import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService.Snapshot;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementPhysicalController {
private final ProcurementPhysicalService service;
@GetMapping("/{orderNo}/physical")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Snapshot> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
@PostMapping("/{orderNo}/overseas-receipts")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Snapshot> overseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.receive(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/domestic-receipts")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')")
public ApiResponse<Snapshot> domestic(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.receive(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/overseas-receipts/complete")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Snapshot> completeOverseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody FinishReceipt request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.finishReceipt(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/domestic-receipts/complete")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')")
public ApiResponse<Snapshot> completeDomestic(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody FinishReceipt request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.finishReceipt(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/dispositions")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> dispose(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Disposition request) {
return ApiResponse.success(service.dispose(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/packing")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_PACK+"')")
public ApiResponse<Snapshot> packing(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Packing request) {
return ApiResponse.success(service.savePacking(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/packing/confirm")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_PACK+"')")
public ApiResponse<Snapshot> confirmPacking(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ConfirmPacking request) {
return ApiResponse.success(service.confirmPacking(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/packing/withdraw")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> withdraw(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(service.withdrawPacking(customerId,orderNo,platform(user,customerId),request));
}
private static void leg(Leg actual,Leg expected){if(actual!=expected)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"收货段别与操作入口不一致");}
@PostMapping("/{orderNo}/receipts/{receiptId}/correct")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> correct(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,
@PathVariable long receiptId,@Valid @RequestBody Correction request) {
return ApiResponse.success(service.correctReceipt(customerId,orderNo,platform(user,customerId),receiptId,request));
}
@PostMapping("/{orderNo}/dispositions/{dispositionId}/revoke")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')")
public ApiResponse<Snapshot> revoke(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,
@PathVariable long dispositionId,@Valid @RequestBody RevokeDisposition request) {
return ApiResponse.success(service.revokeDisposition(customerId,orderNo,platform(user,customerId),dispositionId,request));
}
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg;
import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementTransportService;
import com.baiyue.lastleg.procurement.service.ProcurementTransportService.Snapshot;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
@RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class ProcurementTransportController {
private final ProcurementTransportService service;
@GetMapping("/{orderNo}/transport")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')")
public ApiResponse<Snapshot> read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) {
platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo));
}
@PostMapping("/{orderNo}/labels")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LABEL+"')")
public ApiResponse<Snapshot> labels(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Labels request) {
return ApiResponse.success(service.saveLabels(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/international-estimate")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_ESTIMATE+"')")
public ApiResponse<Snapshot> estimate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Estimate request) {
return ApiResponse.success(service.reviewEstimate(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/labels/apply")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_APPLY_LABEL+"')")
public ApiResponse<Snapshot> apply(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ApplyLabels request) {
return ApiResponse.success(service.applyLabels(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/domestic-repack")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')")
public ApiResponse<Snapshot> repack(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Repack request) {
return ApiResponse.success(service.repack(customerId,orderNo,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/overseas-shipment")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_OVERSEAS+"')")
public ApiResponse<Snapshot> overseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Ship request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.ship(customerId,orderNo,operator,request));
}
@PostMapping("/{orderNo}/customer-shipment")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Snapshot> customer(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Ship request) {
String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.ship(customerId,orderNo,operator,request));
}
private static void leg(Leg actual,Leg expected){if(actual!=expected)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"运输段与操作入口不一致");}
}
@@ -22,11 +22,11 @@ public final class OverseasProcurementRequests {
@Size(max = 500) String productSpecification,
@Size(max = 2000) String productUrl,
@NotNull @Positive Long purchaseQuantity,
@DecimalMin(value = "0.00") BigDecimal budgetUnitPrice,
@Size(min = 3, max = 3) String currency,
@Size(max = 1000) String receivingAddress,
@NotNull @DecimalMin(value = "0.00") @jakarta.validation.constraints.Digits(integer=16,fraction=2) BigDecimal budgetUnitPrice,
@NotBlank @jakarta.validation.constraints.Pattern(regexp="CNY|USD") String currency,
@NotBlank @Size(max = 1000) String receivingAddress,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone,
@NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) {}
@@ -51,38 +51,15 @@ public final class OverseasProcurementRequests {
@NotNull @Min(0) Long version,
@NotBlank String decision,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record CreateFee(
@NotBlank @Size(max = 32) String feeItemCode,
@NotBlank @Size(max = 128) String feeItemName,
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
@NotNull @Positive Long voucherFileId,
@Size(max = 500) String remark) {}
@Size(max = 1000) String remark,
@Positive Long purchaseWarehouseId) {}
public record Update(
@NotNull @Min(0) Long version,
@Size(max = 128) String purchaseCategory,
@Size(max = 255) String productName,
JsonNode productSpecification,
JsonNode productUrl,
@Min(0) Long inboundQuantity,
JsonNode purchaseWarehouseId,
JsonNode purchaseWarehouseName,
JsonNode domesticCarrier,
JsonNode domesticTrackingNo,
JsonNode overseasCarrier,
JsonNode overseasTrackingNo,
JsonNode estimatedArrivalAt,
JsonNode remark,
JsonNode outboundDetails) {
public Update(Long version, String purchaseCategory, String productName, JsonNode productSpecification,
JsonNode productUrl, Long inboundQuantity, JsonNode purchaseWarehouseId, JsonNode purchaseWarehouseName,
JsonNode domesticCarrier, JsonNode domesticTrackingNo, JsonNode overseasCarrier, JsonNode overseasTrackingNo,
JsonNode estimatedArrivalAt, JsonNode remark) {
this(version, purchaseCategory, productName, productSpecification, productUrl, inboundQuantity,
purchaseWarehouseId, purchaseWarehouseName, domesticCarrier, domesticTrackingNo, overseasCarrier,
overseasTrackingNo, estimatedArrivalAt, remark, null);
JsonNode remark) {
@com.fasterxml.jackson.annotation.JsonAnySetter
public void rejectUnknownField(String name, JsonNode value) {
throw new IllegalArgumentException("采购通用编辑仅支持备注,业务字段请使用对应履约操作");
}
}
}
@@ -7,14 +7,11 @@ import java.util.List;
public final class OverseasProcurementResponses {
private OverseasProcurementResponses() {}
public record OutboundDetail(String id, long quantity, String carrier, String trackingNo, LocalDateTime shippedAt, String remark) {}
public record Summary(
Long id, String orderNo, String customerId, String customerName,
String purchaseCategory, String productName, String productSpecification,
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
String purchaseWarehouseId, String purchaseWarehouseName,
long inboundQuantity, long outboundQuantity, long inventoryQuantity,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record CustomerOption(String customerId, String customerName) {}
@@ -38,10 +35,9 @@ public final class OverseasProcurementResponses {
public record Detail(
Summary summary, String productUrl, String receivingAddress,
String contactName, String contactPhone,
String domesticCarrier, String domesticTrackingNo,
String overseasCarrier, String overseasTrackingNo,
LocalDateTime estimatedArrivalAt, String remark,
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
List<History> histories, List<OutboundDetail> outboundDetails,
List<History> histories,
List<FileInfo> files, List<Fee> fees) {}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.procurement.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public final class ProcurementChargeRequests {
private ProcurementChargeRequests() { }
public record Amount(@NotBlank @Pattern(regexp="POSITIVE|ZERO|NOT_APPLICABLE") String determination,
@NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency,@NotNull LocalDate businessDate,
@Positive Long voucherFileId,@NotBlank @Size(max=500) String reason) { }
/** Existing immutable fee OR a newly determined amount; never both. */
public record Selection(@Positive Long feeId,@Valid Amount value) { }
public record Other(@NotBlank @Pattern(regexp="[A-Z][A-Z0-9_]{0,31}") String code,
@NotBlank @Size(max=128) String name,@NotNull @Valid Selection selection) { }
public record Confirm(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull @Valid Selection internationalFreight,@NotNull @Valid Selection duty,
@NotNull @Valid Selection domesticFreight,
@NotBlank @Pattern(regexp="NOT_APPLICABLE|ITEMIZED") String otherDetermination,
@NotNull List<@NotNull @Valid Other> otherFees,@NotBlank @Size(max=1000) String reason) { }
public record Additional(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotBlank @Size(max=128) String name,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=16,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency,@NotNull @PastOrPresent LocalDate businessDate,
@Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { }
public record Correct(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal targetOriginalAmount,
@Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { }
public record Refund(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=16,fraction=2) BigDecimal amount,
@Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { }
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.procurement.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
public final class ProcurementControlRequests {
private ProcurementControlRequests() { }
public record Note(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotBlank @Pattern(regexp="PURCHASE|WAREHOUSE|RECEIPT|SHIPMENT") String targetType,@Positive Long targetId,
boolean blocking,@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason,
@Size(max=500) String clientSummary) { }
public record Resolve(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { }
public record WarehouseCorrection(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long warehouseId,@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { }
public record Resolution(@NotBlank @Pattern(regexp="PURCHASE_REMAINDER|TRANSIT_REMAINDER|GOOD|DAMAGED|EXTRA") String kind,
@Positive Long sourceId,@Positive long quantity,
@NotBlank @Pattern(regexp="SUPPLIER_RETAINED|RETURNED_TO_SUPPLIER|RETURNED_TO_SENDER|LOSS_CONFIRMED|CUSTOMER_AUTHORIZED_RETENTION|DISPOSED") String decision,
@NotBlank @Size(max=500) String destination,@Positive long voucherFileId) { }
public record Close(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull List<@NotNull @Valid Resolution> resolutions,@Positive long customerVoucherFileId,
@NotBlank @Size(max=1000) String financialConclusion,@NotBlank @Size(max=1000) String reason,
@NotBlank @Size(max=500) String clientSummary) { }
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.procurement.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class ProcurementLifecycleRequests {
private ProcurementLifecycleRequests() { }
public record Command(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason) { }
public record Purchase(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey,
@NotBlank @Size(max=255) String productName, @Size(max=500) String specification,
@Positive long quantity, @NotNull @DecimalMin("0.00") @Digits(integer=16,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency,
@NotNull @PastOrPresent LocalDateTime purchasedAt, @NotNull LocalDateTime estimatedArrivalAt,
@Positive long voucherFileId, @NotBlank @Size(max=1000) String reason) { }
public record Budget(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey,
@NotNull @DecimalMin("0.00") @Digits(integer=16,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency, @Positive long voucherFileId,
@NotBlank @Size(max=1000) String reason) { }
public record Warehouse(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey,
@Positive long warehouseId, @NotBlank @Size(max=1000) String reason) { }
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.procurement.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class ProcurementPhysicalRequests {
private ProcurementPhysicalRequests() { }
public enum Leg { OVERSEAS, DOMESTIC }
public record Receipt(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg,
@Positive Long warehouseId,@Positive Long sourceCartonId,
@Min(0) long goodQuantity,@Min(0) long damagedQuantity,@Min(0) long extraQuantity,
@NotNull @PastOrPresent LocalDateTime receivedAt,@NotBlank @Size(max=1000) String reason) { }
public record FinishReceipt(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg,
@NotBlank @Size(max=1000) String reason) { }
public record Disposition(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg,
@Positive Long sourceCartonId,@Positive Long receiptId,
@NotBlank @Pattern(regexp="SHORTAGE|DAMAGED|EXTRA") String kind,
@Positive long quantity,@NotBlank @Pattern(regexp="ACCEPT_SHORTAGE|DISPOSE_DAMAGED|RETURN_EXTRA") String decision,
@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { }
public record Source(@Positive long receiptId,@Positive long quantity) { }
public record Correction(@NotNull @Valid Receipt receipt,@Positive long voucherFileId) { }
public record RevokeDisposition(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { }
public record Carton(@NotBlank @Size(max=64) String cartonNo,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal lengthCm,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal widthCm,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal heightCm,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=9,fraction=3) BigDecimal grossWeightKg,
@NotEmpty List<@NotNull @Valid Source> sources) { }
public record Packing(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotEmpty List<@NotNull @Valid Carton> cartons,@NotBlank @Size(max=1000) String reason) { }
public record ConfirmPacking(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@Min(1) int packingVersion,@NotBlank @Size(max=1000) String reason) { }
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.procurement.dto;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class ProcurementTransportRequests {
private ProcurementTransportRequests() { }
public record Address(@NotBlank @Size(max=128) String name,
@NotBlank @Pattern(regexp="[+0-9 ()-]{5,64}") String phone,
@NotBlank @Size(max=1000) String address) { }
public record Label(@Positive long cartonId,@Positive long fileId,@Min(1) int page,
@NotBlank @Size(max=128) String carrier,@NotBlank @Size(max=128) String trackingNo) { }
public record Labels(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull Leg leg,@Min(1) int packingVersion,@NotNull @Valid Address receiver,
@Positive Long addressVoucherFileId,@NotEmpty List<@NotNull @Valid Label> labels,
@NotBlank @Size(max=1000) String reason) { }
public record Estimate(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@Min(1) int labelVersion,@NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal amount,
@NotNull LocalDate businessDate,@NotBlank @Size(max=500) String reason) { }
public record ApplyLabels(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@Min(1) int labelVersion,@NotEmpty List<@NotNull @Positive Long> cartonIds,
@NotNull @PastOrPresent LocalDateTime appliedAt,@NotBlank @Size(max=1000) String reason) { }
public record Ship(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotNull Leg leg,@Min(1) int labelVersion,@NotEmpty List<@NotNull @Positive Long> cartonIds,
@NotNull @PastOrPresent LocalDateTime shippedAt,@NotBlank @Size(max=1000) String reason) { }
public record Repack(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long cartonId,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal lengthCm,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal widthCm,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal heightCm,
@NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=9,fraction=3) BigDecimal grossWeightKg,
@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { }
}
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
@Data
@TableName("ll_overseas_procurement_fee")
@@ -14,6 +15,24 @@ public class OverseasProcurementFee {
private Long orderId;
private String customerId;
private String feeNo;
private String purpose;
private String confirmationOrigin;
private Integer feeVersion;
private Boolean active;
private String determination;
private String originalCurrency;
private BigDecimal originalAmount;
private BigDecimal exchangeRate;
private String exchangeRateMonth;
private LocalDate businessDate;
private Long walletBusinessId;
private String walletBusinessNo;
private String walletHoldId;
private String walletLedgerId;
private String settledCommandEventId;
private Long sourceFeeId;
private Long parentFeeId;
private String requestHash;
private String feeItemCode;
private String feeItemName;
private BigDecimal amount;
@@ -22,7 +41,9 @@ public class OverseasProcurementFee {
private String voucherFileName;
private String remark;
private String walletStatus;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String walletErrorCode;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String walletErrorMessage;
private String walletCommandEventId;
private String createdBy;
@@ -30,11 +30,6 @@ public class OverseasProcurementOrder {
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName;
private Long inboundQuantity;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String outboundDetails;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
private String status;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
@@ -37,7 +37,7 @@ public class HttpProcurementFileGateway implements ProcurementFileGateway {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(file.getContentType()));
} catch (Exception exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购文件无法读取", exception); }
String businessType = "RECEIPT".equals(category) ? "OVERSEAS_PROCUREMENT_RECEIPT" : "OVERSEAS_PROCUREMENT_VOUCHER";
String businessType = ProcurementFileCategory.parse(category).businessType();
Envelope response = client.post().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
Long.parseLong(customerId), businessType, orderId)
.header("X-Internal-Service-Token", token).header("X-Operator-Id", String.valueOf(operatorId))
@@ -51,5 +51,16 @@ public class HttpProcurementFileGateway implements ProcurementFileGateway {
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
}
private record Envelope(String code, String message, Metadata data) { }
@Override
public Download download(String customerId,long orderId,String category,long fileId) {
var response=client.get().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
Long.parseLong(customerId),ProcurementFileCategory.parse(category).businessType(),orderId,fileId)
.header("X-Internal-Service-Token",token).retrieve()
.onStatus(HttpStatusCode::isError,(request,result)->{throw new ResponseStatusException(
result.getStatusCode().is4xxClientError()?HttpStatus.NOT_FOUND:HttpStatus.SERVICE_UNAVAILABLE,"采购文件暂不可读取");})
.toEntity(byte[].class);
if(response.getBody()==null)throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"文件服务没有返回文件内容");
return new Download(response.getBody(),response.getHeaders().getContentType()==null?MediaType.APPLICATION_OCTET_STREAM_VALUE:response.getHeaders().getContentType().toString());
}
private record Metadata(Long fileId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.procurement.gateway;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.Locale;
public enum ProcurementFileCategory {
RECEIPT,VOUCHER,LABEL,PURCHASE,BUDGET,EXCEPTION;
public String businessType(){return "OVERSEAS_PROCUREMENT_"+name();}
public static ProcurementFileCategory parse(String value){
try{return valueOf(value==null?"":value.trim().toUpperCase(Locale.ROOT));}
catch(IllegalArgumentException exception){throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"采购文件用途不受支持");}
}
}
@@ -2,5 +2,7 @@ package com.baiyue.lastleg.procurement.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface ProcurementFileGateway {
UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file);
Download download(String customerId,long orderId,String category,long fileId);
record Download(byte[] content,String contentType) { }
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -2,4 +2,9 @@ package com.baiyue.lastleg.procurement.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper<OverseasProcurementFee> { }
@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper<OverseasProcurementFee> {
@org.apache.ibatis.annotations.Select("SELECT * FROM ll_overseas_procurement_fee WHERE id=#{id} AND order_id=#{orderId} AND customer_id=#{customerId} FOR UPDATE")
OverseasProcurementFee lock(@org.apache.ibatis.annotations.Param("id") long id,
@org.apache.ibatis.annotations.Param("orderId") long orderId,
@org.apache.ibatis.annotations.Param("customerId") String customerId);
}
@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
@@ -14,11 +13,6 @@ import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.wallet.WalletResultHandler;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
import com.baiyue.lastleg.security.OverseasPermissions;
@@ -39,7 +33,6 @@ import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -49,24 +42,24 @@ import java.util.Set;
import org.springframework.web.multipart.MultipartFile;
@Service
public class OverseasProcurementService implements WalletResultHandler {
public class OverseasProcurementService {
@Resource private ProcurementOrderNumberGenerator orderNoGenerator;
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String WAIT_PURCHASE = "WAIT_PURCHASE";
public static final String REJECTED = "REJECTED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "WAIT_OVERSEAS_RECEIPT",
"OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "INTERNATIONAL_TRANSIT",
"DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED");
@Resource private OverseasProcurementOrderMapper orders;
@Resource private OverseasProcurementStatusHistoryMapper histories;
@Resource private PartnerWarehouseDirectory warehouseDirectory;
@Resource private ProcurementWarehouseDirectory warehouseDirectory;
@Resource private ObjectMapper objectMapper;
@Resource private OverseasProcurementFileMapper procurementFiles;
@Resource private OverseasProcurementFeeMapper procurementFees;
@Resource private ProcurementFileGateway fileGateway;
@Resource private WalletCommandOutboxService walletCommands;
@Resource private OverseasFeeSourceService feeSources;
@Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
@@ -117,13 +110,13 @@ public class OverseasProcurementService implements WalletResultHandler {
if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200");
return orders.customerOptions(trim(keyword), limit).stream()
.map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(),
Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId())))
Objects.requireNonNullElse(trim(value.getCustomerName()), "—")))
.toList();
}
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() {
return warehouseDirectory.options().stream()
.map(value -> new OverseasProcurementResponses.WarehouseOption(value.warehouseId(),
.map(value -> new OverseasProcurementResponses.WarehouseOption(String.valueOf(value.warehouseId()),
value.warehouseCode(), value.warehouseName(), value.countryCode()))
.toList();
}
@@ -159,21 +152,20 @@ public class OverseasProcurementService implements WalletResultHandler {
if (order == null) throw notFound("Overseas procurement order not found");
return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(),
order.getReceivingAddress(), order.getContactName(), order.getContactPhone(),
order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(),
order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(),
order.getEstimatedArrivalAt(), order.getRemark(),
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
histories.listOrder(customerId, order.getId()).stream().map(value ->
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList(), outboundDetails(order), files(order), fees(order));
value.getCreatedAt())).toList(), files(order), fees(order));
}
@Transactional
public OverseasProcurementResponses.FileInfo uploadFile(String customerId, String orderNo, String operatorId,
String category, MultipartFile file) {
OverseasProcurementOrder order = lock(customerId, orderNo);
if (!Set.of("RECEIPT", "VOUCHER").contains(category)) throw badRequest("File category must be RECEIPT or VOUCHER");
category = com.baiyue.lastleg.procurement.gateway.ProcurementFileCategory.parse(category).name();
ProcurementFileGateway.UploadedFile uploaded = fileGateway.upload(customerId, order.getId(), Long.parseLong(operatorId), category, file);
OverseasProcurementFile row = new OverseasProcurementFile();
row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); row.setCategory(category);
@@ -183,54 +175,15 @@ public class OverseasProcurementService implements WalletResultHandler {
return file(row);
}
@Transactional
public OverseasProcurementResponses.Fee createFee(String customerId, String orderNo, String operatorId,
OverseasProcurementRequests.CreateFee request) {
OverseasProcurementOrder order = lock(customerId, orderNo);
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can add fees");
OverseasProcurementFile voucher = procurementFiles.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>()
.eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, customerId)
.eq(OverseasProcurementFile::getCategory, "VOUCHER").eq(OverseasProcurementFile::getFileId, request.voucherFileId()));
if (voucher == null) throw badRequest("Fee voucher does not belong to this procurement order");
LocalDateTime now = LocalDateTime.now();
OverseasProcurementFee row = new OverseasProcurementFee();
row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId);
row.setFeeNo("PF" + row.getId()); row.setFeeItemCode(request.feeItemCode().trim().toUpperCase());
row.setFeeItemName(request.feeItemName().trim()); row.setAmount(request.amount()); row.setCurrency("CNY");
row.setVoucherFileId(voucher.getFileId()); row.setVoucherFileName(voucher.getFileName()); row.setRemark(trim(request.remark()));
row.setWalletStatus("FREEZE_PENDING"); row.setCreatedBy(operatorId); row.setCreatedAt(now); row.setUpdatedAt(now);
procurementFees.insert(row);
String eventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "PROCUREMENT_FEE", row.getFeeNo(),
row.getAmount(), "海外采购费用冻结:" + orderNo, 1L);
row.setWalletCommandEventId(eventId); procurementFees.updateById(row);
return fee(row);
}
@Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); }
@Override
@Transactional
public void handle(WalletCommandCompletedV1 result) {
OverseasProcurementFee row = procurementFees.selectOne(new LambdaQueryWrapper<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getCustomerId, String.valueOf(result.customerId()))
.eq(OverseasProcurementFee::getFeeNo, result.businessNo()));
if (row == null) throw notFound("Procurement fee not found for wallet result");
if ("SUCCEEDED".equals(result.result())) {
row.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); });
row.setWalletErrorCode(null); row.setWalletErrorMessage(null);
} else {
row.setWalletStatus(result.operation() + "_FAILED"); row.setWalletErrorCode(result.errorCode()); row.setWalletErrorMessage(result.errorMessage());
}
row.setUpdatedAt(LocalDateTime.now()); procurementFees.updateById(row);
if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) {
OverseasProcurementOrder order = orders.selectById(row.getOrderId());
if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) {
publishFeeSource(order, row);
walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null,
"海外采购完成入库,结算费用", 2L);
row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row);
}
}
public record DownloadedFile(String fileName,String contentType,byte[] content) { }
public DownloadedFile downloadFile(String customerId,String orderNo,long fileId) {
var order=orders.findNo(customerId,orderNo);
if(order==null)throw notFound("采购订单不存在或不属于当前客户");
var file=procurementFiles.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>()
.eq(OverseasProcurementFile::getCustomerId,customerId).eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getFileId,fileId));
if(file==null)throw notFound("文件未关联本采购订单");
var content=fileGateway.download(customerId,order.getId(),file.getCategory(),fileId);
return new DownloadedFile(file.getFileName(),content.contentType(),content.content());
}
private List<OverseasProcurementResponses.FileInfo> files(OverseasProcurementOrder order) {
@@ -257,8 +210,8 @@ public class OverseasProcurementService implements WalletResultHandler {
public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId,
OverseasProcurementRequests.Review request) {
String next = switch (request.decision().trim().toUpperCase()) {
case "APPROVE", APPROVED -> APPROVED;
case "REJECT", REJECTED -> REJECTED;
case "APPROVE" -> WAIT_PURCHASE;
case "REJECT" -> REJECTED;
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
OverseasProcurementOrder order = lock(customerId, orderNo);
@@ -272,6 +225,12 @@ public class OverseasProcurementService implements WalletResultHandler {
}
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
if (WAIT_PURCHASE.equals(next)) {
if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择有效海外收货仓");
var warehouse = warehouseDirectory.required(request.purchaseWarehouseId());
order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));
order.setPurchaseWarehouseName(warehouse.warehouseName());
}
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
order.setReviewedBy(operatorId);
@@ -292,23 +251,7 @@ public class OverseasProcurementService implements WalletResultHandler {
OverseasProcurementOrder order = lock(customerId, orderNo);
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
Map<String, Object> before = editableSnapshot(order);
if (request.inboundQuantity() != null) {
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock");
if (request.inboundQuantity() > order.getPurchaseQuantity()) throw badRequest("Inbound quantity cannot exceed purchase quantity");
order.setInboundQuantity(request.inboundQuantity());
}
setIfText(request.purchaseCategory(), order::setPurchaseCategory);
setIfText(request.productName(), order::setProductName);
patchText(request.productSpecification(), 500, "productSpecification", order::setProductSpecification);
patchText(request.productUrl(), 2000, "productUrl", order::setProductUrl);
patchWarehouse(order, request.purchaseWarehouseId(), request.purchaseWarehouseName());
patchText(request.domesticCarrier(), 128, "domesticCarrier", order::setDomesticCarrier);
patchText(request.domesticTrackingNo(), 128, "domesticTrackingNo", order::setDomesticTrackingNo);
patchText(request.overseasCarrier(), 128, "overseasCarrier", order::setOverseasCarrier);
patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo);
patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt);
patchText(request.remark(), 1000, "remark", order::setRemark);
if (request.outboundDetails() != null) order.setOutboundDetails(normalizeOutbound(request.outboundDetails(), order.getInboundQuantity()));
Map<String, Object> after = editableSnapshot(order);
if (before.equals(after)) return detail(customerId, orderNo);
LocalDateTime now = LocalDateTime.now();
@@ -316,28 +259,9 @@ public class OverseasProcurementService implements WalletResultHandler {
orders.updateById(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
null, changeDetail(before, after), null, now);
if (order.getInboundQuantity() >= order.getPurchaseQuantity()) {
for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) {
publishFeeSource(order, fee);
walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null,
"海外采购完成入库,结算费用", 2L);
fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee);
}
}
return detail(customerId, orderNo);
}
private void publishFeeSource(OverseasProcurementOrder order, OverseasProcurementFee fee) {
feeSources.publishApproved(StringUtils.hasText(fee.getCreatedBy()) ? fee.getCreatedBy() : "SYSTEM", "PROCUREMENT:" + fee.getFeeNo(),
new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
"OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getId(), fee.getFeeNo(),
order.getId(), null, null, null, null, null, parseLong(order.getPurchaseWarehouseId()),
fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", null, null, null, null,
null, null, null, List.of(), LocalDate.now(), fee.getCurrency(), fee.getAmount(),
BigDecimal.ONE, "ITEM", fee.getAmount(), null, null));
}
private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) {
if (!hash.equals(order.getRequestHash())) {
throw conflict("Idempotency key was reused with different procurement data");
@@ -352,16 +276,11 @@ public class OverseasProcurementService implements WalletResultHandler {
}
private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) {
long inbound = value(order.getInboundQuantity());
long outbound = outboundDetails(order).stream().mapToLong(OverseasProcurementResponses.OutboundDetail::quantity).sum();
String displayStatus = order.getStatus();
if (APPROVED.equals(order.getStatus()) && inbound > 0) {
displayStatus = outbound == 0 ? "PENDING_OUTBOUND" : outbound < inbound ? "PARTIALLY_OUTBOUND" : "OUTBOUNDED";
}
return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(),
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(),
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound, Math.max(0, inbound - outbound),
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(),
displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
}
@@ -400,31 +319,6 @@ public class OverseasProcurementService implements WalletResultHandler {
}
}
private void patchWarehouse(OverseasProcurementOrder order, JsonNode warehouseId, JsonNode warehouseName) {
if (warehouseId == null) {
if (warehouseName != null) throw badRequest("purchaseWarehouseName cannot be changed without purchaseWarehouseId");
return;
}
String id = nullableText(warehouseId, 128, "purchaseWarehouseId");
if (id == null) {
order.setPurchaseWarehouseId(null);
order.setPurchaseWarehouseName(null);
return;
}
long numericId;
try {
numericId = Long.parseLong(id);
} catch (NumberFormatException exception) {
throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID");
}
// Keep the warehouse snapshot already frozen on a historical order. A later master-data
// deactivation must not block unrelated edits such as recording outbound details.
if (Long.toString(numericId).equals(order.getPurchaseWarehouseId())) return;
var option = warehouseDirectory.required(numericId);
order.setPurchaseWarehouseId(option.warehouseId());
order.setPurchaseWarehouseName(option.warehouseName());
}
private static void patchText(JsonNode node, int maxLength, String field,
java.util.function.Consumer<String> setter) {
if (node != null) setter.accept(nullableText(node, maxLength, field));
@@ -438,55 +332,12 @@ public class OverseasProcurementService implements WalletResultHandler {
return value;
}
private static void patchDateTime(JsonNode node, java.util.function.Consumer<LocalDateTime> setter) {
if (node == null) return;
String value = nullableText(node, 64, "estimatedArrivalAt");
if (value == null) {
setter.accept(null);
return;
}
try {
setter.accept(LocalDateTime.parse(value));
} catch (DateTimeParseException exception) {
throw badRequest("estimatedArrivalAt must be an ISO local date-time or null");
}
}
private static Map<String, Object> editableSnapshot(OverseasProcurementOrder order) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("purchaseCategory", order.getPurchaseCategory());
result.put("productName", order.getProductName());
result.put("productSpecification", order.getProductSpecification());
result.put("productUrl", order.getProductUrl());
result.put("inboundQuantity", value(order.getInboundQuantity()));
result.put("purchaseWarehouseId", order.getPurchaseWarehouseId());
result.put("purchaseWarehouseName", order.getPurchaseWarehouseName());
result.put("domesticCarrier", order.getDomesticCarrier());
result.put("domesticTrackingNo", order.getDomesticTrackingNo());
result.put("overseasCarrier", order.getOverseasCarrier());
result.put("overseasTrackingNo", order.getOverseasTrackingNo());
result.put("estimatedArrivalAt", order.getEstimatedArrivalAt());
result.put("remark", order.getRemark());
result.put("outboundDetails", order.getOutboundDetails());
Map<String,Object> result=new LinkedHashMap<>();
result.put("remark",order.getRemark());
return result;
}
private List<OverseasProcurementResponses.OutboundDetail> outboundDetails(OverseasProcurementOrder order) {
if (!StringUtils.hasText(order.getOutboundDetails())) return List.of();
try { return objectMapper.readValue(order.getOutboundDetails(), objectMapper.getTypeFactory().constructCollectionType(List.class, OverseasProcurementResponses.OutboundDetail.class)); }
catch (JsonProcessingException e) { return List.of(); }
}
private String normalizeOutbound(JsonNode node, Long inbound) {
if (!node.isArray()) throw badRequest("outboundDetails must be an array");
long total = 0;
for (JsonNode item : node) {
if (!item.isObject() || item.path("quantity").asLong(-1) < 0) throw badRequest("outboundDetails quantity must be non-negative");
total += item.path("quantity").asLong();
}
if (total > value(inbound)) throw badRequest("outbound quantity cannot exceed inbound quantity");
try { return objectMapper.writeValueAsString(node); } catch (JsonProcessingException e) { throw badRequest("Invalid outboundDetails"); }
}
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> {
@@ -0,0 +1,110 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Correct;
import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Additional;
import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Refund;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*;
@Service @RequiredArgsConstructor
public class ProcurementAdjustmentService {
private final ProcurementLifecycleService lifecycle;
private final ProcurementFinanceService finance;
private final JdbcTemplate jdbc;
public record Result(String correctionNo,String rootFeeNo,BigDecimal targetOriginalAmount,String originalCurrency,
BigDecimal exchangeRate,String exchangeRateMonth,BigDecimal targetAmount,BigDecimal previousNetAmount,
BigDecimal differenceAmount,List<String> feeNos) { }
private record Identity(long rootFeeId,Correct request) { }
@Transactional
public Result correct(String customer,String orderNo,String operator,long rootFeeId,Correct request) {
var order=lifecycle.lock(customer,orderNo);var identity=new Identity(rootFeeId,request);
if(lifecycle.replay(order,"CORRECT_FEE_TARGET",request.idempotencyKey(),identity))return result(order.getId(),request.idempotencyKey());
version(order,request.version());var root=finance.required(order,rootFeeId);
if(Set.of("INTERNATIONAL_ESTIMATE","ADJUSTMENT","REFUND").contains(root.getPurpose()))throw bad("目标更正须选择原实际费用,不能选择预估或差额单");
var chain=finance.readyChain(order,root);
if(request.targetOriginalAmount()==null || request.targetOriginalAmount().signum()<0 || request.targetOriginalAmount().scale()>2)throw bad("正确原币总额须为非负两位小数");
lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER"));
if(root.getExchangeRate()==null || root.getExchangeRate().signum()<=0)throw conflict("原费用汇率快照缺失,不能重新取汇率替代");
BigDecimal net=chain.stream().filter(f->"SETTLED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)
.subtract(chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add));
BigDecimal target=request.targetOriginalAmount().multiply(root.getExchangeRate()).setScale(2,RoundingMode.HALF_UP),delta=target.subtract(net);
if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围");
long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
// The node command carries the exact request hash; this table stores the computed immutable monetary facts.
jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
id,order.getId(),customer,no,rootFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),root.getOriginalCurrency(),root.getExchangeRate(),root.getExchangeRateMonth(),target,net,delta,request.voucherFileId(),request.reason(),operator);
List<OverseasProcurementFee> created=delta.signum()>0?List.of(finance.supplement(customer,orderNo,operator,rootFeeId,delta,request.voucherFileId(),request.reason(),no)):
delta.signum()<0?finance.reduceChain(customer,orderNo,operator,rootFeeId,delta.negate(),request.voucherFileId(),request.reason(),no):List.of();
for(var fee:created)jdbc.update("INSERT INTO ll_procurement_fee_correction_item(correction_id,fee_id) VALUES(?,?)",id,fee.getId());
lifecycle.complete(order,operator,"CORRECT_FEE_TARGET",request.idempotencyKey(),request.reason(),identity);
return result(order.getId(),request.idempotencyKey());
}
@Transactional
public Result replaceFailed(String customer,String orderNo,String operator,long originalFeeId,Correct request) {
var order=lifecycle.lock(customer,orderNo);var identity=new Identity(originalFeeId,request);
if(lifecycle.replay(order,"REPLACE_FAILED_FEE",request.idempotencyKey(),identity))return result(order.getId(),request.idempotencyKey());
version(order,request.version());var original=finance.requireFailedReplacement(order,originalFeeId);
if(request.targetOriginalAmount()==null || request.targetOriginalAmount().signum()<0 || request.targetOriginalAmount().scale()>2)throw bad("正确原币总额须为非负两位小数");
lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER"));
BigDecimal target=request.targetOriginalAmount().multiply(original.getExchangeRate()).setScale(2,RoundingMode.HALF_UP);
if("SAMPLE_PAYMENT".equals(original.getPurpose()))lifecycle.requireReplacementBudget(order,request.targetOriginalAmount(),original.getOriginalCurrency(),original.getBusinessDate(),target);
if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围");
long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
id,order.getId(),customer,no,originalFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),original.getOriginalCurrency(),original.getExchangeRate(),original.getExchangeRateMonth(),target,BigDecimal.ZERO,target,request.voucherFileId(),request.reason(),operator);
var fee=finance.replaceFailed(customer,orderNo,operator,originalFeeId,request.targetOriginalAmount(),request.voucherFileId(),request.reason());
jdbc.update("INSERT INTO ll_procurement_fee_correction_item(correction_id,fee_id) VALUES(?,?)",id,fee.getId());
if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) {
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId());
if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES");
}
lifecycle.complete(order,operator,"REPLACE_FAILED_FEE",request.idempotencyKey(),request.reason(),identity);
return result(order.getId(),request.idempotencyKey());
}
@Transactional
public OverseasProcurementFee additional(String customer,String orderNo,String operator,Additional request) {
var order=lifecycle.lock(customer,orderNo);String code="ADD_"+lifecycle.requestHash(List.of(customer,orderNo,request.idempotencyKey())).substring(0,24).toUpperCase();
if(lifecycle.replay(order,"ADD_ACTUAL_FEE",request.idempotencyKey(),request))return finance.all(order.getId()).stream().filter(f->code.equals(f.getFeeItemCode())).min(Comparator.comparing(OverseasProcurementFee::getFeeVersion)).orElseThrow(()->conflict("原新增费用记录缺失"));
version(order,request.version());
if(lifecycle.flow(order.getId()).get("purchased_at")==null)throw conflict("独立新增费用须关联已发生购买的采购订单");
if(request.businessDate()==null || request.businessDate().isAfter(java.time.LocalDate.now()))throw bad("独立费用业务日期须为已发生日期");
lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER"));
var fee=finance.confirmAdditional(customer,orderNo,operator,new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.OTHER,code,request.name(),"POSITIVE",request.amount(),request.currency(),request.businessDate(),request.voucherFileId(),request.reason()));
if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) {
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId());
if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES");
}
lifecycle.complete(order,operator,"ADD_ACTUAL_FEE",request.idempotencyKey(),request.reason(),request);return fee;
}
private record RefundIdentity(long originalFeeId,Refund request) { }
@Transactional
public OverseasProcurementFee refund(String customer,String orderNo,String operator,long originalFeeId,Refund request) {
var order=lifecycle.lock(customer,orderNo);var identity=new RefundIdentity(originalFeeId,request);
if(lifecycle.replay(order,"REFUND_FEE",request.idempotencyKey(),identity))return finance.refund(customer,orderNo,operator,originalFeeId,request.amount(),request.voucherFileId(),request.reason(),request.idempotencyKey());
version(order,request.version());
var fee=finance.refund(customer,orderNo,operator,originalFeeId,request.amount(),request.voucherFileId(),request.reason(),request.idempotencyKey());
lifecycle.complete(order,operator,"REFUND_FEE",request.idempotencyKey(),request.reason(),identity);return fee;
}
private Result result(long orderId,String key) {
var row=jdbc.queryForMap("SELECT c.*,f.fee_no AS root_fee_no FROM ll_procurement_fee_correction c JOIN ll_overseas_procurement_fee f ON f.id=c.root_fee_id WHERE c.order_id=? AND c.idempotency_key=?",orderId,key);
var numbers=jdbc.queryForList("SELECT f.fee_no FROM ll_procurement_fee_correction_item i JOIN ll_overseas_procurement_fee f ON f.id=i.fee_id WHERE i.correction_id=? ORDER BY f.id",String.class,row.get("id"));
return new Result((String)row.get("correction_no"),(String)row.get("root_fee_no"),(BigDecimal)row.get("target_original_amount"),(String)row.get("original_currency"),
(BigDecimal)row.get("exchange_rate"),(String)row.get("exchange_rate_month"),(BigDecimal)row.get("target_amount"),(BigDecimal)row.get("previous_net_amount"),(BigDecimal)row.get("difference_amount"),numbers);
}
@Transactional(readOnly=true)
public List<Result> history(String customer,String orderNo) {
var order=lifecycle.requireOrder(customer,orderNo);
return jdbc.queryForList("SELECT idempotency_key FROM ll_procurement_fee_correction WHERE order_id=? ORDER BY id DESC",String.class,order.getId())
.stream().map(key->result(order.getId(),key)).toList();
}
}
@@ -0,0 +1,113 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*;
@Service @RequiredArgsConstructor
public class ProcurementChargeService {
private final ProcurementLifecycleService lifecycle;
private final ProcurementFinanceService finance;
private final JdbcTemplate jdbc;
public record Fee(OverseasProcurementFee detail,boolean sourceLinked) { }
public record Snapshot(String orderNo,long version,String status,LocalDateTime confirmedAt,List<Fee> fees) { }
@Transactional
public Snapshot confirm(String customer,String orderNo,String operator,Confirm request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"CONFIRM_CHARGES",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());open(order);lifecycle.requireNoBlockers(order);
if(lifecycle.flow(order.getId()).get("domestic_received_at")==null)throw conflict("须国内收货完成且差异处置明确后确认费用");
if(request.otherFees()==null || !Set.of("NOT_APPLICABLE","ITEMIZED").contains(request.otherDetermination()))throw bad("其他费用须明确核定,不能将未知当作零元");
if("NOT_APPLICABLE".equals(request.otherDetermination())!=request.otherFees().isEmpty())throw bad("其他费用不适用时不登记收费行,适用时须逐项核定");
Integer confirmed=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command WHERE order_id=? AND action='CONFIRM_CHARGES'",Integer.class,order.getId());
if(confirmed!=null && confirmed>0 && (Arrays.asList(request.internationalFreight(),request.duty(),request.domesticFreight()).stream().anyMatch(item->item!=null && item.value()!=null)
|| request.otherFees().stream().anyMatch(item->item!=null && item.selection()!=null && item.selection().value()!=null)))throw conflict("首次核费后只可引用已有费用版本,新增或替代费用须使用财务操作");
Set<Long> selected=new HashSet<>();
selected.add(select(order,operator,ProcurementFeePurpose.INTERNATIONAL_FREIGHT,null,null,request.internationalFreight()).getId());
selected.add(select(order,operator,ProcurementFeePurpose.DUTY,null,null,request.duty()).getId());
selected.add(select(order,operator,ProcurementFeePurpose.DOMESTIC_FREIGHT,null,null,request.domesticFreight()).getId());
Set<String> codes=new HashSet<>();
for(var item:request.otherFees()) {
if(item==null || item.code()==null || !codes.add(item.code()))throw bad("其他费用编码不能重复");
selected.add(select(order,operator,ProcurementFeePurpose.OTHER,item.code(),item.name(),item.selection()).getId());
}
for(var fee:finance.all(order.getId())) {
if(Boolean.TRUE.equals(fee.getActive()) && Set.of("INTERNATIONAL_FREIGHT","DUTY","DOMESTIC_FREIGHT","OTHER").contains(fee.getPurpose()) && !selected.contains(fee.getId())) {
throw conflict("存在未列入本次核对的有效实际费用,不能通过省略行隐藏原收费");
}
}
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP(3) WHERE order_id=?",order.getId());
order.setStatus("WAIT_CUSTOMER_SHIPMENT");
lifecycle.complete(order,operator,"CONFIRM_CHARGES",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
private OverseasProcurementFee select(OverseasProcurementOrder order,String operator,ProcurementFeePurpose purpose,String code,String name,Selection selection) {
if(selection==null || (selection.feeId()==null)==(selection.value()==null))throw bad("每项费用须选择原已核定版本或填写新的核定结果,不能同时选择");
if(selection.feeId()!=null) {
var fee=finance.required(order,selection.feeId());
if(!Boolean.TRUE.equals(fee.getActive()) || !purpose.name().equals(fee.getPurpose())
|| (purpose==ProcurementFeePurpose.OTHER && !Objects.equals(code,fee.getFeeItemCode())))throw bad("引用费用不是本单该用途的有效版本");
return fee;
}
var value=selection.value();
var request=new ProcurementFinanceService.Confirmation(purpose,code,name,value.determination(),value.amount(),value.currency(),value.businessDate(),value.voucherFileId(),value.reason());
return purpose==ProcurementFeePurpose.INTERNATIONAL_FREIGHT?finance.confirmInternational(order.getCustomerId(),order.getOrderNo(),operator,request)
:finance.confirm(order.getCustomerId(),order.getOrderNo(),operator,request);
}
private record FeeCommand(long feeId,Command command) { }
@Transactional
public Snapshot retry(String customer,String orderNo,String operator,long feeId,Command request) {
var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request);
if(lifecycle.replay(order,"RETRY_FEE",request.idempotencyKey(),identity))return snapshot(order);
version(order,request.version());finance.retry(customer,orderNo,feeId);
lifecycle.complete(order,operator,"RETRY_FEE",request.idempotencyKey(),request.reason(),identity);return snapshot(order);
}
@Transactional
public Snapshot terminate(String customer,String orderNo,String operator,long feeId,Command request) {
var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request);
if(lifecycle.replay(order,"TERMINATE_FEE",request.idempotencyKey(),identity))return snapshot(order);
version(order,request.version());finance.terminateFailed(customer,orderNo,feeId);
// The terminated determination must be explicitly replaced or acknowledged before any further handoff.
if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) {
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId());
if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES");
}
lifecycle.complete(order,operator,"TERMINATE_FEE",request.idempotencyKey(),request.reason(),identity);return snapshot(order);
}
public Snapshot read(String customer,String orderNo){return snapshot(lifecycle.requireOrder(customer,orderNo));}
@Transactional
public Snapshot releaseEstimate(String customer,String orderNo,String operator,long feeId,Command request) {
var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request);
if(lifecycle.replay(order,"RELEASE_ESTIMATE",request.idempotencyKey(),identity))return snapshot(order);
version(order,request.version());finance.releaseEstimate(customer,orderNo,feeId);
lifecycle.complete(order,operator,"RELEASE_ESTIMATE",request.idempotencyKey(),request.reason(),identity);return snapshot(order);
}
public String retryPermission(String customer,String orderNo,long feeId) {
var order=lifecycle.requireOrder(customer,orderNo);
var fee=finance.all(order.getId()).stream().filter(f->f.getId()==feeId).findFirst().orElseThrow(()->bad("费用不属于本采购订单"));
if("FINANCIAL".equals(fee.getConfirmationOrigin()))return "platform:overseas-business:purchase:finance-adjust";
return "platform:overseas-business:purchase:"+switch(ProcurementFeePurpose.valueOf(fee.getPurpose())) {
case SAMPLE_PAYMENT->"buy";
case INTERNATIONAL_ESTIMATE->"estimate";
case ADJUSTMENT,REFUND->"finance-adjust";
default->"charge";
};
}
private Snapshot snapshot(OverseasProcurementOrder order) {
Object at=lifecycle.flow(order.getId()).get("charges_confirmed_at");
return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),at==null?null:((java.sql.Timestamp)at).toLocalDateTime(),
finance.all(order.getId()).stream().map(f->new Fee(f,finance.sourceLinked(f))).toList());
}
}
@@ -0,0 +1,145 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*;
@Service @RequiredArgsConstructor
public class ProcurementControlService {
private final ProcurementLifecycleService lifecycle;
private final ProcurementFinanceService finance;
private final ProcurementWarehouseDirectory warehouses;
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public record Remaining(String kind,Long sourceId,String businessNo,long quantity) { }
public record Snapshot(String orderNo,long version,String status,List<Map<String,Object>> notes,
List<Remaining> remaining,List<Map<String,Object>> closures,List<Map<String,Object>> closureItems) { }
@Transactional
public Snapshot note(String customer,String orderNo,String operator,Note request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"CONTROL_NOTE",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());validateTarget(order,request.targetType(),request.targetId());proof(order,request.voucherFileId());
insertNote(order,operator,request.targetType(),request.targetId(),request.blocking(),request.voucherFileId(),request.reason(),request.clientSummary(),null,null);
lifecycle.complete(order,operator,"CONTROL_NOTE",request.idempotencyKey(),request.reason(),request);return snapshot(order);
}
private record ResolveIdentity(long noteId,Resolve request) { }
@Transactional
public Snapshot resolve(String customer,String orderNo,String operator,long noteId,Resolve request) {
var order=lifecycle.lock(customer,orderNo);var identity=new ResolveIdentity(noteId,request);
if(lifecycle.replay(order,"RESOLVE_CONTROL",request.idempotencyKey(),identity))return snapshot(order);
version(order,request.version());proof(order,request.voucherFileId());
if(jdbc.update("UPDATE ll_procurement_control_note SET resolved_by=?,resolved_at=CURRENT_TIMESTAMP(3),resolution_reason=?,resolution_voucher_id=? WHERE id=? AND order_id=? AND blocking=1 AND resolved_at IS NULL",
operator,request.reason(),request.voucherFileId(),noteId,order.getId())!=1)throw conflict("本单待处理阻断异常不存在或已处理");
lifecycle.complete(order,operator,"RESOLVE_CONTROL",request.idempotencyKey(),request.reason(),identity);return snapshot(order);
}
@Transactional
public Snapshot correctWarehouse(String customer,String orderNo,String operator,WarehouseCorrection request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());proof(order,request.voucherFileId());var flow=lifecycle.flow(order.getId());
if(flow.get("actual_warehouse_id")==null)throw conflict("尚无实际收货仓,请修改计划仓");
var warehouse=warehouses.required(request.warehouseId());
long old=((Number)flow.get("actual_warehouse_id")).longValue();if(old==request.warehouseId())throw bad("更正仓库与原仓库相同");
Integer downstream=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_carton WHERE order_id=?",Integer.class,order.getId());
boolean appendOnly=downstream>0 || flow.get("overseas_shipped_at")!=null || Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED").contains(order.getStatus());
var before=new LinkedHashMap<String,Object>();before.put("warehouseId",old);before.put("code",flow.get("actual_warehouse_code"));before.put("name",flow.get("actual_warehouse_name"));
insertNote(order,operator,"WAREHOUSE",old,appendOnly,request.voucherFileId(),request.reason(),null,before,warehouse);
if(!appendOnly)jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?",warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId());
lifecycle.complete(order,operator,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request.reason(),request);return snapshot(order);
}
@Transactional
public Snapshot close(String customer,String orderNo,String operator,Close request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"EXCEPTION_CLOSE",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());open(order);lifecycle.requireNoBlockers(order);
if(lifecycle.flow(order.getId()).get("purchased_at")==null)throw conflict("购买前请取消订单,不使用购买后异常关闭");
proof(order,request.customerVoucherFileId());finance.requireShipmentReady(order.getId());
Map<String,Remaining> expected=new LinkedHashMap<>();for(var source:remaining(order))expected.put(key(source.kind(),source.sourceId()),source);
if(request.resolutions()==null)throw bad("请逐来源核对剩余货物去向");
for(var resolution:request.resolutions()) {
var source=expected.remove(key(resolution.kind(),resolution.sourceId()));
if(source==null || source.quantity()!=resolution.quantity())throw bad("最终处置必须恰好覆盖本单每份剩余来源数量,不能重复或跨单关联");
if(!decisions(resolution.kind()).contains(resolution.decision()))throw bad("处置方式与货物来源不匹配");
if(resolution.destination()==null || resolution.destination().isBlank())throw bad("请填写货物实际去向及接收方");
proof(order,resolution.voucherFileId());
}
if(!expected.isEmpty())throw conflict("仍有货物去向未明确,不能关闭");
if(request.financialConclusion()==null || request.financialConclusion().isBlank() || request.clientSummary()==null || request.clientSummary().isBlank())throw bad("请填写财务处理结论及对客关闭摘要");
long id=IdWorker.getId();
jdbc.update("INSERT INTO ll_procurement_closure(id,order_id,customer_id,closure_no,customer_voucher_file_id,financial_conclusion,reason,client_summary,created_by) VALUES(?,?,?,?,?,?,?,?,?)",
id,order.getId(),customer,number("PX"),request.customerVoucherFileId(),request.financialConclusion(),request.reason(),request.clientSummary(),operator);
for(var r:request.resolutions())jdbc.update("INSERT INTO ll_procurement_closure_item(id,closure_id,source_kind,source_id,quantity,decision,destination,voucher_file_id) VALUES(?,?,?,?,?,?,?,?)",
IdWorker.getId(),id,r.kind(),r.sourceId(),r.quantity(),r.decision(),r.destination(),r.voucherFileId());
jdbc.update("UPDATE ll_procurement_flow SET closed_at=CURRENT_TIMESTAMP(3),control_reason=? WHERE order_id=?",request.reason(),order.getId());
order.setStatus("EXCEPTION_CLOSED");lifecycle.complete(order,operator,"EXCEPTION_CLOSE",request.idempotencyKey(),request.reason(),request);return snapshot(order);
}
List<Remaining> remaining(OverseasProcurementOrder order) {
var flow=lifecycle.flow(order.getId());if(flow.get("purchased_at")==null || flow.get("closed_at")!=null || flow.get("customer_shipped_at")!=null)return List.of();
var result=new ArrayList<Remaining>();
var receipts=jdbc.queryForList("SELECT * FROM ll_procurement_receipt WHERE order_id=? AND active=1 ORDER BY id",order.getId());
long overseasReceived=0;
for(var row:receipts) {
long receiptId=((Number)row.get("id")).longValue();String leg=(String)row.get("leg");String no=(String)row.get("receipt_no");
long good=((Number)row.get("good_quantity")).longValue(),damage=((Number)row.get("damaged_quantity")).longValue(),extra=((Number)row.get("extra_quantity")).longValue();
if("OVERSEAS".equals(leg))overseasReceived=Math.addExact(overseasReceived,Math.addExact(good,damage));
if(!"OVERSEAS".equals(leg) || flow.get("overseas_shipped_at")==null)add(result,"GOOD",receiptId,no,good);
add(result,"DAMAGED",receiptId,no,damage-resolved(order.getId(),leg,"DAMAGED",receiptId,null));
add(result,"EXTRA",receiptId,no,extra-resolved(order.getId(),leg,"EXTRA",receiptId,null));
}
add(result,"PURCHASE_REMAINDER",null,order.getOrderNo(),((Number)flow.get("purchased_quantity")).longValue()-overseasReceived-resolved(order.getId(),"OVERSEAS","SHORTAGE",null,null));
if(flow.get("overseas_shipped_at")!=null) {
var shipped=jdbc.queryForList("SELECT i.carton_id,i.quantity,c.carton_no FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id JOIN ll_procurement_carton c ON c.id=i.carton_id WHERE s.order_id=? AND s.leg='OVERSEAS'",order.getId());
for(var row:shipped) {
long carton=((Number)row.get("carton_id")).longValue();
long received=receipts.stream().filter(r->"DOMESTIC".equals(r.get("leg")) && ((Number)r.get("source_carton_id")).longValue()==carton)
.mapToLong(r->Math.addExact(((Number)r.get("good_quantity")).longValue(),((Number)r.get("damaged_quantity")).longValue())).reduce(0,Math::addExact);
add(result,"TRANSIT_REMAINDER",carton,(String)row.get("carton_no"),((Number)row.get("quantity")).longValue()-received-resolved(order.getId(),"DOMESTIC","SHORTAGE",null,carton));
}
}
return result;
}
private long resolved(long orderId,String leg,String kind,Long receipt,Long carton) {
if(receipt!=null)return jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND receipt_id=? AND active=1",Long.class,orderId,leg,kind,receipt);
return carton==null?jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND source_carton_id IS NULL AND active=1",Long.class,orderId,leg,kind)
:jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND source_carton_id=? AND active=1",Long.class,orderId,leg,kind,carton);
}
private void validateTarget(OverseasProcurementOrder order,String type,Long id) {
if("PURCHASE".equals(type)) {if(id!=null || lifecycle.flow(order.getId()).get("purchased_at")==null)throw bad("购买说明须关联本单已记录购买");return;}
if("WAREHOUSE".equals(type)) {Object warehouse=lifecycle.flow(order.getId()).get("actual_warehouse_id");if(id==null || !(warehouse instanceof Number n) || n.longValue()!=id)throw bad("仓库说明须关联本单实际仓");return;}
String table=switch(type){case "RECEIPT"->"ll_procurement_receipt";case "SHIPMENT"->"ll_procurement_shipment";default->throw bad("说明对象类型不正确");};
if(id==null || jdbc.queryForObject("SELECT COUNT(*) FROM "+table+" WHERE order_id=? AND id=?",Integer.class,order.getId(),id)!=1)throw bad("说明对象不属于本采购订单");
}
private void insertNote(OverseasProcurementOrder order,String operator,String type,Long target,boolean blocking,long voucher,String reason,String clientSummary,Object original,Object corrected) {
jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,target_id,blocking,voucher_file_id,reason,client_summary,original_value_json,corrected_value_json,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)",
IdWorker.getId(),order.getId(),order.getCustomerId(),number("PN"),type,target,blocking,voucher,reason,clientSummary,serialize(original),serialize(corrected),operator);
}
private void proof(OverseasProcurementOrder order,long id){lifecycle.requireFile(order,id,Set.of("EXCEPTION"));}
private static String key(String kind,Long id){return kind+":"+id;}
private static void add(List<Remaining> result,String kind,Long source,String no,long quantity){if(quantity<0)throw conflict("货物来源与处置数量不一致,不能关闭");if(quantity>0)result.add(new Remaining(kind,source,no,quantity));}
private static Set<String> decisions(String kind){return switch(kind){case "PURCHASE_REMAINDER"->Set.of("SUPPLIER_RETAINED","RETURNED_TO_SUPPLIER","LOSS_CONFIRMED");case "TRANSIT_REMAINDER"->Set.of("LOSS_CONFIRMED","RETURNED_TO_SENDER");case "GOOD"->Set.of("RETURNED_TO_SUPPLIER","CUSTOMER_AUTHORIZED_RETENTION","DISPOSED");case "DAMAGED"->Set.of("DISPOSED","RETURNED_TO_SUPPLIER");case "EXTRA"->Set.of("RETURNED_TO_SUPPLIER");default->Set.of();};}
@Transactional(readOnly=true)
public Snapshot read(String customer,String orderNo){return snapshot(lifecycle.requireOrder(customer,orderNo));}
private Snapshot snapshot(OverseasProcurementOrder order){return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),jdbc.queryForList("SELECT * FROM ll_procurement_control_note WHERE order_id=? ORDER BY id",order.getId()).stream().map(this::noteView).toList(),remaining(order),jdbc.queryForList("SELECT * FROM ll_procurement_closure WHERE order_id=?",order.getId()),jdbc.queryForList("SELECT i.* FROM ll_procurement_closure c JOIN ll_procurement_closure_item i ON i.closure_id=c.id WHERE c.order_id=? ORDER BY i.id",order.getId()));}
private Map<String,Object> noteView(Map<String,Object> row) {
var result=new LinkedHashMap<>(row);
for(String field:List.of("original_value_json","corrected_value_json")) {
Object value=row.get(field);
if(value!=null)try {
var node=value instanceof byte[] bytes?json.readTree(bytes):json.readTree(String.valueOf(value));
if(node.isNull()){result.put(field,null);continue;}
if(!node.isObject())throw new IllegalStateException("采购事实更正快照必须为对象");
result.put(field,node);
}catch(java.io.IOException exception){throw new IllegalStateException("采购事实更正快照读取失败",exception);}
}
return result;
}
private String serialize(Object value){if(value==null)return null;try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.procurement.service;
/** Fee semantics are fixed by the business node, never inferred from editable display names. */
public enum ProcurementFeePurpose {
SAMPLE_PAYMENT("样品货款"), INTERNATIONAL_ESTIMATE("预估国际运费"),
INTERNATIONAL_FREIGHT("实际国际运费"), DUTY("关税"), DOMESTIC_FREIGHT("国内运费"),
OTHER("其他费用"), ADJUSTMENT("追加费用"), REFUND("退款");
private final String label;
ProcurementFeePurpose(String label) { this.label = label; }
public String label() { return label; }
public boolean estimate() { return this == INTERNATIONAL_ESTIMATE; }
public boolean immediateCharge() { return this != INTERNATIONAL_ESTIMATE && this != REFUND; }
}
@@ -0,0 +1,532 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.common.event.WalletSettlementReference;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.wallet.WalletResultHandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Procurement-specific orchestration. Called by authorized fulfillment commands while the order is locked. */
@Service
@RequiredArgsConstructor
public class ProcurementFinanceService implements WalletResultHandler {
private final OverseasProcurementFeeMapper fees;
private final OverseasProcurementFileMapper files;
private final OverseasProcurementOrderMapper orders;
private final OverseasFeeCurrencyConverter converter;
private final OverseasFeeSourceService sources;
private final WalletCommandOutboxService commands;
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public record Confirmation(ProcurementFeePurpose purpose, String feeItemCode, String feeItemName,
String determination, BigDecimal originalAmount, String originalCurrency,
LocalDate businessDate, Long voucherFileId, String reason) { }
@Transactional
public OverseasProcurementFee confirm(String customerId, String orderNo, String operatorId, Confirmation request) {
OverseasProcurementOrder order = lockOrder(customerId, orderNo);
return confirmLocked(order, operatorId, request, null);
}
/** The estimate remains its own immutable fact; this actual fee consumes that same reservation. */
@Transactional
public OverseasProcurementFee confirmInternational(String customerId, String orderNo, String operatorId,
Confirmation request) {
OverseasProcurementOrder order = lockOrder(customerId, orderNo);
if (request.purpose() != ProcurementFeePurpose.INTERNATIONAL_FREIGHT) throw bad("实际国际运费用途不正确");
var existing = current(order.getId(), ProcurementFeePurpose.INTERNATIONAL_FREIGHT, "INTERNATIONAL_FREIGHT");
if (existing != null) {
if (!Objects.equals(existing.getRequestHash(), hash(request))) throw conflict("国际运费已确认,请通过受控更正处理");
return existing;
}
OverseasProcurementFee estimate = current(order.getId(), ProcurementFeePurpose.INTERNATIONAL_ESTIMATE, "INTERNATIONAL_ESTIMATE");
if (estimate == null || !Set.of("FROZEN", "ZERO", "RELEASED").contains(estimate.getWalletStatus())) {
throw conflict("国际运费预估尚未完成冻结或零元确认");
}
return confirmLocked(order, operatorId, request, estimate);
}
private OverseasProcurementFee confirmLocked(OverseasProcurementOrder order, String operatorId,
Confirmation request, OverseasProcurementFee estimate) {
validate(request);
if (request.purpose() == ProcurementFeePurpose.REFUND || request.purpose() == ProcurementFeePurpose.ADJUSTMENT) {
throw bad("财务调整必须关联原费用,不得使用普通核费入口");
}
if (request.purpose() == ProcurementFeePurpose.INTERNATIONAL_FREIGHT && estimate == null) {
throw bad("实际国际运费必须核对原预估");
}
String code = request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemCode().trim() : request.purpose().name();
String requestHash = hash(request);
OverseasProcurementFee existing = current(order.getId(), request.purpose(), code);
if (existing != null) {
if (!Objects.equals(existing.getRequestHash(), requestHash)) throw conflict("费用已确认,请通过受控更正处理");
return existing;
}
if(!request.purpose().estimate()) {
Integer previous=jdbc.queryForObject("SELECT COUNT(*) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?",Integer.class,order.getId(),request.purpose().name(),code);
if(previous!=null && previous>0)throw conflict("该实际费用已有历史版本,请使用财务权限重新核定已终止费用");
}
return createFee(order,operatorId,request,estimate,converter.convert(request.originalCurrency(),request.originalAmount(),request.businessDate()),"BUSINESS");
}
// Both Lastleg and Finance fee sources store DECIMAL(20,6), before any wallet command.
static void requireSourceCapacity(BigDecimal original,BigDecimal settlement) {
if(settlement.precision()-settlement.scale()>14)throw bad("折算人民币金额超出支持范围");
if(original.precision()-original.scale()>14)throw bad("原币金额超出费用来源支持范围");
}
private OverseasProcurementFee createFee(OverseasProcurementOrder order,String operatorId,Confirmation request,
OverseasProcurementFee estimate,OverseasFeeCurrencyConverter.Conversion conversion,String origin) {
validate(request);
String code=request.purpose()==ProcurementFeePurpose.OTHER?request.feeItemCode().trim():request.purpose().name();
String requestHash=hash(request);
boolean positive="POSITIVE".equals(request.determination());
requireSourceCapacity(conversion.originalAmount(),conversion.settlementAmount());
if (positive && conversion.settlementAmount().signum() <= 0) throw bad("折算后的实际金额必须大于零");
OverseasProcurementFile voucher = voucher(order, request, positive);
Integer maxVersion = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?",
Integer.class, order.getId(), request.purpose().name(), code);
LocalDateTime now = LocalDateTime.now();
OverseasProcurementFee fee = new OverseasProcurementFee();
fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(order.getCustomerId());
fee.setFeeNo("PF-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase());
fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1);
fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code);
fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label());
fee.setOriginalCurrency(conversion.originalCurrency()); fee.setOriginalAmount(conversion.originalAmount());
fee.setExchangeRate(conversion.exchangeRate()); fee.setExchangeRateMonth(conversion.exchangeRateMonth());
fee.setAmount(conversion.settlementAmount()); fee.setCurrency("CNY"); fee.setBusinessDate(request.businessDate());
fee.setWalletBusinessId(estimate != null && "FROZEN".equals(estimate.getWalletStatus()) ? estimate.getId() : fee.getId());
fee.setWalletBusinessNo(estimate != null && "FROZEN".equals(estimate.getWalletStatus()) ? estimate.getFeeNo() : fee.getFeeNo());
fee.setParentFeeId(estimate == null ? null : estimate.getId());
fee.setVoucherFileId(voucher == null ? null : voucher.getFileId());
fee.setVoucherFileName(voucher == null ? null : voucher.getFileName());
fee.setRemark(request.reason()); fee.setRequestHash(requestHash); fee.setCreatedBy(operatorId);
fee.setCreatedAt(now); fee.setUpdatedAt(now);
fee.setWalletStatus(positive ? "PENDING" : request.determination());
fees.insert(fee);
if (estimate != null && "FROZEN".equals(estimate.getWalletStatus())) {
fee.setWalletHoldId(estimate.getWalletHoldId());
issue(fee, positive ? "SETTLE_ACTUAL" : "RELEASE", positive ? fee.getAmount() : null);
} else if (positive) {
issue(fee, "FREEZE", fee.getAmount());
}
return fee;
}
/** Only the latest definitively terminated, never-consumed actual version can be replaced. */
public OverseasProcurementFee requireFailedReplacement(OverseasProcurementOrder order,long feeId) {
var original=required(order,feeId);
if(Set.of("INTERNATIONAL_ESTIMATE","ADJUSTMENT","REFUND").contains(original.getPurpose())
|| Boolean.TRUE.equals(original.getActive()) || !Set.of("TERMINATED","RELEASED").contains(original.getWalletStatus())
|| original.getSettledCommandEventId()!=null || original.getSourceFeeId()!=null)throw conflict("须先终止未成功实扣的原实际费用,并完成有效冻结释放");
var attempts=jdbc.queryForList("SELECT operation,status FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC",feeId);
if(attempts.isEmpty())throw conflict("原费用缺少可核验的失败及终止记录");
var last=attempts.get(0);
boolean terminated="TERMINATED".equals(original.getWalletStatus()) && "FREEZE".equals(last.get("operation")) && "FAILED".equals(last.get("status"));
boolean released="RELEASED".equals(original.getWalletStatus()) && "RELEASE".equals(last.get("operation")) && "SUCCEEDED".equals(last.get("status"));
if((!terminated && !released) || attempts.stream().anyMatch(row->Set.of("SETTLE","SETTLE_ACTUAL").contains(row.get("operation")) && "SUCCEEDED".equals(row.get("status"))))throw conflict("原费用未明确结束或已经实扣,不能创建替代费用");
Integer pending=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND business_no=? AND status='PENDING'",Integer.class,order.getId(),original.getWalletBusinessNo());
if(pending!=null && pending>0)throw conflict("原费用有关资金请求仍未决");
Integer latest=jdbc.queryForObject("SELECT MAX(fee_version) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?",Integer.class,order.getId(),original.getPurpose(),original.getFeeItemCode());
if(!Objects.equals(latest,original.getFeeVersion()) || current(order.getId(),ProcurementFeePurpose.valueOf(original.getPurpose()),original.getFeeItemCode())!=null)throw conflict("已有后续费用版本,请处理最新版本,不能重复替代旧费用");
if(original.getExchangeRate()==null || original.getExchangeRate().signum()<=0 || original.getExchangeRateMonth()==null || original.getBusinessDate()==null)throw conflict("原费用汇率或业务日期快照缺失");
return original;
}
@Transactional
public OverseasProcurementFee replaceFailed(String customer,String orderNo,String operator,long originalFeeId,BigDecimal target,long voucherId,String reason) {
var order=lockOrder(customer,orderNo);var original=requireFailedReplacement(order,originalFeeId);
if(target==null || target.signum()<0 || target.scale()>2)throw bad("替代费用原币总额须为非负两位小数");
var purpose=ProcurementFeePurpose.valueOf(original.getPurpose());
var request=new Confirmation(purpose,original.getFeeItemCode(),original.getFeeItemName(),target.signum()>0?"POSITIVE":"ZERO",target,
original.getOriginalCurrency(),original.getBusinessDate(),voucherId,reason);
var conversion=new OverseasFeeCurrencyConverter.Conversion(original.getOriginalCurrency(),target,original.getExchangeRate(),original.getExchangeRateMonth(),target.multiply(original.getExchangeRate()).setScale(2,java.math.RoundingMode.HALF_UP));
// The original reservation has already ended; the replacement owns a new wallet business identity.
var replacement=createFee(order,operator,request,null,conversion,"FINANCIAL");
replacement.setParentFeeId(original.getParentFeeId());fees.updateById(replacement);
return replacement;
}
@Transactional
public OverseasProcurementFee confirmAdditional(String customer,String orderNo,String operator,Confirmation request) {
var order=lockOrder(customer,orderNo);validate(request);
if(request.purpose()!=ProcurementFeePurpose.OTHER || !"POSITIVE".equals(request.determination()))throw bad("独立新增费用须为正额其他费用");
Integer previous=jdbc.queryForObject("SELECT COUNT(*) FROM ll_overseas_procurement_fee WHERE order_id=? AND fee_item_code=?",Integer.class,order.getId(),request.feeItemCode());
if(previous!=null && previous>0)throw conflict("独立费用编号已使用,不能重复新增");
return createFee(order,operator,request,null,converter.convert(request.originalCurrency(),request.originalAmount(),request.businessDate()),"FINANCIAL");
}
public OverseasProcurementFee current(long orderId, ProcurementFeePurpose purpose, String code) {
return fees.selectOne(new LambdaQueryWrapper<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getOrderId, orderId).eq(OverseasProcurementFee::getPurpose, purpose.name())
.eq(OverseasProcurementFee::getFeeItemCode, code).eq(OverseasProcurementFee::getActive, true));
}
public List<OverseasProcurementFee> all(long orderId) {
return fees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>().eq(OverseasProcurementFee::getOrderId,orderId).orderByAsc(OverseasProcurementFee::getId));
}
public OverseasProcurementFee required(OverseasProcurementOrder order,long feeId) {
var fee=fees.lock(feeId,order.getId(),order.getCustomerId());
if(fee==null)throw bad("费用不属于本采购订单");return fee;
}
@Transactional
public OverseasProcurementFee refund(String customerId, String orderNo, String operatorId, long originalFeeId,
BigDecimal amount, long voucherFileId, String reason, String idempotencyKey) {
var order = lockOrder(customerId, orderNo);
return refundLocked(order,operatorId,originalFeeId,amount,voucherFileId,reason,idempotencyKey,true);
}
private OverseasProcurementFee refundLocked(OverseasProcurementOrder order,String operatorId,long originalFeeId,
BigDecimal amount,long voucherFileId,String reason,String idempotencyKey,boolean checkChain) {
String customerId=order.getCustomerId(),orderNo=order.getOrderNo();
if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128) throw bad("退款幂等键无效");
var request = new Confirmation(ProcurementFeePurpose.REFUND, "REFUND", "退款", "POSITIVE", amount,
"CNY", LocalDate.now(), voucherFileId, reason);
validate(request);
// Identity is independent of wall-clock time, so retrying after midnight returns the same refund.
String number = "RF-" + UUID.nameUUIDFromBytes((customerId + ':' + orderNo + ':' + idempotencyKey)
.getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString().replace("-", "").toUpperCase();
String requestHash = hash(List.of(originalFeeId, amount, voucherFileId, reason));
var existing = fees.selectOne(new LambdaQueryWrapper<OverseasProcurementFee>().eq(OverseasProcurementFee::getFeeNo, number));
if (existing != null) {
if (!requestHash.equals(existing.getRequestHash())) throw conflict("退款幂等键对应不同原扣款或金额");
return existing;
}
var original = fees.lock(originalFeeId, order.getId(), customerId);
if (original == null || !"SETTLED".equals(original.getWalletStatus()) || original.getWalletLedgerId() == null
|| ProcurementFeePurpose.INTERNATIONAL_ESTIMATE.name().equals(original.getPurpose())) {
throw conflict("退款必须关联本单已成功实扣的实际费用");
}
if(checkChain)readyChain(order,original);
List<OverseasProcurementFee> refunds = fees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getParentFeeId, originalFeeId).eq(OverseasProcurementFee::getPurpose, "REFUND"));
if (refunds.stream().anyMatch(f -> !Set.of("REFUNDED", "TERMINATED").contains(f.getWalletStatus()))) {
throw conflict("原费用存在未完成退款,须先处理原请求");
}
BigDecimal already = refunds.stream().filter(f -> "REFUNDED".equals(f.getWalletStatus()))
.map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (already.add(amount).compareTo(original.getAmount()) > 0) throw bad("退款超过原扣款可退余额");
var voucher = voucher(order, request, true);
var fee = new OverseasProcurementFee();
fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(customerId); fee.setFeeNo(number);
fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("REFUND"); fee.setFeeItemCode("REFUND"); fee.setFeeItemName("关联费用退款");
Integer last = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='REFUND'", Integer.class, order.getId());
fee.setFeeVersion(last == null ? 1 : last + 1); fee.setActive(true); fee.setDetermination("POSITIVE");
fee.setAmount(amount); fee.setCurrency("CNY"); fee.setOriginalAmount(amount); fee.setOriginalCurrency("CNY");
fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth(request.businessDate().toString().substring(0, 7));
fee.setBusinessDate(request.businessDate()); fee.setParentFeeId(originalFeeId); fee.setWalletBusinessId(fee.getId());
fee.setWalletBusinessNo(number); fee.setVoucherFileId(voucher.getFileId()); fee.setVoucherFileName(voucher.getFileName());
fee.setRemark(reason); fee.setRequestHash(requestHash); fee.setCreatedBy(operatorId); fee.setWalletStatus("PENDING");
fee.setCreatedAt(LocalDateTime.now()); fee.setUpdatedAt(fee.getCreatedAt()); fees.insert(fee);
issue(fee, "REFUND", amount);
return fee;
}
/** Includes the base fee, all positive corrections and refunds against each actual consumption. */
public List<OverseasProcurementFee> readyChain(OverseasProcurementOrder order,OverseasProcurementFee selected) {
var root="ADJUSTMENT".equals(selected.getPurpose())?required(order,selected.getParentFeeId()):selected;
if(Set.of("REFUND","ADJUSTMENT","INTERNATIONAL_ESTIMATE").contains(root.getPurpose())
|| !Boolean.TRUE.equals(root.getActive()) || !sourceLinked(root))throw conflict("原费用尚未实扣及关联完成,不能更正费用链");
var all=all(order.getId());
Set<Long> originals=new java.util.HashSet<>();originals.add(root.getId());
all.stream().filter(f->"ADJUSTMENT".equals(f.getPurpose()) && Objects.equals(f.getParentFeeId(),root.getId())).forEach(f->originals.add(f.getId()));
var chain=all.stream().filter(f->originals.contains(f.getId()) || ("REFUND".equals(f.getPurpose()) && originals.contains(f.getParentFeeId()))).toList();
if(chain.stream().anyMatch(f->Boolean.TRUE.equals(f.getActive()) && !sourceLinked(f)))throw conflict("原费用链存在未完成的调整或退款,须先完成或终止旧请求");
return chain;
}
/** The correction target is converted with the original rate; this new charge is only the exact CNY difference. */
@Transactional
public OverseasProcurementFee supplement(String customer,String orderNo,String operator,long rootId,BigDecimal delta,long voucherId,String reason,String correctionNo) {
var order=lockOrder(customer,orderNo);var root=required(order,rootId);readyChain(order,root);
if(delta==null || delta.signum()<=0 || delta.scale()>2)throw bad("追加费用仅允许正额CNY差额");
requireSourceCapacity(delta,delta);
var proof=voucher(order,new Confirmation(ProcurementFeePurpose.ADJUSTMENT,"ADJUSTMENT","追加费用","POSITIVE",delta,"CNY",root.getBusinessDate(),voucherId,reason),true);
var fee=new OverseasProcurementFee();fee.setId(IdWorker.getId());fee.setOrderId(order.getId());fee.setCustomerId(customer);
fee.setFeeNo("PF-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase());fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT");
fee.setFeeItemCode("ADJ_"+root.getId());fee.setFeeItemName("原费用正额调整");
fee.setFeeVersion(jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0)+1 FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='ADJUSTMENT' AND fee_item_code=?",Integer.class,order.getId(),fee.getFeeItemCode()));
fee.setActive(true);fee.setDetermination("POSITIVE");fee.setAmount(delta);fee.setCurrency("CNY");fee.setOriginalCurrency("CNY");fee.setOriginalAmount(delta);
fee.setExchangeRate(BigDecimal.ONE.setScale(6));fee.setExchangeRateMonth(root.getExchangeRateMonth());fee.setBusinessDate(root.getBusinessDate());
fee.setWalletBusinessId(fee.getId());fee.setWalletBusinessNo(fee.getFeeNo());fee.setParentFeeId(rootId);fee.setVoucherFileId(proof.getFileId());fee.setVoucherFileName(proof.getFileName());
fee.setRemark(reason);fee.setRequestHash(hash(List.of(correctionNo,rootId,delta,voucherId,reason)));fee.setCreatedBy(operator);fee.setCreatedAt(LocalDateTime.now());fee.setUpdatedAt(fee.getCreatedAt());fee.setWalletStatus("PENDING");
fees.insert(fee);issue(fee,"FREEZE",delta);return fee;
}
@Transactional
public List<OverseasProcurementFee> reduceChain(String customer,String orderNo,String operator,long rootId,BigDecimal amount,long voucherId,String reason,String correctionNo) {
var order=lockOrder(customer,orderNo);var root=required(order,rootId);var chain=readyChain(order,root);
if(amount==null || amount.signum()<=0 || amount.scale()>2)throw bad("减少费用须填写正额CNY退款差额");
BigDecimal remaining=amount;var created=new java.util.ArrayList<OverseasProcurementFee>();
for(var original:chain) {
if(!"SETTLED".equals(original.getWalletStatus()) || "REFUND".equals(original.getPurpose()))continue;
BigDecimal returned=chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus()) && Objects.equals(f.getParentFeeId(),original.getId())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal available=original.getAmount().subtract(returned),part=available.min(remaining);
if(part.signum()>0) {
created.add(refundLocked(order,operator,original.getId(),part,voucherId,reason,correctionNo+":"+original.getId(),false));remaining=remaining.subtract(part);
}
if(remaining.signum()==0)break;
}
if(remaining.signum()>0)throw bad("减少金额超过费用链净实扣");return created;
}
/** Called only after Finance has reported a definite failure. Unknown outcomes keep their original request. */
@Transactional
public OverseasProcurementFee retry(String customerId, String orderNo, long feeId) {
OverseasProcurementOrder order = lockOrder(customerId, orderNo);
OverseasProcurementFee fee = fees.lock(feeId, order.getId(), customerId);
if (fee == null || !Boolean.TRUE.equals(fee.getActive())) throw bad("有效费用不存在");
List<Map<String, Object>> attempts = jdbc.queryForList("SELECT operation,amount,status FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC LIMIT 1 FOR UPDATE", feeId);
if (attempts.isEmpty() || !"FAILED".equals(attempts.get(0).get("status"))) throw conflict("资金结果未明确失败,不能发起新尝试");
Map<String, Object> last = attempts.get(0);
issue(fee, String.valueOf(last.get("operation")), (BigDecimal) last.get("amount"));
return fee;
}
@Transactional
public void releaseEstimate(String customerId, String orderNo, long feeId) {
var order = lockOrder(customerId, orderNo);
var fee = fees.lock(feeId, order.getId(), customerId);
if (fee == null || !Boolean.TRUE.equals(fee.getActive()) || !"INTERNATIONAL_ESTIMATE".equals(fee.getPurpose())) {
throw bad("当前预估费用不存在");
}
Integer pending = jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND business_no=? AND status='PENDING'",
Integer.class, order.getId(), fee.getWalletBusinessNo());
if (pending != null && pending > 0) throw conflict("冻结有关资金请求未决,不能释放");
if ("ZERO".equals(fee.getWalletStatus())) {
fee.setActive(false); fee.setWalletStatus("RELEASED"); fees.updateById(fee);
} else if ("FROZEN".equals(fee.getWalletStatus())) {
issue(fee, "RELEASE", null);
} else {
throw conflict("预估不处于可释放状态");
}
}
@Transactional
public void terminateFailed(String customerId, String orderNo, long feeId) {
var order = lockOrder(customerId, orderNo);
var fee = fees.lock(feeId, order.getId(), customerId);
if (fee == null || !Boolean.TRUE.equals(fee.getActive())) throw bad("有效费用不存在");
var attempts = jdbc.queryForList("SELECT status,operation FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC LIMIT 1 FOR UPDATE", feeId);
if (attempts.isEmpty() || !"FAILED".equals(attempts.get(0).get("status"))) throw conflict("只能终止已明确失败的资金版本");
String operation = String.valueOf(attempts.get(0).get("operation"));
if (Set.of("FREEZE", "REFUND").contains(operation)) {
fee.setActive(false); fee.setWalletStatus("TERMINATED"); fees.updateById(fee);
} else {
issue(fee, "RELEASE", null);
}
}
private void issue(OverseasProcurementFee fee, String operation, BigDecimal amount) {
Long previous = jdbc.queryForObject("SELECT COALESCE(MAX(attempt_no),0) FROM ll_procurement_wallet_attempt WHERE fee_id=?", Long.class, fee.getId());
long attempt = previous == null ? 1 : previous + 1;
// A shared reservation can be settled by a different fee version, so the aggregate key includes the fee identity.
long attemptId = IdWorker.getId();
long aggregateVersion = attemptId;
String event;
if ("REFUND".equals(operation)) {
var original = fees.lock(fee.getParentFeeId(), fee.getOrderId(), fee.getCustomerId());
if (original == null || original.getWalletLedgerId() == null) throw conflict("原退款扣款引用缺失");
event = commands.publishRefund(Long.parseLong(fee.getCustomerId()), "PROCUREMENT_FEE", fee.getWalletBusinessNo(),
amount, original.getWalletLedgerId(), fee.getRemark(), aggregateVersion);
} else {
event = commands.publish(operation, Long.parseLong(fee.getCustomerId()), "PROCUREMENT_FEE", fee.getWalletBusinessNo(),
amount, fee.getFeeItemName() + ":" + fee.getFeeNo(), aggregateVersion);
}
jdbc.update("INSERT INTO ll_procurement_wallet_attempt(id,order_id,customer_id,fee_id,attempt_no,operation,business_no,amount,command_event_id,status) VALUES(?,?,?,?,?,?,?,?,?,'PENDING')",
attemptId, fee.getOrderId(), fee.getCustomerId(), fee.getId(), attempt, operation, fee.getWalletBusinessNo(), amount, event);
fee.setWalletStatus(operation + "_PENDING"); fee.setWalletCommandEventId(event); fee.setUpdatedAt(LocalDateTime.now());
fees.updateById(fee);
}
@Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); }
@Override
@Transactional
public void handle(WalletCommandCompletedV1 result) {
List<Map<String, Object>> found = jdbc.queryForList("SELECT order_id,fee_id FROM ll_procurement_wallet_attempt WHERE command_event_id=? AND customer_id=?",
result.commandEventId(), String.valueOf(result.customerId()));
if (found.size() != 1) throw conflict("资金结果不属于已登记的采购请求");
long orderId = ((Number) found.get(0).get("order_id")).longValue();
var order = orders.selectById(orderId);
if (order == null) throw bad("采购订单不存在");
order = lockOrder(String.valueOf(result.customerId()), order.getOrderNo());
long feeId = ((Number) found.get(0).get("fee_id")).longValue();
var fee = fees.lock(feeId, orderId, String.valueOf(result.customerId()));
if (fee == null) throw conflict("资金结果对应的采购费用不存在");
Map<String, Object> attempt = jdbc.queryForMap("SELECT * FROM ll_procurement_wallet_attempt WHERE command_event_id=? FOR UPDATE", result.commandEventId());
String resultHash = hash(result);
if (!"PENDING".equals(attempt.get("status"))) {
if (!resultHash.equals(attempt.get("result_hash"))) throw conflict("同一资金请求存在不同最终结果");
return;
}
if (!result.operation().equals(attempt.get("operation")) || !result.businessNo().equals(attempt.get("business_no"))
|| !Set.of("SUCCEEDED", "FAILED").contains(result.result())) throw conflict("资金结果与原请求不匹配");
boolean success = "SUCCEEDED".equals(result.result());
if (success) {
if (result.walletLedgerId() == null || (!"REFUND".equals(result.operation()) && result.walletHoldId() == null)) throw conflict("成功资金结果缺少权威引用");
String expectedStatus = switch (result.operation()) {
case "FREEZE" -> "FROZEN";
case "SETTLE", "SETTLE_ACTUAL" -> "SETTLED";
case "RELEASE" -> "RELEASED";
case "REFUND" -> "REFUNDED";
default -> throw conflict("采购资金操作不受支持");
};
if (!expectedStatus.equals(result.holdStatus())) throw conflict("资金结果状态与操作不匹配");
BigDecimal expected = (BigDecimal) attempt.get("amount");
if ("SETTLE".equals(result.operation())) expected = fee.getAmount();
if (expected != null && (result.amount() == null || expected.compareTo(result.amount()) != 0)) {
throw conflict("资金结果金额与原请求不匹配");
}
}
jdbc.update("UPDATE ll_procurement_wallet_attempt SET status=?,result_hash=?,wallet_hold_id=?,wallet_ledger_id=?,error_code=?,error_message=?,completed_at=CURRENT_TIMESTAMP(3) WHERE command_event_id=? AND status='PENDING'",
result.result(), resultHash, result.walletHoldId(), result.walletLedgerId(), result.errorCode(), result.errorMessage(), result.commandEventId());
// Terminal results always remain on their original attempt. Never apply an old result to a newer fee request.
if (!Objects.equals(fee.getWalletCommandEventId(), result.commandEventId())) return;
fee.setWalletStatus(success ? switch (result.operation()) {
case "FREEZE" -> "FROZEN";
case "SETTLE", "SETTLE_ACTUAL" -> "SETTLED";
case "RELEASE" -> "RELEASED";
case "REFUND" -> "REFUNDED";
default -> throw conflict("采购资金操作不受支持");
} : result.operation() + "_FAILED");
fee.setWalletErrorCode(success ? null : result.errorCode()); fee.setWalletErrorMessage(success ? null : result.errorMessage());
if (success) { fee.setWalletHoldId(result.walletHoldId()); fee.setWalletLedgerId(result.walletLedgerId()); }
fee.setUpdatedAt(LocalDateTime.now()); fees.updateById(fee);
if (!success) return;
if ("RELEASE".equals(result.operation()) && ("INTERNATIONAL_ESTIMATE".equals(fee.getPurpose())
|| "POSITIVE".equals(fee.getDetermination()))) {
fee.setActive(false); fees.updateById(fee);
}
if ("REFUND".equals(result.operation())) {
fee.setSettledCommandEventId(result.commandEventId()); fees.updateById(fee); return;
}
if ("FREEZE".equals(result.operation()) && ProcurementFeePurpose.valueOf(fee.getPurpose()).immediateCharge()) {
issue(fee, "SETTLE", null);
} else if (Set.of("SETTLE", "SETTLE_ACTUAL").contains(result.operation())) {
fee.setSettledCommandEventId(result.commandEventId());
publishSource(order, fee);
fees.updateById(fee);
}
if (fee.getParentFeeId() != null && Set.of("SETTLE_ACTUAL", "RELEASE").contains(result.operation())) {
var estimate = fees.lock(fee.getParentFeeId(), orderId, fee.getCustomerId());
if (estimate != null && ProcurementFeePurpose.INTERNATIONAL_ESTIMATE.name().equals(estimate.getPurpose())) {
estimate.setWalletStatus(fee.getWalletStatus()); estimate.setUpdatedAt(LocalDateTime.now()); fees.updateById(estimate);
}
}
}
private void publishSource(OverseasProcurementOrder order, OverseasProcurementFee fee) {
var request = new OverseasFeeSourceDtos.SaveRequest(fee.getCustomerId(), order.getCustomerName(),
"OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getWalletBusinessId(), fee.getWalletBusinessNo(),
order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), fee.getBusinessDate(), fee.getOriginalCurrency(),
fee.getOriginalAmount(), BigDecimal.ONE, "ITEM", fee.getOriginalAmount(), null, null);
var source = sources.publishSettled(fee.getCreatedBy(), "PROCUREMENT:" + fee.getFeeNo(), request,
new OverseasFeeCurrencyConverter.Conversion(fee.getOriginalCurrency(), fee.getOriginalAmount(), fee.getExchangeRate(),
fee.getExchangeRateMonth(), fee.getAmount()),
new WalletSettlementReference(fee.getSettledCommandEventId(), fee.getWalletHoldId(), fee.getWalletLedgerId()));
fee.setSourceFeeId(source.getSourceFeeId());
}
public boolean sourceLinked(OverseasProcurementFee fee) {
if(fee==null || fee.getWalletStatus()==null)return false;
if (Set.of("ZERO", "NOT_APPLICABLE", "REFUNDED").contains(fee.getWalletStatus())) return true;
if ("RELEASED".equals(fee.getWalletStatus()) && fee.getAmount().signum() == 0) return true;
if (!"SETTLED".equals(fee.getWalletStatus()) || fee.getSourceFeeId() == null) return false;
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox WHERE aggregate_type='OVERSEAS_PROCUREMENT_FEE' AND aggregate_id=? AND status='PUBLISHED'",
Integer.class, String.valueOf(fee.getSourceFeeId()));
return count != null && count > 0;
}
/** Called under the procurement order lock before the final physical handoff or exception closure. */
public void requireShipmentReady(long orderId) {
Integer pending=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND status='PENDING'",Integer.class,orderId);
if(pending!=null && pending>0)throw conflict("采购存在未决资金请求,不能交寄或关闭");
var all=fees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>().eq(OverseasProcurementFee::getOrderId,orderId));
for(var fee:all) {
if("INTERNATIONAL_ESTIMATE".equals(fee.getPurpose())) {
if(!Set.of("ZERO","RELEASED","SETTLED","TERMINATED").contains(fee.getWalletStatus()))throw conflict("预估冻结尚未结清或释放");
} else if(Boolean.TRUE.equals(fee.getActive()) || "SETTLED".equals(fee.getWalletStatus())) {
if(!sourceLinked(fee))throw conflict("仍有费用扣款、退款、调整或费用来源关联尚未完成");
}
}
}
public void requireAdjustmentsComplete(long orderId) {
var adjustments=fees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getOrderId,orderId).eq(OverseasProcurementFee::getActive,true)
.eq(OverseasProcurementFee::getConfirmationOrigin,"FINANCIAL"));
if(adjustments.stream().anyMatch(f->!sourceLinked(f)))throw conflict("存在未完成的财务调整或退款,不能继续交寄");
}
private OverseasProcurementFile voucher(OverseasProcurementOrder order, Confirmation request, boolean positive) {
if (request.voucherFileId() == null) {
if (positive && !request.purpose().estimate()) throw bad("正额实际费用必须关联本单凭证");
return null;
}
var file = files.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>()
.eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, order.getCustomerId())
.eq(OverseasProcurementFile::getFileId, request.voucherFileId()));
if (file == null || !Set.of("VOUCHER", "PURCHASE").contains(file.getCategory())) throw bad("费用凭证不属于当前采购订单或分类不正确");
return file;
}
private static void validate(Confirmation request) {
if (request == null || request.purpose() == null || request.businessDate() == null || request.reason() == null
|| request.reason().isBlank() || request.reason().length() > 500 || request.originalAmount() == null
|| request.originalAmount().signum() < 0 || request.originalAmount().scale() > 2
|| request.originalCurrency() == null || !Set.of("CNY", "USD").contains(request.originalCurrency())
|| request.determination() == null || !Set.of("POSITIVE", "ZERO", "NOT_APPLICABLE").contains(request.determination())) {
throw bad("费用核定信息不完整或精度不正确");
}
if ("POSITIVE".equals(request.determination()) != (request.originalAmount().signum() > 0)) throw bad("费用核定情况与金额不一致");
if (request.purpose().estimate() && !"CNY".equals(request.originalCurrency())) throw bad("预估国际运费仅支持CNY");
if (Set.of(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE, ProcurementFeePurpose.SAMPLE_PAYMENT).contains(request.purpose())
&& "NOT_APPLICABLE".equals(request.determination())) throw bad("货款和预估须明确正额或零元,不能标为不适用");
if (request.purpose() == ProcurementFeePurpose.OTHER && (request.feeItemCode() == null
|| !request.feeItemCode().matches("[A-Z][A-Z0-9_]{0,31}") || request.feeItemName() == null
|| request.feeItemName().isBlank() || request.feeItemName().length() > 128)) throw bad("其他费用必须填写编码和名称");
}
private OverseasProcurementOrder lockOrder(String customerId, String orderNo) {
var order = orders.lockNo(customerId, orderNo);
if (order == null) throw bad("采购订单不存在或不属于当前客户");
return order;
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); }
catch (Exception exception) { throw new IllegalStateException("采购请求摘要生成失败", exception); }
}
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,244 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** One procurement order, one physical batch. Warehouse and wallet remain authoritative outside this aggregate. */
@Service
@RequiredArgsConstructor
public class ProcurementLifecycleService {
private final OverseasProcurementOrderMapper orders;
private final OverseasProcurementFileMapper files;
private final ProcurementWarehouseDirectory warehouses;
private final ProcurementFinanceService finance;
private final OverseasFeeCurrencyConverter converter;
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public record PurchaseStatus(boolean recorded, boolean confirmed, String issue, String feeNo,
String walletStatus, boolean sourceLinked) { }
public record Outcome(String orderNo, long version, String status, PurchaseStatus purchase) { }
@Transactional
public Outcome recordPurchase(String customerId,String orderNo,String operator,Purchase request) {
var order=lock(customerId,orderNo);
if(replay(order,"PURCHASE",request.idempotencyKey(),request)) return outcome(order);
version(order,request.version()); open(order);
ensureFlow(order);
var flow=flow(order.getId());
if(flow.get("purchased_at")!=null) throw conflict("本单已登记购买事实,不能再次购买或覆盖原记录");
if(request.purchasedAt()==null || request.purchasedAt().isAfter(LocalDateTime.now())
|| request.estimatedArrivalAt()==null || request.estimatedArrivalAt().isBefore(request.purchasedAt())
|| request.quantity()<=0 || request.productName()==null || request.productName().isBlank()
|| request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2
|| request.currency()==null || !Set.of("CNY","USD").contains(request.currency())) throw bad("实际购买信息不完整");
requireFile(order,request.voucherFileId(),Set.of("VOUCHER","PURCHASE"));
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=?,purchased_product_name=?,purchased_specification=?,purchased_quantity=?,purchase_amount=?,purchase_currency=?,purchase_voucher_id=? WHERE order_id=?",
request.purchasedAt(),request.productName().trim(),trim(request.specification()),request.quantity(),request.amount(),request.currency(),request.voucherFileId(),order.getId());
order.setEstimatedArrivalAt(request.estimatedArrivalAt());
confirmRecordedPurchase(order,operator);
if(order.getReviewedAt()!=null && !"REJECTED".equals(order.getStatus())) order.setStatus("WAIT_OVERSEAS_RECEIPT");
complete(order,operator,"PURCHASE",request.idempotencyKey(),request.reason(),request);
return outcome(order);
}
@Transactional
public Outcome confirmPurchase(String customerId,String orderNo,String operator,Command request) {
var order=lock(customerId,orderNo);
if(replay(order,"CONFIRM_PURCHASE",request.idempotencyKey(),request)) return outcome(order);
version(order,request.version());open(order);
if(flow(order.getId()).get("purchased_at")==null) throw conflict("请先登记真实购买事实");
confirmRecordedPurchase(order,operator);
if(order.getReviewedAt()!=null && Set.of("PENDING_REVIEW","WAIT_PURCHASE").contains(order.getStatus())) order.setStatus("WAIT_OVERSEAS_RECEIPT");
complete(order,operator,"CONFIRM_PURCHASE",request.idempotencyKey(),request.reason(),request);
return outcome(order);
}
@Transactional
public Outcome authorizeBudget(String customerId,String orderNo,String operator,Budget request) {
var order=lock(customerId,orderNo);
if(replay(order,"AUTHORIZE_BUDGET",request.idempotencyKey(),request)) return outcome(order);
version(order,request.version());open(order);
if(request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2
|| !Set.of("CNY","USD").contains(request.currency())) throw bad("预算金额或币种无效");
if(finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")!=null) {
throw conflict("货款已经核定,不能通过修改预算改变原费用");
}
requireFile(order,request.voucherFileId(),Set.of("VOUCHER","BUDGET"));
var previous=budget(order);
jdbc.update("INSERT INTO ll_procurement_budget_authorization(id,order_id,customer_id,previous_amount,previous_currency,authorized_amount,currency,voucher_file_id,reason,reviewed_by) VALUES(?,?,?,?,?,?,?,?,?,?)",
IdWorker.getId(),order.getId(),customerId,previous.amount(),previous.currency(),request.amount(),request.currency(),request.voucherFileId(),request.reason(),operator);
complete(order,operator,"AUTHORIZE_BUDGET",request.idempotencyKey(),request.reason(),request);
return outcome(order);
}
@Transactional
public Outcome selectWarehouse(String customerId,String orderNo,String operator,Warehouse request) {
var order=lock(customerId,orderNo);
if(replay(order,"PLAN_WAREHOUSE",request.idempotencyKey(),request)) return outcome(order);
version(order,request.version());open(order);ensureFlow(order);
if(flow(order.getId()).get("actual_warehouse_id")!=null) throw conflict("首次实收已冻结实际仓库,请使用事实更正操作");
var warehouse=warehouses.required(request.warehouseId());
order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));order.setPurchaseWarehouseName(warehouse.warehouseName());
complete(order,operator,"PLAN_WAREHOUSE",request.idempotencyKey(),request.reason(),request);
return outcome(order);
}
@Transactional
public Outcome cancel(String customerId,String orderNo,String operator,Command request) {
var order=lock(customerId,orderNo);
if(replay(order,"CANCEL",request.idempotencyKey(),request)) return outcome(order);
version(order,request.version());open(order);ensureFlow(order);
if(flow(order.getId()).get("purchased_at")!=null) throw conflict("购买后不能取消原单,请处理实物及资金后异常关闭");
order.setStatus("CANCELLED");
jdbc.update("UPDATE ll_procurement_flow SET cancelled_at=CURRENT_TIMESTAMP(3),control_reason=? WHERE order_id=?",request.reason(),order.getId());
complete(order,operator,"CANCEL",request.idempotencyKey(),request.reason(),request);
return outcome(order);
}
private void confirmRecordedPurchase(OverseasProcurementOrder order,String operator) {
var flow=flow(order.getId());
if(flow.get("purchase_confirmed_at")!=null) return;
String issue=null;
if(order.getReviewedAt()==null || "REJECTED".equals(order.getStatus())) issue="采购订单尚未审核通过";
if(!Objects.equals(trim(order.getProductName()),flow.get("purchased_product_name"))
|| !Objects.equals(trim(order.getProductSpecification()),flow.get("purchased_specification"))
|| ((Number)flow.get("purchased_quantity")).longValue()!=order.getPurchaseQuantity()) {
issue="实际购买商品、规格或数量与审核内容不一致,须异常处置,不能通过提高预算改写";
}
var budget=budget(order);
if(budget.amount()==null) issue="客户货款预算尚未明确,请取得凭证并完成预算核验";
var businessDate=((Timestamp)flow.get("purchased_at")).toLocalDateTime().toLocalDate();
if(issue==null) {
try {
var payment=converter.convert(String.valueOf(flow.get("purchase_currency")),(BigDecimal)flow.get("purchase_amount"),businessDate);
var limit=converter.convert(budget.currency(),budget.amount(),businessDate);
try { ProcurementFinanceService.requireSourceCapacity(payment.originalAmount(),payment.settlementAmount()); }
catch(ResponseStatusException capacity) { issue=capacity.getReason()+",已保留真实购买事实"; }
if(issue==null && payment.settlementAmount().compareTo(limit.settlementAmount())>0) issue="实际货款超过客户已授权预算,请先取得凭证并完成预算核验";
} catch(ResponseStatusException exception) {
if(exception.getStatusCode().is4xxClientError()) issue="业务月份汇率尚未核定,已保留真实购买事实";
else throw exception;
}
}
if(issue!=null) {
jdbc.update("UPDATE ll_procurement_flow SET purchase_issue=? WHERE order_id=?",issue,order.getId());
return;
}
BigDecimal amount=(BigDecimal)flow.get("purchase_amount");
finance.confirm(order.getCustomerId(),order.getOrderNo(),operator,new ProcurementFinanceService.Confirmation(
ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","样品货款",amount.signum()==0?"ZERO":"POSITIVE",amount,
String.valueOf(flow.get("purchase_currency")),businessDate,((Number)flow.get("purchase_voucher_id")).longValue(),"购买完成核定样品货款"));
jdbc.update("UPDATE ll_procurement_flow SET purchase_confirmed_at=CURRENT_TIMESTAMP(3),purchase_issue=NULL WHERE order_id=?",order.getId());
}
void requireReplacementBudget(OverseasProcurementOrder order,BigDecimal target,String currency,
java.time.LocalDate businessDate,BigDecimal settlementAmount) {
var facts=flow(order.getId());
if(facts.get("purchased_at")==null || facts.get("purchase_confirmed_at")==null)throw conflict("替代货款须关联本单已核定的真实购买事实");
var limit=budget(order);
if(limit.amount()==null)throw conflict("客户预算尚未明确,请先取得凭证并核验预算");
boolean within=currency.equals(limit.currency())?target.compareTo(limit.amount())<=0
:settlementAmount.compareTo("CNY".equals(limit.currency())?limit.amount():converter.convert(limit.currency(),limit.amount(),businessDate).settlementAmount())<=0;
if(!within)throw conflict("替代货款超过客户已授权预算,请先取得凭证并完成预算核验");
}
private record BudgetLimit(BigDecimal amount,String currency) { }
private BudgetLimit budget(OverseasProcurementOrder order) {
var rows=jdbc.query("SELECT authorized_amount,currency FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1",
(rs,n)->new BudgetLimit(rs.getBigDecimal(1),rs.getString(2)),order.getId());
if(!rows.isEmpty()) return rows.get(0);
if(order.getBudgetUnitPrice()==null) return new BudgetLimit(null,order.getCurrency());
return new BudgetLimit(order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency());
}
public PurchaseStatus purchaseStatus(OverseasProcurementOrder order) {
var flow=flow(order.getId());
var fee=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT");
return new PurchaseStatus(flow.get("purchased_at")!=null,flow.get("purchase_confirmed_at")!=null,
(String)flow.get("purchase_issue"),fee==null?null:fee.getFeeNo(),fee==null?null:fee.getWalletStatus(),fee!=null && finance.sourceLinked(fee));
}
public Outcome read(String customerId,String orderNo) {
return outcome(requireOrder(customerId,orderNo));
}
OverseasProcurementOrder requireOrder(String customerId,String orderNo) {
var order=orders.findNo(customerId,orderNo);
if(order==null) throw bad("采购订单不存在或不属于当前客户");
return order;
}
Outcome outcome(OverseasProcurementOrder order) {
return new Outcome(order.getOrderNo(),order.getVersion(),order.getStatus(),purchaseStatus(order));
}
Map<String,Object> flow(long orderId) {
var rows=jdbc.queryForList("SELECT * FROM ll_procurement_flow WHERE order_id=?",orderId);
return rows.isEmpty()?Map.of():rows.get(0);
}
void ensureFlow(OverseasProcurementOrder order) {
if(flow(order.getId()).isEmpty()) jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id) VALUES(?,?)",order.getId(),order.getCustomerId());
}
void requireNoBlockers(OverseasProcurementOrder order) {
Integer count=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_control_note WHERE order_id=? AND blocking=1 AND resolved_at IS NULL",Integer.class,order.getId());
if(count!=null && count>0)throw conflict("存在未处理的采购阻断异常,请先完成有凭证的异常处理");
}
void requireFile(OverseasProcurementOrder order,long id,Set<String> categories) {
var file=files.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>().eq(OverseasProcurementFile::getOrderId,order.getId())
.eq(OverseasProcurementFile::getCustomerId,order.getCustomerId()).eq(OverseasProcurementFile::getFileId,id));
if(file==null || !categories.contains(file.getCategory())) throw bad("凭证不属于本采购订单或文件用途不正确");
}
OverseasProcurementOrder lock(String customerId,String orderNo) {
var order=orders.lockNo(customerId,orderNo);
if(order==null) throw bad("采购订单不存在或不属于当前客户");
return order;
}
boolean replay(OverseasProcurementOrder order,String action,String key,Object request) {
if(key==null || key.isBlank() || key.length()>128) throw bad("操作幂等键无效");
var previous=jdbc.queryForList("SELECT request_hash FROM ll_procurement_command WHERE order_id=? AND action=? AND idempotency_key=?",String.class,order.getId(),action,key);
if(previous.isEmpty()) return false;
if(!previous.get(0).equals(hash(request))) throw conflict("相同操作幂等键不能用于不同内容");
return true;
}
void complete(OverseasProcurementOrder order,String operator,String action,String key,String reason,Object request) {
if(reason==null || reason.isBlank() || reason.length()>1000) throw bad("请填写操作依据");
jdbc.update("INSERT INTO ll_procurement_command(id,order_id,customer_id,action,idempotency_key,request_hash,operator_id,reason,detail_json) VALUES(?,?,?,?,?,?,?,?,CAST(? AS JSON))",
IdWorker.getId(),order.getId(),order.getCustomerId(),action,key,hash(request),operator,reason,serialize(request));
order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now());orders.updateById(order);
}
static void version(OverseasProcurementOrder order,long version) {
if(order.getVersion()!=version) throw conflict("订单已被修改,请刷新后重试");
}
static void open(OverseasProcurementOrder order) {
if(Set.of("REJECTED","CANCELLED","EXCEPTION_CLOSED","SHIPPED_TO_CUSTOMER").contains(order.getStatus())) throw conflict("订单已结束,不能执行该履约操作");
}
String requestHash(Object value) {return hash(value);}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(serialize(value).getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
catch(Exception exception) {throw new IllegalStateException(exception);}
}
private String serialize(Object value) {
try {return json.writeValueAsString(value);}catch(Exception exception){throw new IllegalStateException(exception);}
}
private static String trim(String value){return value==null || value.isBlank()?null:value.trim();}
static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,332 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*;
/** Physical procurement ledger only. Never writes WMS balances, pallets or stock movements. */
@Service
@RequiredArgsConstructor
public class ProcurementPhysicalService {
private final ProcurementLifecycleService lifecycle;
private final ProcurementWarehouseDirectory warehouses;
private final ProcurementFinanceService finance;
private final JdbcTemplate jdbc;
public record ReceiptRow(long id,String receiptNo,String leg,Long sourceCartonId,long goodQuantity,long damagedQuantity,
long extraQuantity,LocalDateTime receivedAt,boolean active) { }
public record CartonRow(long id,String cartonNo,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,
BigDecimal grossWeightKg,long quantity,List<Source> sources) { }
public record Snapshot(String orderNo,long version,String status,List<ReceiptRow> receipts,
List<Map<String,Object>> dispositions,Integer packingVersion,String packingStatus,List<CartonRow> cartons) { }
@Transactional
public Snapshot receive(String customer,String orderNo,String operator,Receipt request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"RECEIVE",request.idempotencyKey(),request)) return snapshot(order);
version(order,request.version());open(order);
insertReceipt(order,operator,request,null);
lifecycle.complete(order,operator,"RECEIVE",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
private record CorrectionIdentity(long receiptId,Correction correction) { }
@Transactional
public Snapshot correctReceipt(String customer,String orderNo,String operator,long receiptId,Correction correction) {
var order=lifecycle.lock(customer,orderNo);var request=correction.receipt();var identity=new CorrectionIdentity(receiptId,correction);
if(lifecycle.replay(order,"CORRECT_RECEIPT",request.idempotencyKey(),identity))return snapshot(order);
version(order,request.version());open(order);requireCorrectable(order,request.leg());
var rows=jdbc.query("SELECT * FROM ll_procurement_receipt WHERE id=? AND order_id=? AND customer_id=? AND active=1",this::receiptRow,receiptId,order.getId(),customer);
if(rows.size()!=1)throw bad("有效实收记录不存在或不属于当前订单");
var old=rows.get(0);
if(!request.leg().name().equals(old.leg()) || !Objects.equals(request.sourceCartonId(),old.sourceCartonId()))throw bad("更正不得改变实收的运输段和货物来源");
lifecycle.requireFile(order,correction.voucherFileId(),Set.of("VOUCHER","EXCEPTION"));
jdbc.update("UPDATE ll_procurement_receipt SET active=0 WHERE id=?",receiptId);
jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND receipt_id=?",order.getId(),receiptId);
invalidateShortage(order.getId(),request.leg(),request.sourceCartonId());
reopenReceipt(order,request.leg());
insertReceipt(order,operator,request,receiptId);
lifecycle.complete(order,operator,"CORRECT_RECEIPT",request.idempotencyKey(),request.reason(),identity);
return snapshot(order);
}
private record RevokeIdentity(long dispositionId,RevokeDisposition request) { }
@Transactional
public Snapshot revokeDisposition(String customer,String orderNo,String operator,long dispositionId,RevokeDisposition request) {
var order=lifecycle.lock(customer,orderNo);var identity=new RevokeIdentity(dispositionId,request);
if(lifecycle.replay(order,"REVOKE_DISPOSITION",request.idempotencyKey(),identity))return snapshot(order);
version(order,request.version());open(order);
var rows=jdbc.queryForList("SELECT leg FROM ll_procurement_disposition WHERE id=? AND order_id=? AND active=1",dispositionId,order.getId());
if(rows.size()!=1)throw bad("有效差异处置不存在");
Leg leg=Leg.valueOf(String.valueOf(rows.get(0).get("leg")));requireCorrectable(order,leg);
lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER","EXCEPTION"));
jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE id=?",dispositionId);reopenReceipt(order,leg);
lifecycle.complete(order,operator,"REVOKE_DISPOSITION",request.idempotencyKey(),request.reason(),identity);
return snapshot(order);
}
private void requireCorrectable(OverseasProcurementOrder order,Leg leg) {
var flow=lifecycle.flow(order.getId());
if(flow.get(leg==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at")!=null)throw conflict("本段已实际交寄,只能追加更正说明,不能改写数量");
if(leg==Leg.OVERSEAS && flow.get("packing_version")!=null)throw conflict("须先撤回尚未交寄的确认清单,再更正海外实收");
}
private void reopenReceipt(OverseasProcurementOrder order,Leg leg) {
if(leg==Leg.OVERSEAS) {
jdbc.update("UPDATE ll_procurement_flow SET overseas_received_at=NULL,packing_version=NULL WHERE order_id=?",order.getId());
jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId());
order.setStatus("OVERSEAS_RECEIVING");
} else {
jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=NULL,charges_confirmed_at=NULL WHERE order_id=?",order.getId());
order.setStatus("DOMESTIC_RECEIVING");
}
}
private void invalidateShortage(long orderId,Leg leg,Long source) {
if(source==null)jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND leg=? AND kind='SHORTAGE' AND source_carton_id IS NULL",orderId,leg.name());
else jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND leg=? AND kind='SHORTAGE' AND source_carton_id=?",orderId,leg.name(),source);
}
private void insertReceipt(OverseasProcurementOrder order,String operator,Receipt request,Long supersedes) {
if(request.leg()==null || request.receivedAt()==null || request.receivedAt().isAfter(LocalDateTime.now())
|| request.goodQuantity()<0 || request.damagedQuantity()<0 || request.extraQuantity()<0
|| add(add(request.goodQuantity(),request.damagedQuantity()),request.extraQuantity())<=0) throw bad("实收数量或时间无效");
var flow=lifecycle.flow(order.getId());
String completed=request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at";
if(flow.get(completed)!=null) throw conflict("本段收货已完成,请使用受控更正操作");
long expected=expected(order,request.leg(),request.sourceCartonId());
LocalDateTime originAt;
if(request.leg()==Leg.OVERSEAS) {
if(request.warehouseId()==null || request.sourceCartonId()!=null) throw bad("海外实收须选择实际收货仓,不能指定国内来源箱");
var warehouse=warehouses.required(request.warehouseId());
if(flow.get("actual_warehouse_id")!=null && ((Number)flow.get("actual_warehouse_id")).longValue()!=request.warehouseId()) throw conflict("本单实际收货仓已冻结,不支持转仓");
jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?",
warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId());
originAt=((Timestamp)flow.get("purchased_at")).toLocalDateTime();
} else {
if(request.warehouseId()!=null) throw bad("国内实收不应指定海外仓");
originAt=((Timestamp)flow.get("overseas_shipped_at")).toLocalDateTime();
}
if(request.receivedAt().isBefore(originAt)) throw bad("实收时间不能早于来源购买或国际交寄时间");
long counted=received(order.getId(),request.leg(),request.sourceCartonId()).stream()
.mapToLong(r->add(r.goodQuantity(),r.damagedQuantity())).reduce(0,ProcurementPhysicalService::add);
if(add(counted,add(request.goodQuantity(),request.damagedQuantity()))>expected) throw bad("正常及破损实收超过来源数量,多到货物必须单独记录为多货");
jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,active,supersedes_id,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?,?)",
IdWorker.getId(),order.getId(),order.getCustomerId(),number("PR"),request.leg().name(),request.sourceCartonId(),request.warehouseId(),
request.goodQuantity(),request.damagedQuantity(),request.extraQuantity(),request.receivedAt(),supersedes,operator);
order.setStatus(request.leg()==Leg.OVERSEAS?"OVERSEAS_RECEIVING":"DOMESTIC_RECEIVING");
}
@Transactional
public Snapshot dispose(String customer,String orderNo,String operator,Disposition request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"DISPOSE",request.idempotencyKey(),request)) return snapshot(order);
version(order,request.version());open(order);
var flow=lifecycle.flow(order.getId());
if(flow.get(request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at")!=null) throw conflict("已完成的收货需先受控更正,不能直接改差异结果");
long target=expected(order,request.leg(),request.sourceCartonId());
long available;
if("SHORTAGE".equals(request.kind())) {
if(request.receiptId()!=null || !"ACCEPT_SHORTAGE".equals(request.decision())) throw bad("短少须按来源确认,不关联单条实收");
long counted=received(order.getId(),request.leg(),request.sourceCartonId()).stream()
.mapToLong(r->add(r.goodQuantity(),r.damagedQuantity())).reduce(0,ProcurementPhysicalService::add);
available=target-counted-resolved(order.getId(),request.leg(),request.sourceCartonId(),null,"SHORTAGE");
} else {
if(request.receiptId()==null) throw bad("破损或多货须关联实际收货记录");
var receipt=received(order.getId(),request.leg(),request.sourceCartonId()).stream().filter(r->r.id()==request.receiptId()).findFirst()
.orElseThrow(()->bad("实收记录不属于当前来源"));
if("DAMAGED".equals(request.kind()) && "DISPOSE_DAMAGED".equals(request.decision())) available=receipt.damagedQuantity();
else if("EXTRA".equals(request.kind()) && "RETURN_EXTRA".equals(request.decision())) available=receipt.extraQuantity();
else throw bad("差异类型和处置动作不匹配");
available-=resolved(order.getId(),request.leg(),request.sourceCartonId(),request.receiptId(),request.kind());
}
if(request.quantity()<=0 || request.quantity()>available) throw bad("处置数量超过未处理差异");
lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER","EXCEPTION"));
jdbc.update("INSERT INTO ll_procurement_disposition(id,order_id,customer_id,leg,source_carton_id,receipt_id,kind,quantity,decision,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
IdWorker.getId(),order.getId(),customer,request.leg().name(),request.sourceCartonId(),request.receiptId(),request.kind(),request.quantity(),request.decision(),request.voucherFileId(),request.reason(),operator);
lifecycle.complete(order,operator,"DISPOSE",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
@Transactional
public Snapshot finishReceipt(String customer,String orderNo,String operator,FinishReceipt request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"FINISH_RECEIPT",request.idempotencyKey(),request)) return snapshot(order);
version(order,request.version());open(order);
var flow=lifecycle.flow(order.getId());
String field=request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at";
if(flow.get(field)!=null) throw conflict("本段收货已完成");
if(request.leg()==Leg.OVERSEAS) validateReceiptSource(order,Leg.OVERSEAS,null);
else {
var sourceIds=jdbc.queryForList("SELECT i.carton_id FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.order_id=? AND s.leg='OVERSEAS'",Long.class,order.getId());
if(sourceIds.isEmpty()) throw conflict("尚无国际交寄事实");
for(long sourceId:sourceIds) validateReceiptSource(order,Leg.DOMESTIC,sourceId);
}
jdbc.update("UPDATE ll_procurement_flow SET "+field+"=CURRENT_TIMESTAMP(3) WHERE order_id=?",order.getId());
long good=goodTotal(order.getId(),request.leg());
if(request.leg()==Leg.OVERSEAS) {
order.setInboundQuantity(good);
order.setStatus(good>0?"WAIT_PACKING":"OVERSEAS_RECEIVING");
} else order.setStatus(good>0?"WAIT_CHARGES":"DOMESTIC_RECEIVING");
lifecycle.complete(order,operator,"FINISH_RECEIPT",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
private void validateReceiptSource(OverseasProcurementOrder order,Leg leg,Long source) {
long target=expected(order,leg,source),counted=0;
for(var receipt:received(order.getId(),leg,source)) {
counted=add(counted,add(receipt.goodQuantity(),receipt.damagedQuantity()));
if(resolved(order.getId(),leg,source,receipt.id(),"DAMAGED")!=receipt.damagedQuantity()
|| resolved(order.getId(),leg,source,receipt.id(),"EXTRA")!=receipt.extraQuantity()) throw conflict("存在未处理的破损或多到货物");
}
if(add(counted,resolved(order.getId(),leg,source,null,"SHORTAGE"))!=target) throw conflict("货物未收齐或短少处置与来源数量不一致");
}
@Transactional
public Snapshot savePacking(String customer,String orderNo,String operator,Packing request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"SAVE_PACKING",request.idempotencyKey(),request)) return snapshot(order);
version(order,request.version());open(order);
if(lifecycle.flow(order.getId()).get("overseas_shipped_at")!=null) throw conflict("国际段已实际交寄,不能改写原装箱事实");
if(request.cartons()==null || request.cartons().isEmpty()) throw bad("装箱清单不能为空");
Map<Long,Long> available=new HashMap<>(),used=new HashMap<>();
for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) available.put(receipt.id(),receipt.goodQuantity());
Set<String> names=new HashSet<>();
for(var carton:request.cartons()) {
if(carton.cartonNo()==null || carton.cartonNo().isBlank() || carton.cartonNo().length()>64 || !names.add(carton.cartonNo().trim())) throw bad("箱号为空、重复或过长");
measure(carton.lengthCm(),2);measure(carton.widthCm(),2);measure(carton.heightCm(),2);measure(carton.grossWeightKg(),3);
if(carton.sources()==null || carton.sources().isEmpty()) throw bad("每箱必须有明确实收货物来源");
Set<Long> unique=new HashSet<>();
for(var source:carton.sources()) {
if(source.quantity()<=0 || !unique.add(source.receiptId()) || !available.containsKey(source.receiptId())) throw bad("箱内来源无效、重复或不属于本单有效实收");
used.merge(source.receiptId(),source.quantity(),ProcurementPhysicalService::add);
if(used.get(source.receiptId())>available.get(source.receiptId())) throw bad("装箱数量超过来源合格实收,不能重复分配");
}
}
Integer previous=jdbc.queryForObject("SELECT COALESCE(MAX(version),0) FROM ll_procurement_manifest WHERE order_id=?",Integer.class,order.getId());
int packingVersion=previous==null?1:previous+1;long manifestId=IdWorker.getId();
jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId());
jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(?,?,?,?,'DRAFT',?)",manifestId,order.getId(),customer,packingVersion,operator);
for(var carton:request.cartons()) {
long cartonId=IdWorker.getId(),quantity=carton.sources().stream().mapToLong(Source::quantity).reduce(0,ProcurementPhysicalService::add);
jdbc.update("INSERT INTO ll_procurement_carton(id,order_id,manifest_id,carton_no,length_cm,width_cm,height_cm,gross_weight_kg,quantity) VALUES(?,?,?,?,?,?,?,?,?)",
cartonId,order.getId(),manifestId,carton.cartonNo().trim(),carton.lengthCm(),carton.widthCm(),carton.heightCm(),carton.grossWeightKg(),quantity);
for(var source:carton.sources()) jdbc.update("INSERT INTO ll_procurement_carton_source(carton_id,receipt_id,quantity) VALUES(?,?,?)",cartonId,source.receiptId(),source.quantity());
}
jdbc.update("UPDATE ll_procurement_flow SET packing_version=NULL WHERE order_id=?",order.getId());
if(lifecycle.flow(order.getId()).get("overseas_received_at")!=null) order.setStatus("WAIT_PACKING");
lifecycle.complete(order,operator,"SAVE_PACKING",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
@Transactional
public Snapshot confirmPacking(String customer,String orderNo,String operator,ConfirmPacking request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"CONFIRM_PACKING",request.idempotencyKey(),request)) return snapshot(order);
version(order,request.version());open(order);lifecycle.requireNoBlockers(order);
var flow=lifecycle.flow(order.getId());
if(flow.get("overseas_received_at")==null || flow.get("overseas_shipped_at")!=null) throw conflict("须海外收货完成且尚未国际交寄");
var payment=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT");
if(payment==null || !finance.sourceLinked(payment)) throw conflict("样品货款扣费及来源关联未完成,不能确认装箱");
validateReceiptSource(order,Leg.OVERSEAS,null);
var manifest=currentManifest(order.getId());
if(manifest==null || ((Number)manifest.get("version")).intValue()!=request.packingVersion() || !"DRAFT".equals(manifest.get("status"))) throw conflict("装箱草稿版本已变化");
long manifestId=((Number)manifest.get("id")).longValue();
Map<Long,Long> assigned=new HashMap<>();
jdbc.query("SELECT s.receipt_id,SUM(s.quantity) AS quantity FROM ll_procurement_carton c JOIN ll_procurement_carton_source s ON s.carton_id=c.id WHERE c.manifest_id=? GROUP BY s.receipt_id",
(org.springframework.jdbc.core.RowCallbackHandler)rs->assigned.put(rs.getLong("receipt_id"),rs.getLong("quantity")),manifestId);
long total=0;
for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) {
if(assigned.getOrDefault(receipt.id(),0L)!=receipt.goodQuantity()) throw conflict("确认装箱须覆盖本批全部合格实收,且每份来源数量一致");
assigned.remove(receipt.id());total=add(total,receipt.goodQuantity());
}
if(!assigned.isEmpty() || total<=0) throw conflict("清单包含失效来源或没有可发货物");
jdbc.update("UPDATE ll_procurement_manifest SET status='CONFIRMED',confirmed_at=CURRENT_TIMESTAMP(3) WHERE id=?",manifestId);
jdbc.update("UPDATE ll_procurement_flow SET packing_version=? WHERE order_id=?",request.packingVersion(),order.getId());
order.setStatus("WAIT_LABELS");
lifecycle.complete(order,operator,"CONFIRM_PACKING",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
@Transactional
public Snapshot withdrawPacking(String customer,String orderNo,String operator,Command request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"WITHDRAW_PACKING",request.idempotencyKey(),request)) return snapshot(order);
version(order,request.version());open(order);
if(lifecycle.flow(order.getId()).get("overseas_shipped_at")!=null) throw conflict("已交寄装箱事实不能撤回");
jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId());
jdbc.update("UPDATE ll_procurement_flow SET packing_version=NULL WHERE order_id=?",order.getId());
order.setStatus(lifecycle.flow(order.getId()).get("overseas_received_at")==null?"OVERSEAS_RECEIVING":"WAIT_PACKING");
lifecycle.complete(order,operator,"WITHDRAW_PACKING",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
public Snapshot read(String customer,String orderNo) {
var outcome=lifecycle.read(customer,orderNo);
// Read the same authorized aggregate without obtaining a write lock.
var rows=jdbc.queryForList("SELECT id FROM ll_overseas_procurement_order WHERE customer_id=? AND order_no=?",Long.class,customer,orderNo);
if(rows.size()!=1) throw bad("采购订单不存在");
return snapshot(rows.get(0),outcome.orderNo(),outcome.version(),outcome.status());
}
private Snapshot snapshot(OverseasProcurementOrder order){return snapshot(order.getId(),order.getOrderNo(),order.getVersion(),order.getStatus());}
private Snapshot snapshot(long orderId,String orderNo,long version,String status) {
var receipts=jdbc.query("SELECT * FROM ll_procurement_receipt WHERE order_id=? ORDER BY created_at,id",this::receiptRow,orderId);
var dispositions=jdbc.queryForList("SELECT id,leg,source_carton_id,receipt_id,kind,quantity,decision,reason,active,created_at FROM ll_procurement_disposition WHERE order_id=? ORDER BY id",orderId);
var manifests=jdbc.queryForList("SELECT * FROM ll_procurement_manifest WHERE order_id=? ORDER BY version DESC LIMIT 1",orderId);
if(manifests.isEmpty()) return new Snapshot(orderNo,version,status,receipts,dispositions,null,null,List.of());
var manifest=manifests.get(0);long manifestId=((Number)manifest.get("id")).longValue();
Map<Long,List<Source>> sources=new HashMap<>();
jdbc.query("SELECT s.carton_id,s.receipt_id,s.quantity FROM ll_procurement_carton_source s JOIN ll_procurement_carton c ON c.id=s.carton_id WHERE c.manifest_id=? ORDER BY s.receipt_id",
(org.springframework.jdbc.core.RowCallbackHandler)rs->sources.computeIfAbsent(rs.getLong(1),x->new ArrayList<>()).add(new Source(rs.getLong(2),rs.getLong(3))),manifestId);
var cartons=jdbc.query("SELECT * FROM ll_procurement_carton WHERE manifest_id=? ORDER BY carton_no",(rs,n)->new CartonRow(rs.getLong("id"),rs.getString("carton_no"),
rs.getBigDecimal("length_cm"),rs.getBigDecimal("width_cm"),rs.getBigDecimal("height_cm"),rs.getBigDecimal("gross_weight_kg"),rs.getLong("quantity"),sources.getOrDefault(rs.getLong("id"),List.of())),manifestId);
return new Snapshot(orderNo,version,status,receipts,dispositions,((Number)manifest.get("version")).intValue(),String.valueOf(manifest.get("status")),cartons);
}
Map<String,Object> currentManifest(long orderId) {
var rows=jdbc.queryForList("SELECT * FROM ll_procurement_manifest WHERE order_id=? AND status<>'SUPERSEDED' ORDER BY version DESC LIMIT 1",orderId);
return rows.isEmpty()?null:rows.get(0);
}
long goodTotal(long orderId,Leg leg){return received(orderId,leg,null,true).stream().mapToLong(ReceiptRow::goodQuantity).reduce(0,ProcurementPhysicalService::add);}
List<ReceiptRow> received(long orderId,Leg leg,Long source){return received(orderId,leg,source,false);}
private List<ReceiptRow> received(long orderId,Leg leg,Long source,boolean allSources) {
String sql="SELECT * FROM ll_procurement_receipt WHERE order_id=? AND leg=? AND active=1";
if(allSources) return jdbc.query(sql,this::receiptRow,orderId,leg.name());
return source==null?jdbc.query(sql+" AND source_carton_id IS NULL",this::receiptRow,orderId,leg.name())
:jdbc.query(sql+" AND source_carton_id=?",this::receiptRow,orderId,leg.name(),source);
}
private ReceiptRow receiptRow(ResultSet rs,int index)throws SQLException {
Number source=(Number)rs.getObject("source_carton_id");
return new ReceiptRow(rs.getLong("id"),rs.getString("receipt_no"),rs.getString("leg"),source==null?null:source.longValue(),
rs.getLong("good_quantity"),rs.getLong("damaged_quantity"),rs.getLong("extra_quantity"),rs.getTimestamp("received_at").toLocalDateTime(),rs.getBoolean("active"));
}
private long resolved(long orderId,Leg leg,Long source,Long receipt,String kind) {
String sql="SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND active=1 AND ";
List<Object> args=new ArrayList<>(List.of(orderId,leg.name(),kind));
sql+=source==null?"source_carton_id IS NULL":"source_carton_id=?";if(source!=null)args.add(source);
sql+=receipt==null?" AND receipt_id IS NULL":" AND receipt_id=?";if(receipt!=null)args.add(receipt);
Long value=jdbc.queryForObject(sql,Long.class,args.toArray());return value==null?0:value;
}
private long expected(OverseasProcurementOrder order,Leg leg,Long source) {
var flow=lifecycle.flow(order.getId());
if(leg==Leg.OVERSEAS) {
if(source!=null || flow.get("purchased_at")==null) throw conflict("海外实收必须关联本单真实购买");
return ((Number)flow.get("purchased_quantity")).longValue();
}
if(source==null || flow.get("overseas_shipped_at")==null) throw conflict("国内实收必须关联本单原国际交寄箱");
var quantities=jdbc.queryForList("SELECT i.quantity FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.order_id=? AND s.customer_id=? AND s.leg='OVERSEAS' AND i.carton_id=?",Long.class,order.getId(),order.getCustomerId(),source);
if(quantities.size()!=1) throw bad("来源箱不属于本单国际交寄");return quantities.get(0);
}
private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("箱规须为正数且最多两位小数,毛重须为正数且最多三位小数");}
private static long add(long a,long b){try{return Math.addExact(a,b);}catch(ArithmeticException e){throw bad("数量超过可处理范围");}}
private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();}
}
@@ -0,0 +1,132 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
/** Browser detail aggregation; the customer result is a separate allowlist, never a filtered platform map. */
@Service @RequiredArgsConstructor
public class ProcurementQueryService {
private final ProcurementLifecycleService lifecycle;
private final ProcurementPhysicalService physical;
private final ProcurementTransportService transport;
private final ProcurementFinanceService finance;
private final ProcurementAdjustmentService adjustments;
private final ProcurementControlService controls;
private final OverseasProcurementService orders;
private final JdbcTemplate jdbc;
public record Header(Long id,String orderNo,String customerId,String customerName,String purchaseCategory,String productName,
String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency,
String receivingAddress,String contactName,String contactPhone,LocalDateTime estimatedArrivalAt,
String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version,
LocalDateTime createdAt,LocalDateTime updatedAt) { }
public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { }
public record Progress(String key,String title,LocalDateTime completedAt) { }
public record Box(String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,BigDecimal grossWeightKg) { }
public record Shipment(String shipmentNo,String leg,LocalDateTime shippedAt,String cartonNo,long quantity,String carrier,String trackingNo) { }
public record CustomerFee(String feeNo,String purpose,String name,Integer version,boolean active,String determination,
BigDecimal amount,String currency,BigDecimal originalAmount,String originalCurrency,
BigDecimal exchangeRate,String exchangeRateMonth,LocalDate businessDate,String parentFeeNo,
String walletStatus,boolean sourceLinked) {
public static CustomerFee from(OverseasProcurementFee fee,String parentNo,boolean linked) {
return new CustomerFee(fee.getFeeNo(),fee.getPurpose(),fee.getFeeItemName(),fee.getFeeVersion(),Boolean.TRUE.equals(fee.getActive()),
fee.getDetermination(),fee.getAmount(),fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(),fee.getExchangeRateMonth(),fee.getBusinessDate(),parentNo,fee.getWalletStatus(),linked);
}
}
public record CustomerView(Header order,Budget budget,boolean purchased,boolean purchaseConfirmed,boolean purchaseNeedsAttention,
List<Progress> progress,List<Box> boxes,List<Shipment> shipments,List<CustomerFee> fees,List<String> exceptionSummaries) { }
public record Action(String key,String label,String permission,boolean allowed,String reason) { }
public record PlatformView(CustomerView customerView,Map<String,Object> purchaseFacts,ProcurementPhysicalService.Snapshot physical,
ProcurementTransportService.Snapshot transport,List<OverseasProcurementFee> fees,
List<ProcurementAdjustmentService.Result> feeCorrections,ProcurementControlService.Snapshot controls,
List<OverseasProcurementResponses.FileInfo> files,List<OverseasProcurementResponses.History> reviewHistory,
List<Map<String,Object>> operations,List<Action> actions,String internalRemark) { }
@Transactional(readOnly=true)
public CustomerView customer(String customer,String orderNo) {
var order=lifecycle.requireOrder(customer,orderNo);return customer(order);
}
private CustomerView customer(OverseasProcurementOrder order) {
var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map<Long,String> feeNos=new HashMap<>();fees.forEach(f->feeNos.put(f.getId(),f.getFeeNo()));
var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),order.getProductSpecification(),order.getProductUrl(),
order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(),
(String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt());
var budgets=jdbc.query("SELECT authorized_amount,currency,reviewed_at FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1",
(rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId());
var budget=budgets.isEmpty()?new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null):budgets.get(0);
List<Progress> progress=new ArrayList<>();progress.add(new Progress("CREATED","客户下单",order.getCreatedAt()));progress.add(new Progress("REVIEWED","采购审核",order.getReviewedAt()));
for(var step:List.of(new String[]{"purchased_at","购买完成"},new String[]{"overseas_received_at","海外收货完成"},new String[]{"overseas_shipped_at","海外实际交寄"},
new String[]{"domestic_received_at","国内收货完成"},new String[]{"charges_confirmed_at","国内费用核定"},new String[]{"customer_shipped_at","已寄客户"},new String[]{"closed_at","异常关闭"})) {
progress.add(new Progress(step[0],step[1],date(flow.get(step[0]))));
}
var manifest=physical.currentManifest(order.getId());
boolean confirmed=manifest!=null && "CONFIRMED".equals(manifest.get("status"));
progress.add(4,new Progress("packing_confirmed","装箱确认",confirmed?date(manifest.get("confirmed_at")):null));
var labelDates=confirmed?jdbc.queryForList("SELECT l.id,l.created_at FROM ll_procurement_label_set l WHERE l.order_id=? AND l.leg='OVERSEAS' AND l.manifest_id=? ORDER BY l.version DESC LIMIT 1",order.getId(),manifest.get("id")):List.<Map<String,Object>>of();
progress.add(5,new Progress("international_labels","国际面单上传",labelDates.isEmpty()?null:date(labelDates.get(0).get("created_at"))));
LocalDateTime applied=null;
if(!labelDates.isEmpty()) {
var states=jdbc.queryForMap("SELECT COUNT(*) AS total,SUM(CASE WHEN applied_at IS NULL THEN 1 ELSE 0 END) AS missing,MAX(applied_at) AS applied_at FROM ll_procurement_label_item WHERE label_set_id=?",labelDates.get(0).get("id"));
if(((Number)states.get("total")).longValue()>0 && ((Number)states.get("missing")).longValue()==0)applied=date(states.get("applied_at"));
}
progress.add(6,new Progress("labels_applied","全部箱贴单",applied));
var boxes=jdbc.query("SELECT c.carton_no,c.quantity,c.length_cm,c.width_cm,c.height_cm,c.gross_weight_kg FROM ll_procurement_manifest m JOIN ll_procurement_carton c ON c.manifest_id=m.id WHERE m.order_id=? AND m.status='CONFIRMED' ORDER BY c.carton_no",
(rs,n)->new Box(rs.getString(1),rs.getLong(2),rs.getBigDecimal(3),rs.getBigDecimal(4),rs.getBigDecimal(5),rs.getBigDecimal(6)),order.getId());
var shipments=jdbc.query("SELECT s.shipment_no,s.leg,s.shipped_at,c.carton_no,i.quantity,i.carrier,i.tracking_no FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id JOIN ll_procurement_carton c ON c.id=i.carton_id WHERE s.order_id=? ORDER BY s.id,c.carton_no",
(rs,n)->new Shipment(rs.getString(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime(),rs.getString(4),rs.getLong(5),rs.getString(6),rs.getString(7)),order.getId());
var summaries=new ArrayList<>(jdbc.queryForList("SELECT client_summary FROM ll_procurement_control_note WHERE order_id=? AND client_summary IS NOT NULL AND TRIM(client_summary)<>'' ORDER BY id",String.class,order.getId()));
summaries.addAll(jdbc.queryForList("SELECT client_summary FROM ll_procurement_closure WHERE order_id=?",String.class,order.getId()));
return new CustomerView(header,budget,flow.get("purchased_at")!=null,flow.get("purchase_confirmed_at")!=null,flow.get("purchase_issue")!=null,List.copyOf(progress),boxes,shipments,
fees.stream().map(f->CustomerFee.from(f,feeNos.get(f.getParentFeeId()),finance.sourceLinked(f))).toList(),List.copyOf(summaries));
}
@Transactional(readOnly=true)
public PlatformView platform(String customer,String orderNo,Set<String> permissions) {
var order=lifecycle.requireOrder(customer,orderNo);var base=orders.detail(customer,orderNo);var flow=lifecycle.flow(order.getId());
var physicalView=physical.read(customer,orderNo);var transportView=transport.read(customer,orderNo);
return new PlatformView(customer(order),flow,physicalView,transportView,finance.all(order.getId()),adjustments.history(customer,orderNo),controls.read(customer,orderNo),base.files(),base.histories(),
jdbc.queryForList("SELECT action,operator_id,reason,detail_json,created_at FROM ll_procurement_command WHERE order_id=? ORDER BY id DESC",order.getId()),actions(order,flow,physicalView,transportView,permissions),base.remark());
}
private List<Action> actions(OverseasProcurementOrder order,Map<String,Object> flow,ProcurementPhysicalService.Snapshot physicalView,ProcurementTransportService.Snapshot transportView,Set<String> permissions) {
boolean closed=Set.of("REJECTED","CANCELLED","EXCEPTION_CLOSED","SHIPPED_TO_CUSTOMER").contains(order.getStatus()),purchased=flow.get("purchased_at")!=null,international=flow.get("overseas_shipped_at")!=null;
String stopped=closed?"订单已结束":null;
String blockers=reason(()->lifecycle.requireNoBlockers(order));
var sample=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT");boolean paid=sample!=null && finance.sourceLinked(sample);
List<Action> result=new ArrayList<>();
action(result,permissions,"REVIEW","审核采购","review",closed?stopped:!"PENDING_REVIEW".equals(order.getStatus())?"订单不处于待审核":null);
action(result,permissions,"PURCHASE","登记购买完成","buy",closed?stopped:purchased?"已经记录购买事实":order.getReviewedAt()==null?"须审核通过":null);
action(result,permissions,"CONFIRM_PURCHASE","重新核定原购买","buy",closed?stopped:!purchased?"尚无真实购买事实":flow.get("purchase_confirmed_at")!=null?"原购买已核定":null);
action(result,permissions,"AUTHORIZE_BUDGET","核验预算上限","review",closed?stopped:sample!=null?"货款已核定,不能改原费用":null);
action(result,permissions,"PLAN_WAREHOUSE","修改计划仓","review",closed?stopped:flow.get("actual_warehouse_id")!=null?"实际仓已冻结,请使用受控更正":null);
action(result,permissions,"RECEIVE_OVERSEAS","登记海外实收","receive-overseas",closed?stopped:!purchased?"尚无购买来源":flow.get("overseas_received_at")!=null?"海外收货已完成":null);
action(result,permissions,"FINISH_OVERSEAS","确认海外收货完成","receive-overseas",closed?stopped:!purchased?"尚无购买来源":flow.get("overseas_received_at")!=null?"海外收货已完成":null);
action(result,permissions,"PACK","保存装箱草稿","pack",closed?stopped:international?"国际段已交寄":!purchased?"尚无购买来源":null);
action(result,permissions,"CONFIRM_PACKING","确认装箱","pack",closed?stopped:international?"国际段已交寄":blockers!=null?blockers:flow.get("overseas_received_at")==null?"须完成海外收货":!paid?"样品货款或来源关联未完成":!"DRAFT".equals(physicalView.packingStatus())?"须有当前装箱草稿":null);
action(result,permissions,"LABELS","维护两段面单","label",closed?stopped:!"CONFIRMED".equals(physicalView.packingStatus())?"须确认海外装箱":international && flow.get("domestic_received_at")==null?"须完成国内收货后制作寄客户面单":null);
action(result,permissions,"ESTIMATE","核对预估运费","estimate",closed?stopped:international?"国际段已交寄":blockers!=null?blockers:!paid?"样品货款或来源关联未完成":transportView.overseasLabels()==null?"请先维护国际面单":null);
action(result,permissions,"APPLY_LABELS","确认逐箱贴单","apply-label",closed?stopped:international?"国际段已交寄":transportView.overseasLabels()==null?"请先维护国际面单":null);
action(result,permissions,"SHIP_OVERSEAS","确认海外交寄","ship-overseas",transport.shipmentBlockReason(order,Leg.OVERSEAS));
action(result,permissions,"RECEIVE_DOMESTIC","登记国内实收","receive-domestic",closed?stopped:!international?"尚无国际交寄来源":flow.get("domestic_received_at")!=null?"国内收货已完成":null);
action(result,permissions,"FINISH_DOMESTIC","确认国内收货完成","receive-domestic",closed?stopped:!international?"尚无国际交寄来源":flow.get("domestic_received_at")!=null?"国内收货已完成":null);
action(result,permissions,"REPACK","国内一对一换包装","receive-domestic",closed?stopped:flow.get("domestic_received_at")==null?"须完成国内收货":null);
action(result,permissions,"CONFIRM_CHARGES","确认国内费用","charge",closed?stopped:blockers!=null?blockers:flow.get("domestic_received_at")==null?"须完成国内收货":null);
action(result,permissions,"SHIP_CUSTOMER","确认寄客户","ship-customer",transport.shipmentBlockReason(order,Leg.DOMESTIC));
action(result,permissions,"MANAGE","事实更正及异常处置","manage",null);
action(result,permissions,"FINANCE_ADJUST","费用更正与退款","finance-adjust",null);
action(result,permissions,"CANCEL","购买前取消","cancel",closed?stopped:purchased?"购买后不能取消,请处理实物资金后异常关闭":null);
action(result,permissions,"EXCEPTION_CLOSE","异常关闭","manage",closed?stopped:!purchased?"购买前请取消订单":blockers!=null?blockers:reason(()->finance.requireShipmentReady(order.getId())));
return List.copyOf(result);
}
private static void action(List<Action> result,Set<String> permissions,String key,String label,String suffix,String block){String code="platform:overseas-business:purchase:"+suffix;String reason=permissions==null || !permissions.contains(code)?"无此操作权限":block;result.add(new Action(key,label,code,reason==null,reason));}
private static String reason(Runnable action){try{action.run();return null;}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}}
private static LocalDateTime date(Object value){return value==null?null:((java.sql.Timestamp)value).toLocalDateTime();}
}
@@ -0,0 +1,277 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Source;
import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*;
/** Every shipment is a single immutable consumption of this order's entire eligible batch. */
@Service @RequiredArgsConstructor
public class ProcurementTransportService {
private final ProcurementLifecycleService lifecycle;
private final ProcurementPhysicalService physical;
private final ProcurementFinanceService finance;
private final ProcurementWarehouseDirectory warehouses;
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public record Cargo(long cartonId,String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,
BigDecimal heightCm,BigDecimal grossWeightKg,Long packagingRecordId,List<Source> sources) { }
public record LabelRow(long cartonId,long fileId,int page,String carrier,String trackingNo,LocalDateTime appliedAt) { }
public record LabelSet(long id,int version,long manifestId,String cargoHash,Address sender,Address receiver,List<LabelRow> labels) { }
public record Shipment(long id,String shipmentNo,String leg,LocalDateTime shippedAt) { }
public record Snapshot(String orderNo,long version,String status,LabelSet overseasLabels,LabelSet domesticLabels,
List<Shipment> shipments,List<Cargo> overseasCargo,List<Cargo> domesticCargo) { }
@Transactional
public Snapshot saveLabels(String customer,String orderNo,String operator,Labels request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"LABELS",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());open(order);notShipped(order,request.leg());
var manifest=manifest(order,request.leg());
if(((Number)manifest.get("version")).intValue()!=request.packingVersion())throw conflict("清单版本已变化,请重新核对");
var cargo=cargo(order,request.leg(),((Number)manifest.get("id")).longValue());
if(cargo.isEmpty())throw conflict("没有可寄货物,不能制作面单");
if(request.labels()==null || request.labels().isEmpty())throw bad("请关联本批全部箱的面单");
exactCartons(cargo,request.labels().stream().map(Label::cartonId).toList());
address(request.receiver());
Address sender;
if(request.leg()==Leg.OVERSEAS) {
var id=(Number)lifecycle.flow(order.getId()).get("actual_warehouse_id");
if(id==null)throw conflict("尚无实际海外收货仓");
sender=warehouses.sender(id.longValue());
} else {
var original=internationalAddresses(order);
sender=original.receiver();
var customerAddress=new Address(order.getContactName(),order.getContactPhone(),order.getReceivingAddress());
if(!customerAddress.equals(request.receiver())) {
if(request.addressVoucherFileId()==null)throw bad("最终收件信息变更须关联本单客户确认凭证");
lifecycle.requireFile(order,request.addressVoucherFileId(),Set.of("EXCEPTION"));
}
}
address(sender);
for(var label:request.labels()) {
lifecycle.requireFile(order,label.fileId(),Set.of("LABEL"));
var types=jdbc.queryForList("SELECT content_type FROM ll_overseas_procurement_file WHERE order_id=? AND file_id=? AND category='LABEL'",String.class,order.getId(),label.fileId());
if(types.size()!=1 || !Set.of("application/pdf","image/jpeg","image/png").contains(types.get(0))
|| label.page()<1 || (!"application/pdf".equals(types.get(0)) && label.page()!=1))throw bad("面单类型或对应页码不正确");
text(label.carrier(),128,"承运商");text(label.trackingNo(),128,"运单号");
}
int next=jdbc.queryForObject("SELECT COALESCE(MAX(version),0)+1 FROM ll_procurement_label_set WHERE order_id=? AND leg=?",Integer.class,order.getId(),request.leg().name());
long id=IdWorker.getId();
jdbc.update("INSERT INTO ll_procurement_label_set(id,order_id,customer_id,leg,version,manifest_id,cargo_hash,sender_snapshot,receiver_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)",
id,order.getId(),customer,request.leg().name(),next,manifest.get("id"),hash(cargo),serialize(sender),serialize(request.receiver()),operator);
for(var label:request.labels())jdbc.update("INSERT INTO ll_procurement_label_item(label_set_id,carton_id,file_id,page_no,carrier,tracking_no) VALUES(?,?,?,?,?,?)",
id,label.cartonId(),label.fileId(),label.page(),label.carrier().trim(),label.trackingNo().trim());
if(request.leg()==Leg.OVERSEAS)order.setStatus("WAIT_LABELS");
lifecycle.complete(order,operator,"LABELS",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
/** Different prices release first; callers retry after the authoritative release result to create the replacement. */
@Transactional
public Snapshot reviewEstimate(String customer,String orderNo,String operator,Estimate request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"ESTIMATE",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());open(order);notShipped(order,Leg.OVERSEAS);samplePaid(order);lifecycle.requireNoBlockers(order);
var labels=validLabels(order,Leg.OVERSEAS,request.labelVersion());
if(request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2 || request.businessDate()==null)throw bad("请填写准确的CNY预估金额和业务日期");
var existing=finance.current(order.getId(),ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE");
if(existing!=null) {
if(!Set.of("FROZEN","ZERO").contains(existing.getWalletStatus()))throw conflict("原预估资金未完成,请先处理原请求");
if(existing.getAmount().compareTo(request.amount())!=0) {
finance.releaseEstimate(customer,orderNo,existing.getId());
order.setStatus("WAIT_LABELS");
lifecycle.complete(order,operator,"ESTIMATE",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
} else existing=finance.confirm(customer,orderNo,operator,new ProcurementFinanceService.Confirmation(
ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估国际运费",request.amount().signum()>0?"POSITIVE":"ZERO",
request.amount(),"CNY",request.businessDate(),null,request.reason()));
Integer count=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_estimate_review WHERE label_set_id=? AND fee_id=?",Integer.class,labels.id(),existing.getId());
if(count==0)jdbc.update("INSERT INTO ll_procurement_estimate_review(id,order_id,label_set_id,fee_id,reviewed_by) VALUES(?,?,?,?,?)",IdWorker.getId(),order.getId(),labels.id(),existing.getId(),operator);
order.setStatus("WAIT_OVERSEAS_SHIPMENT");
lifecycle.complete(order,operator,"ESTIMATE",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
@Transactional
public Snapshot applyLabels(String customer,String orderNo,String operator,ApplyLabels request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"APPLY_LABELS",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());open(order);notShipped(order,Leg.OVERSEAS);
var labels=validLabels(order,Leg.OVERSEAS,request.labelVersion());
if(request.appliedAt()==null || request.appliedAt().isAfter(LocalDateTime.now()))throw bad("请填写真实贴单时间");
var flow=lifecycle.flow(order.getId());
if(request.appliedAt().isBefore(((Timestamp)flow.get("overseas_received_at")).toLocalDateTime()))throw bad("贴单时间不能早于海外收货完成");
if(request.cartonIds()==null || request.cartonIds().isEmpty() || new HashSet<>(request.cartonIds()).size()!=request.cartonIds().size())throw bad("请选择不重复的实际贴单箱");
for(long carton:request.cartonIds()) {
var label=labels.labels().stream().filter(l->l.cartonId()==carton).findFirst().orElseThrow(()->bad("贴单箱不属于当前面单"));
if(label.appliedAt()!=null)throw conflict("该箱已确认贴单,不能重复登记");
jdbc.update("UPDATE ll_procurement_label_item SET applied_at=?,applied_by=? WHERE label_set_id=? AND carton_id=? AND applied_at IS NULL",request.appliedAt(),operator,labels.id(),carton);
}
lifecycle.complete(order,operator,"APPLY_LABELS",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
@Transactional
public Snapshot repack(String customer,String orderNo,String operator,Repack request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"REPACK",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());open(order);notShipped(order,Leg.DOMESTIC);
var manifest=manifest(order,Leg.DOMESTIC);
if(cargo(order,Leg.DOMESTIC,((Number)manifest.get("id")).longValue()).stream().noneMatch(c->c.cartonId()==request.cartonId()))throw bad("换包装箱不属于本单国内可寄原箱");
measure(request.lengthCm(),2);measure(request.widthCm(),2);measure(request.heightCm(),2);measure(request.grossWeightKg(),3);
lifecycle.requireFile(order,request.voucherFileId(),Set.of("EXCEPTION","RECEIPT"));
jdbc.update("INSERT INTO ll_procurement_repack(id,order_id,carton_id,length_cm,width_cm,height_cm,gross_weight_kg,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?)",
IdWorker.getId(),order.getId(),request.cartonId(),request.lengthCm(),request.widthCm(),request.heightCm(),request.grossWeightKg(),request.voucherFileId(),request.reason(),operator);
// New measurements require the domestic operator to reconfirm all current cost determinations.
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId());order.setStatus("WAIT_CHARGES");
lifecycle.complete(order,operator,"REPACK",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
@Transactional
public Snapshot ship(String customer,String orderNo,String operator,Ship request) {
var order=lifecycle.lock(customer,orderNo);
if(lifecycle.replay(order,"SHIP",request.idempotencyKey(),request))return snapshot(order);
version(order,request.version());var labels=requireShipmentReady(order,request.leg(),request.labelVersion());
var cargo=cargo(order,request.leg(),labels.manifestId());exactCartons(cargo,request.cartonIds());
if(request.shippedAt()==null || request.shippedAt().isAfter(LocalDateTime.now()))throw bad("请填写真实交承运方时间");
var flow=lifecycle.flow(order.getId());
if(request.leg()==Leg.OVERSEAS) {
if(labels.labels().stream().anyMatch(l->request.shippedAt().isBefore(l.appliedAt())))throw bad("交寄时间不能早于贴单时间");
} else {
if(request.shippedAt().isBefore(((Timestamp)flow.get("domestic_received_at")).toLocalDateTime()))throw bad("交寄时间不能早于国内收货完成");
}
long id=IdWorker.getId();String number="PS-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?)",
id,order.getId(),customer,number,request.leg().name(),labels.manifestId(),request.shippedAt(),serialize(new Addresses(labels.sender(),labels.receiver())),operator);
for(var carton:cargo) {
var label=labels.labels().stream().filter(l->l.cartonId()==carton.cartonId()).findFirst().orElseThrow();
jdbc.update("INSERT INTO ll_procurement_shipment_item(shipment_id,carton_id,quantity,carrier,tracking_no,label_snapshot) VALUES(?,?,?,?,?,CAST(? AS JSON))",
id,carton.cartonId(),carton.quantity(),label.carrier(),label.trackingNo(),serialize(new ShipmentItem(carton,label,labels.version())));
}
String field=request.leg()==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at";
jdbc.update("UPDATE ll_procurement_flow SET "+field+"=? WHERE order_id=?",request.shippedAt(),order.getId());
order.setStatus(request.leg()==Leg.OVERSEAS?"INTERNATIONAL_TRANSIT":"SHIPPED_TO_CUSTOMER");
lifecycle.complete(order,operator,"SHIP",request.idempotencyKey(),request.reason(),request);
return snapshot(order);
}
private LabelSet requireShipmentReady(OverseasProcurementOrder order,Leg leg,int labelVersion) {
open(order);notShipped(order,leg);samplePaid(order);lifecycle.requireNoBlockers(order);finance.requireAdjustmentsComplete(order.getId());
var labels=validLabels(order,leg,labelVersion);
if(leg==Leg.OVERSEAS) {
var estimate=finance.current(order.getId(),ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE");
if(estimate==null || !Set.of("FROZEN","ZERO").contains(estimate.getWalletStatus()))throw conflict("预估国际运费尚未完成冻结或零元核定");
Integer reviewed=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_estimate_review WHERE label_set_id=? AND fee_id=?",Integer.class,labels.id(),estimate.getId());
if(reviewed!=1)throw conflict("当前面单和清单尚未核对有效预估");
if(labels.labels().stream().anyMatch(l->l.appliedAt()==null))throw conflict("本批全部箱须完成贴单确认");
} else {
if(lifecycle.flow(order.getId()).get("charges_confirmed_at")==null)throw conflict("国内整单费用尚未确认");
finance.requireShipmentReady(order.getId());
}
return labels;
}
String shipmentBlockReason(OverseasProcurementOrder order,Leg leg) {
try {
var labels=labels(order.getId(),leg);
requireShipmentReady(order,leg,labels==null?0:labels.version());return null;
} catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}
}
public record Addresses(Address sender,Address receiver) { }
public record ShipmentItem(Cargo cargo,LabelRow label,int labelVersion) { }
private Addresses internationalAddresses(OverseasProcurementOrder order) {
var values=jdbc.queryForList("SELECT address_snapshot FROM ll_procurement_shipment WHERE order_id=? AND leg='OVERSEAS'",String.class,order.getId());
if(values.size()!=1)throw conflict("原国际交寄地址快照缺失");
return deserialize(values.get(0),Addresses.class);
}
private void samplePaid(OverseasProcurementOrder order) {
var fee=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT");
if(fee==null || !finance.sourceLinked(fee))throw conflict("样品货款或费用来源关联尚未完成");
if(lifecycle.flow(order.getId()).get("purchase_issue")!=null)throw conflict("真实购买异常尚未处理");
}
private void notShipped(OverseasProcurementOrder order,Leg leg) {
if(leg==null)throw bad("运输段不能为空");
if(lifecycle.flow(order.getId()).get(leg==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at")!=null)throw conflict("本段已实际交寄,不能重复发出或覆盖历史");
}
private Map<String,Object> manifest(OverseasProcurementOrder order,Leg leg) {
var flow=lifecycle.flow(order.getId());
if(leg==Leg.DOMESTIC && flow.get("domestic_received_at")==null)throw conflict("须国内收货完成后核对本批可寄货物");
var manifest=physical.currentManifest(order.getId());
if(flow.get("overseas_received_at")==null || flow.get("packing_version")==null || manifest==null || !"CONFIRMED".equals(manifest.get("status")))throw conflict("须使用本单已确认的装箱清单");
return manifest;
}
List<Cargo> cargo(OverseasProcurementOrder order,Leg leg,long manifestId) {
var rows=jdbc.queryForList("SELECT * FROM ll_procurement_carton WHERE order_id=? AND manifest_id=? ORDER BY id",order.getId(),manifestId);
List<Cargo> result=new ArrayList<>();
for(var row:rows) {
long id=((Number)row.get("id")).longValue();
List<Source> sources;
if(leg==Leg.OVERSEAS)sources=jdbc.query("SELECT receipt_id,quantity FROM ll_procurement_carton_source WHERE carton_id=? ORDER BY receipt_id",(rs,n)->new Source(rs.getLong(1),rs.getLong(2)),id);
else sources=physical.received(order.getId(),Leg.DOMESTIC,id).stream().filter(r->r.goodQuantity()>0)
.sorted(Comparator.comparingLong(ProcurementPhysicalService.ReceiptRow::id)).map(r->new Source(r.id(),r.goodQuantity())).toList();
long quantity=sources.stream().mapToLong(Source::quantity).reduce(0,Math::addExact);
if(quantity<=0)continue;
var measurements=row;Long packagingRecordId=null;
if(leg==Leg.DOMESTIC) {
var replacements=jdbc.queryForList("SELECT * FROM ll_procurement_repack WHERE order_id=? AND carton_id=? ORDER BY id DESC LIMIT 1",order.getId(),id);
if(!replacements.isEmpty()) {measurements=replacements.get(0);packagingRecordId=((Number)measurements.get("id")).longValue();}
}
result.add(new Cargo(id,String.valueOf(row.get("carton_no")),quantity,(BigDecimal)measurements.get("length_cm"),
(BigDecimal)measurements.get("width_cm"),(BigDecimal)measurements.get("height_cm"),(BigDecimal)measurements.get("gross_weight_kg"),packagingRecordId,sources));
}
return result;
}
private LabelSet validLabels(OverseasProcurementOrder order,Leg leg,int requestedVersion) {
var manifest=manifest(order,leg);var labels=labels(order.getId(),leg);
if(labels==null || labels.version()!=requestedVersion || labels.manifestId()!=((Number)manifest.get("id")).longValue())throw conflict("面单或清单版本已变化,请重新制单核对");
var cargo=cargo(order,leg,labels.manifestId());
if(!labels.cargoHash().equals(hash(cargo)))throw conflict("实收或包装已变化,旧面单不能用于交寄,请重新制单");
exactCartons(cargo,labels.labels().stream().map(LabelRow::cartonId).toList());return labels;
}
private static void exactCartons(List<Cargo> cargo,List<Long> ids) {
Set<Long> expected=new HashSet<>(cargo.stream().map(Cargo::cartonId).toList());
if(cargo.isEmpty() || ids==null || new HashSet<>(ids).size()!=ids.size() || !expected.equals(new HashSet<>(ids)))throw bad("必须覆盖本批全部可寄原箱,不能重复、拆箱、合箱或关联其他订单箱");
}
@Transactional(readOnly=true)
public Snapshot read(String customer,String orderNo) {
return snapshot(lifecycle.requireOrder(customer,orderNo));
}
private Snapshot snapshot(OverseasProcurementOrder order) {
var shipments=jdbc.query("SELECT id,shipment_no,leg,shipped_at FROM ll_procurement_shipment WHERE order_id=? ORDER BY id",
(rs,n)->new Shipment(rs.getLong(1),rs.getString(2),rs.getString(3),rs.getTimestamp(4).toLocalDateTime()),order.getId());
var manifest=physical.currentManifest(order.getId());
Long manifestId=manifest==null?null:((Number)manifest.get("id")).longValue();
return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),labels(order.getId(),Leg.OVERSEAS),labels(order.getId(),Leg.DOMESTIC),shipments,
manifestId==null?List.of():cargo(order,Leg.OVERSEAS,manifestId),manifestId==null?List.of():cargo(order,Leg.DOMESTIC,manifestId));
}
private LabelSet labels(long orderId,Leg leg) {
var rows=jdbc.queryForList("SELECT * FROM ll_procurement_label_set WHERE order_id=? AND leg=? ORDER BY version DESC LIMIT 1",orderId,leg.name());
if(rows.isEmpty())return null;var row=rows.get(0);long id=((Number)row.get("id")).longValue();
var items=jdbc.query("SELECT * FROM ll_procurement_label_item WHERE label_set_id=? ORDER BY carton_id",(rs,n)->new LabelRow(rs.getLong("carton_id"),rs.getLong("file_id"),rs.getInt("page_no"),rs.getString("carrier"),rs.getString("tracking_no"),rs.getTimestamp("applied_at")==null?null:rs.getTimestamp("applied_at").toLocalDateTime()),id);
return new LabelSet(id,((Number)row.get("version")).intValue(),((Number)row.get("manifest_id")).longValue(),String.valueOf(row.get("cargo_hash")),
deserialize(jsonText(row.get("sender_snapshot")),Address.class),deserialize(jsonText(row.get("receiver_snapshot")),Address.class),items);
}
private static String jsonText(Object value){return value instanceof byte[] b?new String(b,java.nio.charset.StandardCharsets.UTF_8):String.valueOf(value);}
private static void address(Address value){if(value==null)throw bad("收寄资料不完整");text(value.name(),128,"收寄姓名");text(value.address(),1000,"收寄地址");if(value.phone()==null || !value.phone().matches("[+0-9 ()-]{5,64}"))throw bad("请填写有效快递联系电话");}
private static void text(String value,int max,String name){if(value==null || value.isBlank() || value.length()>max)throw bad(name+"不能为空或超过长度限制");}
private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("换包装箱规及重量须符合正数与精度要求");}
private String serialize(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
private <T>T deserialize(String value,Class<T> type){try{return json.readValue(value,type);}catch(Exception e){throw new IllegalStateException("采购运输快照无效",e);}}
private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(serialize(value).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class ProcurementWarehouseDirectory {
private final DomesticWarehouseDirectoryClient client;
@Value("${baiyue.security.internal-service-token}") private String token;
public List<DomesticWarehouseDirectoryClient.ReceivingWarehouse> options() {
var result=client.receivingWarehouses(token);
if(result==null || !result.successful() || result.data()==null) throw unavailable();
return result.data();
}
public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id) {
var result=client.receivingWarehouse(id,token);
if(result==null || !result.successful() || result.data()==null) throw unavailable();
var warehouse=result.data();
if(!Long.valueOf(id).equals(warehouse.warehouseId()) || !"ACTIVE".equals(warehouse.status())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"所选仓库不是有效海外收货仓");
}
return warehouse;
}
public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id) {
required(id);
var result=client.activeWarehouse(id,token);
if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable();
var warehouse=result.data().warehouse();
if(!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId())
|| !"OVERSEAS".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) throw unavailable();
var addresses=result.data().addresses();
var operating=addresses==null?List.<DomesticWarehouseDirectoryClient.Address>of():addresses.stream()
.filter(a->"OPERATING".equals(a.addressType())).toList();
if(operating.size()!=1 || warehouse.contactName()==null || warehouse.contactName().isBlank()
|| warehouse.contactPhone()==null || !warehouse.contactPhone().matches("[+0-9 ()-]{5,64}")
|| operating.get(0).addressLine1()==null || operating.get(0).addressLine1().isBlank()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,"实际海外仓的联系人、电话或唯一作业地址不完整,请先维护仓库资料");
}
var a=operating.get(0);
String address=java.util.stream.Stream.of(a.countryCode(),a.stateProvince(),a.city(),a.district(),a.addressLine1(),a.addressLine2(),a.postalCode())
.filter(v->v!=null && !v.isBlank()).collect(java.util.stream.Collectors.joining(" "));
return new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address(warehouse.contactName(),warehouse.contactPhone(),address);
}
private static ResponseStatusException unavailable() {
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"海外收货仓目录暂不可用,请稍后重试");
}
}
@@ -73,6 +73,19 @@ public final class OverseasPermissions {
public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list";
public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit";
public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review";
public static final String PURCHASE_BUY = "platform:overseas-business:purchase:buy";
public static final String PURCHASE_CANCEL = "platform:overseas-business:purchase:cancel";
public static final String PURCHASE_RECEIVE_OVERSEAS = "platform:overseas-business:purchase:receive-overseas";
public static final String PURCHASE_RECEIVE_DOMESTIC = "platform:overseas-business:purchase:receive-domestic";
public static final String PURCHASE_PACK = "platform:overseas-business:purchase:pack";
public static final String PURCHASE_ESTIMATE = "platform:overseas-business:purchase:estimate";
public static final String PURCHASE_APPLY_LABEL = "platform:overseas-business:purchase:apply-label";
public static final String PURCHASE_SHIP_OVERSEAS = "platform:overseas-business:purchase:ship-overseas";
public static final String PURCHASE_SHIP_CUSTOMER = "platform:overseas-business:purchase:ship-customer";
public static final String PURCHASE_MANAGE = "platform:overseas-business:purchase:manage";
public static final String PURCHASE_LABEL = "platform:overseas-business:purchase:label";
public static final String PURCHASE_CHARGE = "platform:overseas-business:purchase:charge";
public static final String PURCHASE_FINANCE_ADJUST = "platform:overseas-business:purchase:finance-adjust";
public static final String TRUCK_LIST = "platform:overseas-business:truck:list";
public static final String TRUCK_CREATE = "platform:overseas-business:truck:create";
public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit";
@@ -27,12 +27,41 @@ public class WalletCommandOutboxService {
public String publish(String operation, long customerId, String businessType, String businessNo,
BigDecimal amount, String remark, long aggregateVersion) {
String eventKey = "WALLET_COMMAND:" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion;
if ("REFUND".equals(operation)) throw new IllegalArgumentException("退款必须关联原扣款流水");
return publishCommand(operation, customerId, businessType, businessNo, amount, remark, aggregateVersion, null);
}
public String publishRefund(long customerId, String businessType, String refundNo, BigDecimal amount,
String originalLedgerId, String remark, long aggregateVersion) {
if (originalLedgerId == null || !originalLedgerId.matches("[1-9][0-9]*")) {
throw new IllegalArgumentException("退款原扣款流水无效");
}
return publishCommand("REFUND", customerId, businessType, refundNo, amount, remark, aggregateVersion, originalLedgerId);
}
private String publishCommand(String operation, long customerId, String businessType, String businessNo,
BigDecimal amount, String remark, long aggregateVersion, String originalLedgerId) {
String eventKey = "WALLET_COMMAND:" + customerId + ':' + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion;
LastlegEventOutbox existing = mapper.findByEventKey(eventKey);
if (existing != null) return eventId(existing.getPayloadJson());
String idempotencyKey = businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion;
if (existing != null) {
try {
EventEnvelope saved = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class);
WalletCommandRequestedV1 command = objectMapper.treeToValue(saved.payload(), WalletCommandRequestedV1.class);
if (command.customerId() != customerId || !operation.equals(command.operation())
|| !businessType.equals(command.businessType()) || !businessNo.equals(command.businessNo())
|| !java.util.Objects.equals(remark, command.remark())
|| !java.util.Objects.equals(originalLedgerId, command.originalLedgerId())
|| (amount == null ? command.amount() != null : command.amount() == null || amount.compareTo(command.amount()) != 0)) {
throw new IllegalStateException("同一资金请求对应不同费用事实");
}
return saved.eventId();
} catch (java.io.IOException exception) {
throw new IllegalStateException("资金指令事件快照损坏", exception);
}
}
String idempotencyKey = customerId + ":" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion;
WalletCommandRequestedV1 value = new WalletCommandRequestedV1(operation, customerId, businessType,
businessNo, amount, remark, idempotencyKey, Instant.now());
businessNo, amount, remark, idempotencyKey, Instant.now(), originalLedgerId);
JsonNode payload = objectMapper.valueToTree(value);
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED,
WalletCommandRequestedV1.VERSION, Instant.now(),
@@ -49,10 +78,6 @@ public class WalletCommandOutboxService {
return envelope.eventId();
}
private String eventId(String json) {
try { return objectMapper.readValue(json, EventEnvelope.class).eventId(); }
catch (Exception exception) { throw new IllegalStateException("资金指令事件快照损坏", exception); }
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (Exception exception) { throw new IllegalStateException("资金指令事件序列化失败", exception); }
@@ -885,6 +885,7 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
wallet_settlement JSON NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用',
business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY',
amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD',
original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1',
@@ -2631,10 +2632,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价',
currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细',
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称',
inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量',
domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号',
overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
@@ -2880,23 +2879,43 @@ CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence (
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
category VARCHAR(16) NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID',
category VARCHAR(16) NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '媒体类型', size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小',
created_by VARCHAR(128) NULL COMMENT '上传人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_file(order_id,file_id), KEY idx_ll_procurement_file_order(customer_id,order_id,category,created_at),
CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER'))
CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'))
) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee (
purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定',
confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作',
fee_version INT NULL COMMENT '同用途费用确认版本',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本',
determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用',
original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD',
original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额',
exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照',
exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份',
business_date DATE NULL COMMENT '实际费用业务日期',
wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录',
wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用',
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称',
amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NOT NULL COMMENT '凭证文件ID',
voucher_file_name VARCHAR(255) NOT NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明',
amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NULL COMMENT '凭证文件ID',
voucher_file_name VARCHAR(255) NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明',
wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明',
wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) NULL 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 '更新时间',
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at),
CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY')
CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY')
) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期';
@@ -3218,3 +3237,240 @@ CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
PRIMARY KEY(operator_id),
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
CREATE TABLE IF NOT EXISTS ll_procurement_wallet_attempt (
id BIGINT NOT NULL COMMENT '请求尝试ID',
order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID',
attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号',
operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款',
business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号',
amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空',
command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用',
status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败',
result_hash CHAR(64) NULL COMMENT '原业务回执摘要',
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用',
error_code VARCHAR(64) NULL COMMENT '业务失败码',
error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间',
completed_at DATETIME(3) NULL COMMENT '最终结果时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id),
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
KEY idx_procurement_attempt_order(customer_id,order_id,status),
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。
CREATE TABLE IF NOT EXISTS ll_procurement_flow (
order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
purchased_at DATETIME(3) NULL COMMENT '真实购买时间',
purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品',
purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格',
purchased_quantity BIGINT NULL COMMENT '实际购买件数',
purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额',
purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD',
purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用',
purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明',
purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功',
actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID',
actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照',
actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照',
overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间',
packing_version INT NULL COMMENT '当前确认装箱清单版本',
overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间',
domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间',
charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间',
customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间',
domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人',
domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话',
domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址',
cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间',
closed_at DATETIME(3) NULL COMMENT '异常关闭时间',
control_reason VARCHAR(1000) NULL 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 '更新时间',
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
CREATE TABLE IF NOT EXISTS ll_procurement_command (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作',
idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据',
detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
CREATE TABLE IF NOT EXISTS ll_procurement_budget_authorization (
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种',
authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种',
voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据',
reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间',
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。
CREATE TABLE IF NOT EXISTS ll_procurement_receipt (
id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内',
source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓',
good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量',
received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实',
supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
CREATE TABLE IF NOT EXISTS ll_procurement_disposition (
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源',
receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货',
quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实',
voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
CREATE TABLE IF NOT EXISTS ll_procurement_manifest (
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代',
confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
CREATE TABLE IF NOT EXISTS ll_procurement_carton (
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米',
width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米',
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
CREATE TABLE IF NOT EXISTS ll_procurement_carton_source (
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
CREATE TABLE IF NOT EXISTS ll_procurement_shipment (
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户',
manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间',
address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item (
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。
CREATE TABLE IF NOT EXISTS ll_procurement_label_set (
id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本',
manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要',
sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照',
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
CREATE TABLE IF NOT EXISTS ll_procurement_label_item (
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始',
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
CREATE TABLE IF NOT EXISTS ll_procurement_estimate_review (
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
CREATE TABLE IF NOT EXISTS ll_procurement_repack (
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米',
height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克',
voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据',
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。
CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction (
id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根',
idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要',
target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种',
exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份',
target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY',
difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录',
voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据',
created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction_item (
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。
CREATE TABLE IF NOT EXISTS ll_procurement_control_note (
id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型',
target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约',
voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明',
client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容',
original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
CREATE TABLE IF NOT EXISTS ll_procurement_closure (
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证',
financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实',
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
CREATE TABLE IF NOT EXISTS ll_procurement_closure_item (
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空',
quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置',
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
@@ -1012,8 +1012,8 @@ paths:
content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } }
/api/platform/v1/last-leg/overseas-procurements/warehouse-options:
get:
summary: 查询启用的海外合作采购仓候选
description: 权限 platform:overseas-business:purchase:list;复用服务端缓存目录,浏览器不访问国内内部 API。
summary: 查询有效公共海外收货仓候选
description: 权限 platform:overseas-business:purchase:list;固定国内权威公共 OVERSEAS、ACTIVE、RECEIVING 仓,浏览器不访问内部 API。
security: [{ bearerAuth: [] }]
responses:
'200':
@@ -1027,10 +1027,10 @@ paths:
summary: 查询海外采购订单详情及操作流水
description: 权限 platform:overseas-business:purchase:list。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
responses: { '200': { description: 后台采购聚合详情及服务端操作资格, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementPlatformResponse' } } } } }
patch:
summary: 编辑海外采购订单
description: 权限 platform:overseas-business:purchase:edit;可选字段缺失表示不修改,显式 null 或空串表示清空。采购仓名称由 warehouse ID 对应候选快照决定。采购出库不属于本 API。
description: 权限 platform:overseas-business:purchase:edit;仅编辑内部备注,字段缺失表示不修改,显式 null 或空串表示清空。商品、仓库、数量和运输字段由各履约节点维护,提交这些旧字段会被拒绝。
security: [{ bearerAuth: [] }]
requestBody:
required: true
@@ -1246,7 +1246,7 @@ paths:
description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } }
responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } }
responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse' } } } }, '409': { description: 幂等冲突 } }
get:
summary: 客户分页查询自己的海外采购订单
description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。
@@ -1264,11 +1264,11 @@ paths:
responses: { '200': { description: 当前客户采购订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementPageResponse' } } } } }
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:
get:
summary: 客户查询自己的海外采购订单详情及流水
summary: 客户查询自己的采购履约、箱明细、运单及费用
description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
/api/merchant/v1/last-leg/us-truck-orders:
get:
summary: 客户分页查询自己的美国本土卡订单
@@ -3220,11 +3220,6 @@ components:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/ProcurementPage' } }
ProcurementDetailResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/ProcurementDetail' } }
TruckPageResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
@@ -3327,7 +3322,7 @@ components:
properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } }
ProcurementSummary:
type: object
required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, inboundQuantity, outboundQuantity, inventoryQuantity, status, version, createdAt, updatedAt]
required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, status, version, createdAt, updatedAt]
properties:
id: { type: string, pattern: '^[0-9]+$' }
orderNo: { type: string }
@@ -3341,43 +3336,10 @@ components:
currency: { type: string, nullable: true }
purchaseWarehouseId: { type: string, nullable: true }
purchaseWarehouseName: { type: string, nullable: true }
inboundQuantity: { type: integer, format: int64 }
outboundQuantity: { type: integer, format: int64 }
inventoryQuantity: { type: integer, format: int64 }
status: { type: string, description: PENDING_REVIEW/APPROVED/REJECTED 或按审核后出库数量派生的 PENDING_OUTBOUND/PARTIALLY_OUTBOUND/OUTBOUNDED }
status: { type: string, description: 以 ProcurementStatus 中的真实履约阶段返回,不按通用出库数量推断 }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
ProcurementDetail:
type: object
required: [summary, histories, outboundDetails]
properties:
summary: { $ref: '#/components/schemas/ProcurementSummary' }
productUrl: { type: string, nullable: true }
receivingAddress: { type: string, nullable: true }
contactName: { type: string, nullable: true }
contactPhone: { type: string, nullable: true }
domesticCarrier: { type: string, nullable: true }
domesticTrackingNo: { type: string, nullable: true }
overseasCarrier: { type: string, nullable: true }
overseasTrackingNo: { type: string, nullable: true }
estimatedArrivalAt: { type: string, format: date-time, nullable: true }
remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true }
reviewedBy: { type: string, nullable: true }
reviewRemark: { type: string, nullable: true }
reviewedAt: { type: string, format: date-time, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } }
ProcurementOutboundDetail:
type: object
properties:
id: { type: string }
quantity: { type: integer, format: int64 }
carrier: { type: string, nullable: true }
trackingNo: { type: string, nullable: true }
shippedAt: { type: string, format: date-time, nullable: true }
remark: { type: string, nullable: true }
TruckPage:
allOf:
- { $ref: '#/components/schemas/PageMetadata' }
@@ -3720,18 +3682,20 @@ components:
actualVolumeCbm: { type: number, nullable: true }
status: { type: string }
version: { type: integer }
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, SHIPPED_TO_CUSTOMER, REJECTED, CANCELLED, EXCEPTION_CLOSED] }
ProcurementReview:
type: object
required: [version, decision, idempotencyKey]
description: 审核通过必须选择有效公共海外收货仓;拒绝不要求仓库。
properties:
version: { type: integer, format: int64, minimum: 0 }
decision: { type: string, enum: [APPROVE, REJECT] }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
remark: { type: string, nullable: true, maxLength: 1000 }
purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true }
ProcurementCreate:
type: object
required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey]
required: [purchaseCategory, productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey]
properties:
customerName: { type: string, nullable: true, maxLength: 255 }
purchaseCategory: { type: string, maxLength: 128 }
@@ -3739,9 +3703,9 @@ components:
productSpecification: { type: string, nullable: true, maxLength: 500 }
productUrl: { type: string, nullable: true, maxLength: 2000 }
purchaseQuantity: { type: integer, format: int64, minimum: 1 }
budgetUnitPrice: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
receivingAddress: { type: string, nullable: true, maxLength: 1000 }
budgetUnitPrice: { type: number, minimum: 0, multipleOf: 0.01, description: 客户授权的货款预算单价;零表示仅授权免费样品 }
currency: { type: string, enum: [CNY, USD] }
receivingAddress: { type: string, minLength: 1, maxLength: 1000, description: 最终客户国内收件完整地址 }
contactName: { type: string, maxLength: 128 }
contactPhone: { type: string, maxLength: 64 }
remark: { type: string, nullable: true, maxLength: 1000 }
@@ -3749,35 +3713,12 @@ components:
NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 }
ProcurementUpdate:
type: object
additionalProperties: false
description: 只维护内部备注;商品、数量、仓库、实收和运输必须通过独立节点操作。
required: [version]
properties:
version: { type: integer, format: int64, minimum: 0 }
purchaseCategory: { type: string, maxLength: 128 }
productName: { type: string, maxLength: 255 }
productSpecification: { $ref: '#/components/schemas/NullableTextPatch' }
productUrl: { $ref: '#/components/schemas/NullableTextPatch' }
inboundQuantity: { type: integer, format: int64, minimum: 0 }
purchaseWarehouseId: { $ref: '#/components/schemas/NullableTextPatch' }
purchaseWarehouseName: { $ref: '#/components/schemas/NullableTextPatch' }
domesticCarrier: { $ref: '#/components/schemas/NullableTextPatch' }
domesticTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' }
overseasCarrier: { $ref: '#/components/schemas/NullableTextPatch' }
overseasTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' }
estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 缺失不修改;null 或空串清空 }
remark: { $ref: '#/components/schemas/NullableTextPatch' }
outboundDetails:
type: array
description: 出库明细,可一次提交多条;总出库量不得超过入库量
items:
type: object
required: [quantity]
properties:
id: { type: string }
quantity: { type: integer, format: int64, minimum: 0 }
carrier: { type: string, nullable: true, maxLength: 128 }
trackingNo: { type: string, nullable: true, maxLength: 128 }
shippedAt: { type: string, format: date-time, nullable: true }
remark: { type: string, nullable: true, maxLength: 500 }
CustomerOptionResponse:
type: object
properties:
@@ -13,7 +13,7 @@ paths:
responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } }
post:
summary: 创建海外业务费用来源草稿
description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。
description: 采购费用仅由采购履约或财务节点实扣后自动发布,本入口拒绝采购来源或采购业务类型。客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。
security: [{ bearerAuth: [] }]
parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } }
@@ -34,7 +34,7 @@ paths:
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}:
post:
summary: 提交、审核、调整或作废海外业务费用来源
description: 审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。
description: 采购来源不接受通用提交、审核、调整或作废,须回到采购财务节点处理。审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。
security: [{ bearerAuth: [] }]
parameters:
- { in: path, name: sourceFeeId, required: true, schema: { type: string } }
@@ -70,8 +70,8 @@ components:
required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount]
properties:
customerId: { type: string }
sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] }
businessType: { $ref: '#/components/schemas/BusinessType' }
sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] }
businessType: { type: string, enum: [OVERSEAS_INBOUND, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] }
businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
businessNo: { type: string, maxLength: 128 }
containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
File diff suppressed because it is too large Load Diff
@@ -30,7 +30,7 @@ class DomesticWarehouseDirectoryClientContractTest {
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents())
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
.containsExactly("id:Long", "tenantId:Long", "warehouseCode:String", "warehouseName:String",
"warehouseType:String", "countryCode:String", "status:String");
"warehouseType:String", "countryCode:String", "status:String", "contactName:String", "contactPhone:String");
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents())
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
.containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String",
@@ -59,7 +59,7 @@ class DispatchBolPrintServiceTest {
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
null, "91730", "10767 7th Street", null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE");
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
.thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address))));
@@ -89,7 +89,7 @@ class DispatchBolPrintServiceTest {
new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse",
"NYEXX2609110215", null, null, 1)), null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE");
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
.thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of())));
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.WalletSettlementReference;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementSettledFeeSourceTest {
@Test
void publishesActualFeeUsingConfirmedFxAndConsumptionReference() {
var mapper = mock(OverseasFeeSourceMapper.class);
var outbox = mock(OverseasFeeOutboxService.class);
var converter = mock(OverseasFeeCurrencyConverter.class);
var service = new OverseasFeeSourceService(mapper, mock(OverseasFeeCommandMapper.class),
mock(OverseasFeeBusinessGuard.class), outbox, converter, new ObjectMapper().findAndRegisterModules(),
mock(OverseasFeeCancellation.class));
SaveRequest fee = new SaveRequest("9", "customer", "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", 11L,
"PF-1", 12L, null, null, null, null, null, null, "SAMPLE_PAYMENT", "样品货款", "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 10, 9), "USD",
new BigDecimal("10.00"), BigDecimal.ONE, "ITEM", new BigDecimal("10.00"), null, null);
assertThatThrownBy(() -> service.create("7", "manual-source", fee)).hasMessageContaining("采购费用必须通过");
assertThatThrownBy(() -> service.publishApproved("7", "manual-approved", fee)).hasMessageContaining("采购费用必须通过");
verifyNoInteractions(mapper,outbox,converter);
var conversion = new OverseasFeeCurrencyConverter.Conversion("USD", new BigDecimal("10.00"),
new BigDecimal("7.200000"), "2026-10", new BigDecimal("72.00"));
var reference = new WalletSettlementReference("command-1", "1", "2");
OverseasFeeSource result = service.publishSettled("7", "source-1", fee, conversion, reference);
assertThat(result.getAmount()).isEqualByComparingTo("72");
assertThat(result.getWalletSettlement()).isEqualTo(reference);
assertThat(result.getReviewStatus()).isEqualTo("APPROVED");
assertThat(result.getPublicationStatus()).isEqualTo("PUBLISHED");
verify(outbox).publish(result, "PUBLISHED");
verify(mapper).insert(result);
verifyNoInteractions(converter);
assertThatThrownBy(() -> service.publishSettled("7", "source-2", fee, conversion, null))
.hasMessageContaining("快照不完整");
}
}
@@ -60,7 +60,7 @@ class MerchantOverseasOpenApiContractTest {
.contains("merchant:overseas-warehouse:return-order:create")
.contains("merchant:overseas-warehouse:return-order:relabel")
.contains("merchant:warehouse:inbound:list")
.contains("#/components/schemas/ProcurementDetailResponse")
.contains("procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse")
.contains("#/components/schemas/TruckDetailResponse")
.contains("#/components/schemas/LooseboardDetailResponse")
.contains("#/components/schemas/InboundPlanDetailResponse")
@@ -68,6 +68,10 @@ class MerchantOverseasOpenApiContractTest {
.contains("overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }")
.contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE")
.contains("version 必填").contains("幂等冲突");
for(String path:new String[]{"/api/merchant/v1/last-leg/overseas-procurements:","/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:"}) {
assertThat(pathBlock(yaml,path)).contains("ProcurementCustomerResponse")
.doesNotContain("ProcurementPlatformResponse", "ProcurementDetailResponse");
}
assertThat(yaml).contains("/api/platform/v1/last-leg/inbound-plans:")
.contains("/api/platform/v1/last-leg/inbound-plans/{planNo}:")
.contains("overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实")
@@ -26,6 +26,8 @@ class OverseasProcurementInventoryBoundaryTest {
String responses = Files.readString(Path.of(
"src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java"),
StandardCharsets.UTF_8);
assertTrue(responses.contains("outboundQuantity"));
assertFalse(responses.contains("outboundQuantity"));
assertFalse(responses.contains("inventoryQuantity"));
assertFalse(responses.contains("OutboundDetail"));
}
}
@@ -22,7 +22,9 @@ class OverseasProcurementSchemaContractTest {
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains("review_request_hash CHAR(64) NULL"));
assertTrue(sql.contains("outbound_details JSON NULL COMMENT '出库明细'"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_details"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item"));
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
assertTrue(sql.contains("change_detail JSON NULL"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
@@ -41,7 +43,8 @@ class OverseasProcurementSchemaContractTest {
String order = tableBlock(lower, "ll_overseas_procurement_order");
assertTrue(order.contains("review_idempotency_key varchar(255)"));
assertTrue(order.contains("review_request_hash char(64)"));
assertTrue(order.contains("outbound_details json"));
org.junit.jupiter.api.Assertions.assertFalse(order.contains("outbound_details"));
org.junit.jupiter.api.Assertions.assertFalse(order.contains("tracking_no"));
assertTrue(tableBlock(lower, "ll_wallet_event_inbox").contains("payload_hash varchar(71)"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_overseas_procurement"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_wallet_event_inbox"));
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.procurement;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses.*;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class ProcurementRequestBoundaryTest {
@Test void missingCommandVersionCannotDefaultToZero() throws Exception {
var json=new ObjectMapper().findAndRegisterModules();
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();
for(var type:List.of(com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command.class,
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Purchase.class,
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget.class,
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Warehouse.class)) {
assertThat(validator.validate(json.readValue("{}",type))).anyMatch(v->"version".equals(v.getPropertyPath().toString()));
}
}
}
@Test void legacyGenericFieldsCannotSilentlyBypassDedicatedNodes() {
var json=new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
for(String field:List.of("inboundQuantity","outboundDetails","productName","purchaseWarehouseId","domesticTrackingNo")) {
assertThatThrownBy(()->json.readValue("{\"version\":0,\""+field+"\":null}",OverseasProcurementRequests.Update.class))
.hasMessageContaining("采购通用编辑仅支持备注");
}
}
@Test void customerProjectionExcludesInternalFilesAndFinancialDiagnostics() throws Exception {
var fee=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFee();
fee.setFeeNo("PF-1");fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");
fee.setVoucherFileId(2L);fee.setVoucherFileName("private.pdf");fee.setWalletErrorMessage("database error");fee.setRemark("internal note");fee.setCreatedBy("operator");fee.setWalletLedgerId("private-ledger");
var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.from(fee,null,false));
assertThat(output).contains("PF-1","FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger");
}
}
@@ -20,6 +20,7 @@ import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class OverseasProcurementControllerTest {
@Mock OverseasProcurementService service;
@Mock com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
private MerchantOverseasProcurementController merchant;
private PlatformOverseasProcurementController platform;
@@ -29,6 +30,8 @@ class OverseasProcurementControllerTest {
platform = new PlatformOverseasProcurementController();
ReflectionTestUtils.setField(merchant, "service", service);
ReflectionTestUtils.setField(platform, "service", service);
ReflectionTestUtils.setField(merchant,"queryService",queryService);
ReflectionTestUtils.setField(platform,"queryService",queryService);
}
@Test
@@ -37,10 +40,19 @@ class OverseasProcurementControllerTest {
merchant.page(user(100L, "MERCHANT", Set.of()), query);
verify(service).page("100", query.scoped());
}
@Test void customerDetailUsesOnlyTrustedCustomerAndDedicatedProjection() {
merchant.detail(user(100L,"MERCHANT",Set.of()),"PO-1");
verify(queryService).customer("100","PO-1");
}
@Test void platformDetailAggregatesWithCurrentActionPermissions() {
var permissions=Set.of("platform:overseas-business:purchase:list");
platform.detail(user(0L,"PLATFORM",permissions),"100","PO-1");
verify(queryService).platform("100","PO-1",permissions);
}
@Test
void reviewRequiresExplicitPermission() {
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null);
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null, 10L);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request));
assertEquals(403, error.getStatusCode().value());
@@ -56,7 +68,7 @@ class OverseasProcurementControllerTest {
@Test
void platformReviewUsesExplicitTargetTenant() {
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok");
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok", 10L);
platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)),
"100", "HC1", request);
verify(service).review("100", "HC1", "7", request);
@@ -64,8 +76,7 @@ class OverseasProcurementControllerTest {
@Test
void platformUpdatePassesAuditedOperator() {
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
null, null, null, null, null, null, null, null, null);
var request = new OverseasProcurementRequests.Update(0L, null);
platform.update(user(0L, "PLATFORM", Set.of(OverseasProcurementService.MANAGE_PERMISSION)),
"100", "HC1", request);
verify(service).update("100", "HC1", "7", request);
@@ -0,0 +1,141 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService;
import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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.time.LocalDateTime;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class ProcurementLifecycleAuthorizationTest {
private final AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(Config.class);
private final ProcurementLifecycleController controller=context.getBean(ProcurementLifecycleController.class);
private final ProcurementLifecycleService service=context.getBean(ProcurementLifecycleService.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
@Bean ProcurementLifecycleService service(){return mock(ProcurementLifecycleService.class);}
@Bean ProcurementLifecycleController controller(ProcurementLifecycleService service){return new ProcurementLifecycleController(service);}
@Bean ProcurementPhysicalService physicalService(){return mock(ProcurementPhysicalService.class);}
@Bean ProcurementPhysicalController physicalController(ProcurementPhysicalService service){return new ProcurementPhysicalController(service);}
// Register the mock directly: its legacy @Resource fields must not be autowired in this controller-only test.
@Bean static org.springframework.beans.factory.config.BeanFactoryPostProcessor orderMock(){
return factory -> factory.registerSingleton("orders",mock(OverseasProcurementService.class));
}
@Bean ProcurementFileController files(OverseasProcurementService service){return new ProcurementFileController(service);}
@Bean com.baiyue.lastleg.procurement.service.ProcurementTransportService transportService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementTransportService.class);}
@Bean ProcurementTransportController transport(com.baiyue.lastleg.procurement.service.ProcurementTransportService service){return new ProcurementTransportController(service);}
@Bean com.baiyue.lastleg.procurement.service.ProcurementChargeService chargeService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementChargeService.class);}
@Bean com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService adjustmentService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class);}
@Bean ProcurementChargeController charges(com.baiyue.lastleg.procurement.service.ProcurementChargeService service,com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService adjustments){return new ProcurementChargeController(service,adjustments);}
@Bean com.baiyue.lastleg.procurement.service.ProcurementControlService controlService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementControlService.class);}
@Bean ProcurementControlController controls(com.baiyue.lastleg.procurement.service.ProcurementControlService service){return new ProcurementControlController(service);}
}
@AfterEach void cleanup(){SecurityContextHolder.clearContext();context.close();}
@Test void listPermissionDoesNotGrantBuyingOrCancelling() {
var user=login(0,"PLATFORM","list");controller.detail(user,"9","PO-1");
verify(service).read("9","PO-1");
assertThatThrownBy(()->controller.purchase(user,"9","PO-1",purchase())).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.cancel(user,"9","PO-1",new Command(0L,"key","reason"))).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
@Test void buyerCannotAuthorizeItsOwnBudgetWithoutReviewPermission() {
var user=login(0,"PLATFORM","buy");var purchase=purchase();controller.purchase(user,"9","PO-1",purchase);
verify(service).recordPurchase("9","PO-1","7",purchase);
assertThatThrownBy(()->controller.budget(user,"9","PO-1",new Budget(0L,"budget",BigDecimal.TEN,"CNY",1,"reason")))
.isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
@Test void customerIdentityCannotUsePlatformActionEvenWithInjectedAuthority() {
var user=login(9,"MERCHANT","buy");
assertThatThrownBy(()->controller.purchase(user,"9","PO-1",purchase())).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(service);
}
@Test void overseasReceiverCannotPerformDomesticReceivingOrApproveDiscrepancy() {
var physical=context.getBean(ProcurementPhysicalController.class);var implementation=context.getBean(ProcurementPhysicalService.class);
var user=login(0,"PLATFORM","receive-overseas");
var receipt=new ProcurementPhysicalRequests.Receipt(0L,"r",ProcurementPhysicalRequests.Leg.OVERSEAS,91L,null,1,0,0,LocalDateTime.now().minusHours(1),"actual");
physical.overseas(user,"9","PO-1",receipt);verify(implementation).receive("9","PO-1","7",receipt);
assertThatThrownBy(()->physical.domestic(user,"9","PO-1",receipt)).isInstanceOf(AccessDeniedException.class);
var decision=new ProcurementPhysicalRequests.Disposition(0L,"d",ProcurementPhysicalRequests.Leg.OVERSEAS,null,null,"SHORTAGE",1,"ACCEPT_SHORTAGE",1,"confirmed");
assertThatThrownBy(()->physical.dispose(user,"9","PO-1",decision)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(implementation);
}
@Test void labelUploadDoesNotGrantFinancialVoucherOrBudgetUpload() {
var files=context.getBean(ProcurementFileController.class);var orders=context.getBean(OverseasProcurementService.class);
var user=login(0,"PLATFORM","label");var file=new org.springframework.mock.web.MockMultipartFile("file","label.pdf","application/pdf","%PDF-1.4".getBytes());
files.upload(user,"9","PO-1","LABEL",file);verify(orders).uploadFile("9","PO-1","7","LABEL",file);
assertThatThrownBy(()->files.upload(user,"9","PO-1","VOUCHER",file)).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->files.upload(user,"9","PO-1","BUDGET",file)).isInstanceOf(ResponseStatusException.class);
verifyNoMoreInteractions(orders);
}
@Test void overseasShippingDoesNotGrantDomesticHandoffOrEstimatePermission() {
var transport=context.getBean(ProcurementTransportController.class);
var service=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementTransportService.class);
var user=login(0,"PLATFORM","ship-overseas");
var request=new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Ship(0L,"ship",ProcurementPhysicalRequests.Leg.OVERSEAS,1,java.util.List.of(1L),LocalDateTime.now(),"actual");
transport.overseas(user,"9","PO-1",request);verify(service).ship("9","PO-1","7",request);
assertThatThrownBy(()->transport.customer(user,"9","PO-1",request)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->transport.estimate(user,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
@Test void costConfirmationDoesNotGrantFinancialCorrectionAndRetryMatchesFeePurpose() {
var charges=context.getBean(ProcurementChargeController.class);
var service=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementChargeService.class);
var user=login(0,"PLATFORM","charge");
assertThatThrownBy(()->charges.additional(user,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->charges.correct(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->charges.refund(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->charges.replaceFailed(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class);
when(service.retryPermission("9","PO-1",1)).thenReturn("platform:overseas-business:purchase:finance-adjust");
var command=new Command(0L,"retry","retry confirmed failure");
assertThatThrownBy(()->charges.retry(user,"9","PO-1",1,command)).isInstanceOf(ResponseStatusException.class);
verify(service,never()).retry(anyString(),anyString(),anyString(),anyLong(),any());
when(service.retryPermission("9","PO-1",2)).thenReturn("platform:overseas-business:purchase:charge");
charges.retry(user,"9","PO-1",2,command);verify(service).retry("9","PO-1","7",2,command);
}
@Test void financeMayReplaceFailedFeeButCustomerIdentityStillCannotUseThePlatformBoundary() {
var charges=context.getBean(ProcurementChargeController.class);var adjustments=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class);
var request=new com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Correct(0L,"replacement",BigDecimal.TEN,1,"replacement proof");
var user=login(0,"PLATFORM","finance-adjust");charges.replaceFailed(user,"9","PO-1",1,request);verify(adjustments).replaceFailed("9","PO-1","7",1,request);
var customer=login(9,"MERCHANT","finance-adjust");assertThatThrownBy(()->charges.replaceFailed(customer,"9","PO-1",1,request)).isInstanceOf(ResponseStatusException.class);verifyNoMoreInteractions(adjustments);
}
@Test void financialAdditionalUsesPlatformOnlyFinancialBoundary() {
var charges=context.getBean(ProcurementChargeController.class);
var adjustments=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class);
var request=new com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Additional(0L,"additional","Storage",BigDecimal.TEN,"CNY",java.time.LocalDate.now(),1,"invoice");
var user=login(0,"PLATFORM","finance-adjust");charges.additional(user,"9","PO-1",request);
verify(adjustments).additional("9","PO-1","7",request);
var customer=login(9,"MERCHANT","finance-adjust");
assertThatThrownBy(()->charges.additional(customer,"9","PO-1",request)).isInstanceOf(ResponseStatusException.class);
verifyNoMoreInteractions(adjustments);
}
@Test void receivingPermissionCannotCloseOrderAndManagerCannotReleaseFunds() {
var controls=context.getBean(ProcurementControlController.class);
var receiver=login(0,"PLATFORM","receive-overseas");
assertThatThrownBy(()->controls.close(receiver,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controls.warehouse(receiver,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class);
var manager=login(0,"PLATFORM","manage");var charges=context.getBean(ProcurementChargeController.class);
assertThatThrownBy(()->charges.releaseEstimate(manager,"9","PO-1",1,new Command(0L,"release","close order"))).isInstanceOf(AccessDeniedException.class);
}
private AuthenticatedUser login(long customer,String app,String suffix) {
String permission="platform:overseas-business:purchase:"+suffix;
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",java.util.List.of(new SimpleGrantedAuthority(permission))));
return user;
}
private Purchase purchase(){return new Purchase(0L,"purchase","Sample",null,1,BigDecimal.TEN,"CNY",LocalDateTime.now().minusDays(1),LocalDateTime.now().plusDays(1),1,"actual purchase");}
}
@@ -42,7 +42,7 @@ import static org.mockito.Mockito.when;
class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementStatusHistoryMapper histories;
@Mock PartnerWarehouseDirectory warehouseDirectory;
@Mock ProcurementWarehouseDirectory warehouseDirectory;
@Mock ProcurementOrderNumberGenerator orderNoGenerator;
@Mock OverseasProcurementFileMapper procurementFiles;
@Mock OverseasProcurementFeeMapper procurementFees;
@@ -62,9 +62,6 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
ReflectionTestUtils.setField(service, "feeSources", feeSources);
lenient().when(feeSources.publishApproved(anyString(), anyString(), any())).thenReturn(new OverseasFeeSource());
lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001");
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
lenient().when(procurementFees.selectList(any())).thenReturn(List.of());
@@ -85,117 +82,34 @@ class OverseasProcurementServiceTest {
new BigDecimal("26.50"), "USD", "Ningbo", "Buyer", "13800000000", null, "key-1"));
assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status());
assertEquals(0, detail.summary().inboundQuantity());
assertEquals(0, detail.summary().outboundQuantity());
assertEquals(0, detail.summary().inventoryQuantity());
}
@Test
void outboundQuantityAndDisplayStatusFollowOutboundDetails() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 90, 0);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
order.setOutboundDetails("[]");
assertEquals("PENDING_OUTBOUND", service.detail("100", "HC1").summary().status());
order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":1}]");
var partial = service.detail("100", "HC1").summary();
assertEquals(1, partial.outboundQuantity());
assertEquals(89, partial.inventoryQuantity());
assertEquals("PARTIALLY_OUTBOUND", partial.status());
order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":90}]");
var completed = service.detail("100", "HC1").summary();
assertEquals(90, completed.outboundQuantity());
assertEquals(0, completed.inventoryQuantity());
assertEquals("OUTBOUNDED", completed.status());
}
@Test
void pendingOrderCannotReceiveInventory() {
OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0);
when(orders.lockNo("100", "HC1")).thenReturn(order);
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
100L, null, null, null, null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.update("100", "HC1", "7", request));
assertEquals(409, error.getStatusCode().value());
}
@Test
void explicitNullClearsOptionalFieldsAndWritesOperationHistory() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
order.setProductUrl("https://example.test/old");
void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() {
OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0);
order.setRemark("old");
order.setEstimatedArrivalAt(LocalDateTime.of(2026,10,10,12,0));
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var request = new OverseasProcurementRequests.Update(0L, null, null,
new ObjectMapper().nullNode(), new ObjectMapper().nullNode(), null,
null, null, null, null, null, null, null, null);
service.update("100", "HC1", "7", request);
assertEquals(null, order.getProductUrl());
verify(histories).insert(org.mockito.ArgumentMatchers.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
"UPDATE".equals(value.getOperationType()) && value.getChangeDetail().contains("productUrl")));
service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(0L, new ObjectMapper().nullNode()));
assertEquals(null, order.getRemark());
assertEquals(LocalDateTime.of(2026,10,10,12,0), order.getEstimatedArrivalAt());
assertEquals(100L, order.getInboundQuantity());
assertEquals(List.of("version", "remark"), java.util.Arrays.stream(OverseasProcurementRequests.Update.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList());
}
@Test
void omittedOptionalFieldKeepsExistingValue() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
order.setProductUrl("https://example.test/keep");
void omittedRemarkKeepsExistingValueAndStaleVersionCannotWrite() {
OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0);
order.setRemark("keep");
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
null, null, null, null, null, null, null, null, null);
service.update("100", "HC1", "7", request);
assertEquals("https://example.test/keep", order.getProductUrl());
service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(0L, null));
assertEquals("keep", order.getRemark());
verify(orders, never()).updateById(org.mockito.ArgumentMatchers.<OverseasProcurementOrder>any());
}
@Test
void warehouseSelectionFreezesCanonicalCandidateName() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
when(warehouseDirectory.required(10L)).thenReturn(
new OverseasQuoteResponses.PartnerWarehouseOption("10", "LAX-01", "洛杉矶采购仓", "US"));
ObjectMapper mapper = new ObjectMapper();
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
null, mapper.getNodeFactory().textNode("10"), mapper.getNodeFactory().textNode("伪造名称"),
null, null, null, null, null, null);
service.update("100", "HC1", "7", request);
assertEquals("10", order.getPurchaseWarehouseId());
assertEquals("洛杉矶采购仓", order.getPurchaseWarehouseName());
}
@Test
void unchangedHistoricalWarehouseDoesNotBlockUnrelatedEditsAfterDeactivation() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
order.setPurchaseWarehouseId("10");
order.setPurchaseWarehouseName("历史采购仓");
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
ObjectMapper mapper = new ObjectMapper();
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
null, mapper.getNodeFactory().textNode("10"), null,
null, null, null, null, null, mapper.getNodeFactory().textNode("更新出库信息"));
service.update("100", "HC1", "7", request);
assertEquals("10", order.getPurchaseWarehouseId());
assertEquals("历史采购仓", order.getPurchaseWarehouseName());
verify(warehouseDirectory, never()).required(10L);
verify(orders).updateById(order);
assertThrows(ResponseStatusException.class, () -> service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(2L, null)));
}
@Test
@@ -218,25 +132,26 @@ class OverseasProcurementServiceTest {
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved");
when(warehouseDirectory.required(10L)).thenReturn(new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(10L,"LAX","洛杉矶","US","ACTIVE"));
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved", 10L);
var approved = service.review("100", "HC1", "operator-7", request);
assertEquals(OverseasProcurementService.APPROVED, approved.summary().status());
assertEquals(OverseasProcurementService.WAIT_PURCHASE, approved.summary().status());
assertEquals(1L, approved.summary().version());
assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version());
verify(histories).insert(org.mockito.ArgumentMatchers
.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
"REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId())
&& "approve-1".equals(value.getIdempotencyKey())
&& OverseasProcurementService.APPROVED.equals(value.getToStatus())));
&& OverseasProcurementService.WAIT_PURCHASE.equals(value.getToStatus())));
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload")));
new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload", 10L)));
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version")));
new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version", 10L)));
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse")));
assertEquals(OverseasProcurementService.APPROVED, order.getStatus());
new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse", 10L)));
assertEquals(OverseasProcurementService.WAIT_PURCHASE, order.getStatus());
}
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
@@ -256,11 +171,23 @@ class OverseasProcurementServiceTest {
@Test
void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception {
for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId",
"purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier",
"overseasTrackingNo", "estimatedArrivalAt", "remark")) {
"purchaseWarehouseName", "estimatedArrivalAt", "remark")) {
TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class);
assertNotNull(annotation, field);
assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field);
}
}
@Test
void fileDownloadRequiresExactOrderAssociationBeforeCallingFileService() {
var order=order(OverseasProcurementService.WAIT_PURCHASE,0,0);
when(orders.findNo("100","HC1")).thenReturn(order);
assertThrows(ResponseStatusException.class,()->service.downloadFile("100","HC1",999));
org.mockito.Mockito.verifyNoInteractions(fileGateway);
var file=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFile();file.setCategory("LABEL");file.setFileName("label.pdf");
when(procurementFiles.selectOne(any())).thenReturn(file);
when(fileGateway.download("100",1,"LABEL",42)).thenReturn(new ProcurementFileGateway.Download(new byte[]{1},"application/pdf"));
assertEquals("label.pdf",service.downloadFile("100","HC1",42).fileName());
verify(fileGateway).download("100",1,"LABEL",42);
}
}
@@ -0,0 +1,257 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.*;
import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementChargePersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;OverseasProcurementFeeMapper fees;
ProcurementFinanceService finance;ProcurementLifecycleService lifecycle;ProcurementChargeService charges;ProcurementAdjustmentService adjustments;
OverseasFeeCurrencyConverter converter;WalletCommandOutboxService commands;long estimateId;
@BeforeEach void setup()throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String name:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command",
"ll_overseas_procurement_fee","ll_procurement_wallet_attempt","ll_procurement_fee_correction","ll_procurement_fee_correction_item","ll_procurement_budget_authorization")) {
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(m.find()).as(name).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
jdbc.execute("CREATE TABLE ll_event_outbox(aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),status VARCHAR(32))");
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(OverseasProcurementFeeMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
orders=session.getMapper(OverseasProcurementOrderMapper.class);fees=session.getMapper(OverseasProcurementFeeMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample");
order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000");
order.setStatus("WAIT_CHARGES");order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(10L);orders.insert(order);
jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,domestic_received_at) VALUES(11,'9',?)",LocalDateTime.now().minusHours(1));
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','VOUCHER',1001,'evidence.pdf','application/pdf',10,'7')");
converter=mock(OverseasFeeCurrencyConverter.class);when(converter.convert(anyString(),any(),any())).thenAnswer(i->{String currency=i.getArgument(0);BigDecimal amount=i.getArgument(1);BigDecimal rate="USD".equals(currency)?new BigDecimal("7.123456"):BigDecimal.ONE;return new OverseasFeeCurrencyConverter.Conversion(currency,amount,rate,"2026-09",amount.multiply(rate).setScale(2,java.math.RoundingMode.HALF_UP));});
var sources=mock(OverseasFeeSourceService.class);AtomicLong ids=new AtomicLong(900);
when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenAnswer(i->{var s=new OverseasFeeSource();s.setSourceFeeId(ids.incrementAndGet());return s;});
commands=mock(WalletCommandOutboxService.class);AtomicLong events=new AtomicLong();
when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet());
when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet());
var json=new ObjectMapper().findAndRegisterModules();finance=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,json);
lifecycle=new ProcurementLifecycleService(orders,files,mock(ProcurementWarehouseDirectory.class),finance,converter,jdbc,json);
charges=new ProcurementChargeService(lifecycle,finance,jdbc);adjustments=new ProcurementAdjustmentService(lifecycle,finance,jdbc);
tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","货款","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"free")));
estimateId=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"zero estimate"))).getId();
}
@Test void allCategoriesAreExplicitAndReconfirmationReferencesExistingMoney() {
var request=request("100","5");var first=tx.execute(s->charges.confirm("9","PO-1","7",request));
assertThat(first.confirmedAt()).isNotNull();assertThat(first.status()).isEqualTo("WAIT_CUSTOMER_SHIPMENT");
assertThat(finance.all(11)).hasSize(5);assertThat(finance.all(11)).noneMatch(f->"OTHER".equals(f.getPurpose()));
assertThatThrownBy(()->finance.requireShipmentReady(11)).hasMessageContaining("未决资金");
var international=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);var domestic=current(ProcurementFeePurpose.DOMESTIC_FREIGHT);settle(international);settle(domestic);
finance.requireShipmentReady(11);int count=countAttempts();
var again=new Confirm(version(),"reconfirm",new Selection(international.getId(),null),new Selection(current(ProcurementFeePurpose.DUTY).getId(),null),new Selection(domestic.getId(),null),"NOT_APPLICABLE",List.of(),"same expenses after repack");
tx.execute(s->charges.confirm("9","PO-1","7",again));assertThat(countAttempts()).isEqualTo(count);
tx.execute(s->charges.confirm("9","PO-1","7",request));assertThat(countAttempts()).isEqualTo(count);
}
@Test void missingDeterminationWrongPurposeAndUnreceivedOrdersCannotConfirm() {
var bad=new Confirm(0L,"missing",null,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"missing international");
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",bad))).hasMessageContaining("每项费用");
var wrong=new Confirm(0L,"wrong",new Selection(estimateId,null),selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"estimate is not actual");
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",wrong))).hasMessageContaining("该用途");
jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=NULL WHERE order_id=11");
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")))).hasMessageContaining("国内收货完成");
assertThat(countAttempts()).isZero();
}
@Test void omittedExistingOtherChargeCannotDisappearFromReconfirmation() {
var other=new Other("HANDLING","Handling",selection("10"));
tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(0L,"first",selection("0"),selection("0"),selection("0"),"ITEMIZED",List.of(other),"actual")));
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(version(),"omit-other",new Selection(current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT).getId(),null),new Selection(current(ProcurementFeePurpose.DUTY).getId(),null),new Selection(current(ProcurementFeePurpose.DOMESTIC_FREIGHT).getId(),null),"NOT_APPLICABLE",List.of(),"omit")))).hasMessageContaining("未列入");
assertThat(finance.all(11)).hasSize(6);
}
@Test void additionalExpenseUsesOwnBusinessDateFinancialPermissionAndImmutableReplay() {
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=? WHERE order_id=11",LocalDateTime.now().minusDays(2));
tx.execute(s->charges.confirm("9","PO-1","7",request("0","0")));
var request=new Additional(version(),"additional","Storage",new BigDecimal("3.25"),"USD",LocalDate.of(2026,9,2),1001,"new expense");
var fee=tx.execute(s->adjustments.additional("9","PO-1","7",request));
assertThat(fee.getConfirmationOrigin()).isEqualTo("FINANCIAL");
assertThat(fee.getBusinessDate()).isEqualTo(LocalDate.of(2026,9,2));
assertThat(fee.getAmount()).isEqualByComparingTo("23.15");
assertThat(charges.retryPermission("9","PO-1",fee.getId())).endsWith(":finance-adjust");
assertThat(lifecycle.flow(11).get("charges_confirmed_at")).isNull();
assertThat(orders.selectById(11).getStatus()).isEqualTo("WAIT_CHARGES");
int attempts=countAttempts();
assertThat(tx.execute(s->adjustments.additional("9","PO-1","7",request)).getId()).isEqualTo(fee.getId());
assertThat(countAttempts()).isEqualTo(attempts);
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",request("0","0")))).hasMessageContaining("首次核费后");
settle(fee);finance.requireShipmentReady(11);
}
@Test void additionalExpenseRequiresPurchaseAndCannotEmitOutOfRangeMoney() {
var request=new Additional(version(),"additional","Storage",new BigDecimal("3.25"),"USD",LocalDate.of(2026,9,2),1001,"new expense");
assertThatThrownBy(()->tx.execute(s->adjustments.additional("9","PO-1","7",request))).hasMessageContaining("已发生购买");
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=? WHERE order_id=11",LocalDateTime.now().minusDays(2));
var excessive=new Additional(version(),"large","Storage",new BigDecimal("99999999999999.99"),"USD",LocalDate.of(2026,9,2),1001,"large");
assertThatThrownBy(()->tx.execute(s->adjustments.additional("9","PO-1","7",excessive))).hasMessageContaining("超出支持范围");
assertThat(countAttempts()).isZero();assertThat(finance.all(11)).hasSize(2);
}
@Test void target100To120To130To110ChargesOnlyDifferencesAndKeepsOriginal() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
var first=correct(root,"120","first");assertThat(first.differenceAmount()).isEqualByComparingTo("20");settle(byNo(first.feeNos().get(0)));
var second=correct(root,"130","second");assertThat(second.differenceAmount()).isEqualByComparingTo("10");settle(byNo(second.feeNos().get(0)));
var third=correct(root,"110","third");assertThat(third.differenceAmount()).isEqualByComparingTo("-20");
var refund=byNo(third.feeNos().get(0));assertThat(refund.getPurpose()).isEqualTo("REFUND");assertThat(refund.getAmount()).isEqualByComparingTo("20");
assertThatThrownBy(()->correct(root,"115","pending")).hasMessageContaining("未完成");complete(refund,"REFUND");
assertThat(finance.readyChain(orders.findNo("9","PO-1"),fees.selectById(root.getId())).stream().filter(f->"SETTLED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("130");
assertThat(fees.selectById(root.getId()).getAmount()).isEqualByComparingTo("100");
var zero=correct(root,"0","all-refund");assertThat(zero.previousNetAmount()).isEqualByComparingTo("110");assertThat(zero.feeNos()).hasSize(3);
assertThat(zero.feeNos().stream().map(this::byNo).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("110");
}
@Test void failedOldTargetMustBeTerminatedBeforeAnotherTargetAndCannotRetryLater() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
var adjustment=byNo(correct(root,"120","first").feeNos().get(0));
tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(adjustment.getWalletCommandEventId(),"FREEZE",9,"PROCUREMENT_FEE",adjustment.getFeeNo(),"FAILED",null,adjustment.getAmount(),"INSUFFICIENT","insufficient",Instant.now(),null,null)));
assertThatThrownBy(()->correct(root,"130","blocked")).hasMessageContaining("未完成");
tx.execute(s->charges.terminate("9","PO-1","7",adjustment.getId(),new Command(version(),"terminate","replace failed target")));
assertThatThrownBy(()->tx.execute(s->finance.retry("9","PO-1",adjustment.getId()))).hasMessageContaining("有效费用");
assertThat(correct(root,"130","new").differenceAmount()).isEqualByComparingTo("30");
}
@Test void originalUsdSnapshotIsUsedForTargetWithoutReloadingMonthlyRate() {
var usd=new Selection(null,new Amount("POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"original USD"));
tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(0L,"usd",usd,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"original")));
var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);clearInvocations(converter);
var corrected=correct(root,"11","usd-correction");assertThat(corrected.exchangeRate()).isEqualByComparingTo("7.123456");assertThat(corrected.exchangeRateMonth()).isEqualTo("2026-09");
assertThat(corrected.targetAmount()).isEqualByComparingTo("78.36");assertThat(corrected.differenceAmount()).isEqualByComparingTo("7.13");
var delta=byNo(corrected.feeNos().get(0));assertThat(delta.getOriginalCurrency()).isEqualTo("CNY");assertThat(delta.getAmount()).isEqualByComparingTo("7.13");verifyNoInteractions(converter);
}
@Test void correctionReplayReturnsOriginalTargetAndDoesNotRegressCompletedFulfillment() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11");
var request=new Correct(version(),"same",new BigDecimal("120"),1001,"correction");
var original=tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),request));int attempts=countAttempts();
var replay=tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),request));assertThat(replay).isEqualTo(original);
assertThat(countAttempts()).isEqualTo(attempts);assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER");
assertThatThrownBy(()->tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),new Correct(version(),"same",new BigDecimal("130"),1001,"correction")))).hasMessageContaining("相同操作幂等键");
}
@Test void terminatedUnpaidActualCanBeReplacedWithFrozenFxAndNewWalletIdentity() {
var usd=new Selection(null,new Amount("POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"USD original"));
tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(version(),"original-usd",usd,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"all")));
var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);var failure=failed(original,"FREEZE");tx.executeWithoutResult(s->finance.handle(failure));
assertThatThrownBy(()->replace(original,"11","too-soon")).hasMessageContaining("先终止");
tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-unpaid","correct actual")));
assertThatThrownBy(()->tx.execute(s->finance.confirmInternational("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"INTERNATIONAL_FREIGHT","actual","POSITIVE",new BigDecimal("11"),"USD",LocalDate.of(2026,9,1),1001L,"ordinary cannot replace")))).hasMessageContaining("财务权限");
assertThatThrownBy(()->replace(original,"9999999999999999.00","too-large")).hasMessageContaining("人民币金额超出支持范围");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isZero();
clearInvocations(converter);var request=new Correct(version(),"replace-unpaid",new BigDecimal("11"),1001,"correct actual amount");
var result=tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),request));var replacement=byNo(result.feeNos().get(0));
assertThat(result.previousNetAmount()).isZero();assertThat(result.targetAmount()).isEqualByComparingTo("78.36");
assertThat(replacement.getFeeVersion()).isEqualTo(original.getFeeVersion()+1);assertThat(replacement.getExchangeRate()).isEqualByComparingTo(original.getExchangeRate());
assertThat(replacement.getExchangeRateMonth()).isEqualTo("2026-09");assertThat(replacement.getWalletBusinessNo()).isNotEqualTo(original.getWalletBusinessNo());
assertThat(replacement.getWalletStatus()).isEqualTo("FREEZE_PENDING");assertThat(replacement.getPurpose()).isEqualTo("INTERNATIONAL_FREIGHT");
assertThat(charges.retryPermission("9","PO-1",replacement.getId())).isEqualTo("platform:overseas-business:purchase:finance-adjust");
assertThat(fees.selectById(original.getId()).getWalletStatus()).isEqualTo("TERMINATED");assertThat(fees.selectById(original.getId()).getAmount()).isEqualByComparingTo("71.23");
int count=countAttempts();var replay=tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),request));assertThat(replay).isEqualTo(result);assertThat(countAttempts()).isEqualTo(count);
tx.executeWithoutResult(s->finance.handle(failure));assertThat(fees.selectById(replacement.getId()).getWalletStatus()).isEqualTo("FREEZE_PENDING");
assertThatThrownBy(()->replace(original,"12","duplicate-old")).hasMessageContaining("后续费用版本");
assertThatThrownBy(()->tx.execute(s->finance.retry("9","PO-1",original.getId()))).hasMessageContaining("有效费用");verifyNoInteractions(converter);
}
@Test void replacementWaitsForReleaseOfTheOriginalInternationalReservation() {
tx.executeWithoutResult(s->finance.releaseEstimate("9","PO-1",estimateId));
var estimate=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","estimate","POSITIVE",new BigDecimal("100"),"CNY",LocalDate.of(2026,9,1),null,"estimate")));
complete(estimate,"FREEZE");tx.execute(s->charges.confirm("9","PO-1","7",request("180","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);
tx.executeWithoutResult(s->finance.handle(failed(original,"SETTLE_ACTUAL")));
tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-held","end failed settlement")));
assertThat(fees.selectById(original.getId()).getWalletStatus()).isEqualTo("RELEASE_PENDING");
assertThatThrownBy(()->replace(original,"120","before-release")).hasMessageContaining("释放");
var releasing=fees.selectById(original.getId());tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(releasing.getWalletCommandEventId(),"RELEASE",9,"PROCUREMENT_FEE",releasing.getWalletBusinessNo(),"SUCCEEDED","RELEASED",new BigDecimal("100"),null,null,Instant.now(),"original-hold","release-ledger")));
var replacement=byNo(replace(original,"120","after-release").feeNos().get(0));
assertThat(replacement.getWalletStatus()).isEqualTo("FREEZE_PENDING");assertThat(replacement.getWalletBusinessNo()).isEqualTo(replacement.getFeeNo());
assertThat(replacement.getParentFeeId()).isEqualTo(estimate.getId());assertThat(replacement.getAmount()).isEqualByComparingTo("120");
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull();
assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("WAIT_CHARGES");
}
@Test void zeroReplacementDoesNotCreateMoneyAndReplacementRollbackLeavesNoVersionOrAudit() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);
tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate","end failed")));
doThrow(new IllegalStateException("outbox unavailable")).when(commands).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
assertThatThrownBy(()->replace(original,"90","rollback")).hasMessageContaining("outbox unavailable");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isZero();assertThat(finance.current(11,ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"INTERNATIONAL_FREIGHT")).isNull();
int attempts=countAttempts();var result=replace(original,"0","waive");var replacement=byNo(result.feeNos().get(0));
assertThat(replacement.getWalletStatus()).isEqualTo("ZERO");assertThat(countAttempts()).isEqualTo(attempts);assertThat(finance.sourceLinked(replacement)).isTrue();
}
@Test void failedSampleReplacementStillRespectsCustomerBudgetAndPreservesPurchaseFacts() {
jdbc.update("DELETE FROM ll_overseas_procurement_fee WHERE purpose='SAMPLE_PAYMENT'");
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=?,purchase_confirmed_at=?,purchase_amount=10,purchase_currency='USD' WHERE order_id=11",LocalDateTime.of(2026,9,1,10,0),LocalDateTime.of(2026,9,1,10,0));
var original=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","sample","POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"actual sample")));
tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-sample","correct price")));
assertThatThrownBy(()->replace(original,"20","over-budget")).hasMessageContaining("超过客户已授权预算");
tx.execute(s->lifecycle.authorizeBudget("9","PO-1","7",new com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget(version(),"new-budget",new BigDecimal("200"),"CNY",1001,"customer authorization")));
var replacement=byNo(replace(original,"20","within-budget").feeNos().get(0));assertThat(replacement.getPurpose()).isEqualTo("SAMPLE_PAYMENT");assertThat(replacement.getAmount()).isEqualByComparingTo("142.47");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("10");
assertThat(jdbc.queryForObject("SELECT purchase_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull();
}
@Test void concurrentReplacementsCannotBothChargeForTheSameTerminatedVersion()throws Exception {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);
tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"stop","end original")));
var executor=java.util.concurrent.Executors.newFixedThreadPool(2);var gate=new java.util.concurrent.CountDownLatch(1);long version=version();
try {
var futures=new ArrayList<java.util.concurrent.Future<Boolean>>();
for(String key:List.of("replace-a","replace-b"))futures.add(executor.submit(()->{gate.await();try{tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),new Correct(version,key,new BigDecimal("90"),1001,"verified replacement")));return true;}catch(org.springframework.web.server.ResponseStatusException error){assertThat(error.getStatusCode().value()).isEqualTo(409);return false;}}));
gate.countDown();int succeeded=0;for(var future:futures)if(future.get(10,java.util.concurrent.TimeUnit.SECONDS))succeeded++;
assertThat(succeeded).isEqualTo(1);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isEqualTo(1);
assertThat(finance.all(11).stream().filter(f->"INTERNATIONAL_FREIGHT".equals(f.getPurpose()) && Boolean.TRUE.equals(f.getActive())).count()).isEqualTo(1);
}finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();}
}
@Test void financialTerminationAfterHandoffPreservesOriginalChargeConfirmationAndFulfillment() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
var originalConfirmed=jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class);
jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11");
var adjustment=byNo(correct(root,"120","after-handoff").feeNos().get(0));tx.executeWithoutResult(s->finance.handle(failed(adjustment,"FREEZE")));
tx.execute(s->charges.terminate("9","PO-1","7",adjustment.getId(),new Command(version(),"terminate-after-handoff","end failed correction")));
assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER");
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isEqualTo(originalConfirmed);
}
private WalletCommandCompletedV1 failed(OverseasProcurementFee fee,String operation){return new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),"FAILED",null,fee.getAmount(),"FAILED","definitive failure",Instant.now(),null,null);}
private ProcurementAdjustmentService.Result replace(OverseasProcurementFee original,String target,String key){return tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),new Correct(version(),key,new BigDecimal(target),1001,"replacement verified")));}
private ProcurementAdjustmentService.Result correct(OverseasProcurementFee root,String target,String key){return tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),new Correct(version(),key,new BigDecimal(target),1001,"verified target")));}
@Test void concurrentTargetsCannotBothSupplementTheSameOldNetAmount()throws Exception {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
var executor=java.util.concurrent.Executors.newFixedThreadPool(2);var gate=new java.util.concurrent.CountDownLatch(1);long version=version();
try {
var futures=new ArrayList<java.util.concurrent.Future<Boolean>>();
for(String target:List.of("120","130"))futures.add(executor.submit(()->{gate.await();try{tx.execute(s->adjustments.correct("9","PO-1",target,root.getId(),new Correct(version,"parallel-"+target,new BigDecimal(target),1001,"correct target")));return true;}
catch(org.springframework.web.server.ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}}));
gate.countDown();int succeeded=0;for(var result:futures)if(result.get(10,java.util.concurrent.TimeUnit.SECONDS))succeeded++;
assertThat(succeeded).isEqualTo(1);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isEqualTo(1);
assertThat(finance.all(11).stream().filter(f->"ADJUSTMENT".equals(f.getPurpose())).count()).isEqualTo(1);
}finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();}
}
private Selection selection(String amount){return new Selection(null,new Amount(new BigDecimal(amount).signum()>0?"POSITIVE":"ZERO",new BigDecimal(amount),"CNY",LocalDate.of(2026,9,1),new BigDecimal(amount).signum()>0?1001L:null,"actual cost"));}
private Confirm request(String international,String domestic){return new Confirm(version(),"charges-"+version(),selection(international),selection("0"),selection(domestic),"NOT_APPLICABLE",List.of(),"all determined");}
private long version(){return orders.findNo("9","PO-1").getVersion();}
private int countAttempts(){return jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt",Integer.class);}
private OverseasProcurementFee current(ProcurementFeePurpose purpose){return finance.current(11,purpose,purpose.name());}
private OverseasProcurementFee byNo(String no){return finance.all(11).stream().filter(f->no.equals(f.getFeeNo())).findFirst().orElseThrow();}
private void settle(OverseasProcurementFee fee){complete(fee,"FREEZE");complete(fees.selectById(fee.getId()),"SETTLE");var saved=fees.selectById(fee.getId());jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE',?,'PUBLISHED')",String.valueOf(saved.getSourceFeeId()));}
private void complete(OverseasProcurementFee fee,String operation){String status="REFUND".equals(operation)?"REFUNDED":"FREEZE".equals(operation)?"FROZEN":"SETTLED";tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),"SUCCEEDED",status,fee.getAmount(),null,null,Instant.now(),"REFUND".equals(operation)?null:"hold-"+fee.getId(),"ledger-"+fee.getId()+"-"+operation)));}
}
@@ -0,0 +1,129 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ProcurementControlPersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;ProcurementFinanceService finance;
ProcurementControlService service;ProcurementLifecycleService lifecycle;ProcurementWarehouseDirectory warehouses;
@BeforeEach void setup()throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds){
@Override public int update(String sql,Object... args){return super.update(sql.replaceAll("(?i)CAST\\(\\? AS JSON\\)","? FORMAT JSON"),args);}
};tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String name:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_flow","ll_procurement_command","ll_procurement_receipt","ll_procurement_disposition",
"ll_procurement_carton","ll_procurement_shipment","ll_procurement_shipment_item","ll_procurement_control_note","ll_procurement_closure","ll_procurement_closure_item")) {
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(m.find()).as(name).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample");
order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000");
order.setStatus("WAIT_OVERSEAS_RECEIPT");order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order);
jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_amount,purchase_currency) VALUES(11,'9',?,10,100,'CNY')",LocalDateTime.now().minusDays(1));
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','EXCEPTION',1001,'proof.pdf','application/pdf',10,'7')");
finance=mock(ProcurementFinanceService.class);warehouses=mock(ProcurementWarehouseDirectory.class);
when(warehouses.required(92)).thenReturn(new ReceivingWarehouse(92L,"NEW","Correct receiving warehouse","US","ACTIVE"));
var json=new ObjectMapper().findAndRegisterModules();lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json);
service=new ProcurementControlService(lifecycle,finance,warehouses,jdbc,json);
}
@Test void purchasedButUnreceivedGoodsCanCloseOnlyWithAllSupplierDispositionAndCustomerEvidence() {
assertThat(service.read("9","PO-1").remaining()).containsExactly(new ProcurementControlService.Remaining("PURCHASE_REMAINDER",null,"PO-1",10));
assertThatThrownBy(()->close(List.of())).hasMessageContaining("货物去向未明确");
var request=request(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")));
var result=tx.execute(s->service.close("9","PO-1","7",request));assertThat(result.status()).isEqualTo("EXCEPTION_CLOSED");assertThat(result.remaining()).isEmpty();
tx.execute(s->service.close("9","PO-1","7",request));assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero();verify(finance,times(2)).requireShipmentReady(11);verifyNoMoreInteractions(finance);
}
@Test void partialReceiptsRequireExactGoodDamagedExtraAndUnreceivedSources() {
receipt(101,"OVERSEAS",null,6,2,1);
assertThat(service.read("9","PO-1").remaining()).hasSize(4);
assertThatThrownBy(()->close(List.of(resolution("GOOD",101L,5,"RETURNED_TO_SUPPLIER")))).hasMessageContaining("恰好覆盖");
var all=List.of(resolution("GOOD",101L,6,"RETURNED_TO_SUPPLIER"),resolution("DAMAGED",101L,2,"DISPOSED"),resolution("EXTRA",101L,1,"RETURNED_TO_SUPPLIER"),resolution("PURCHASE_REMAINDER",null,2,"SUPPLIER_RETAINED"));
assertThat(close(all).status()).isEqualTo("EXCEPTION_CLOSED");
assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_closure_item",Long.class)).isEqualTo(11L);
assertThat(jdbc.queryForObject("SELECT good_quantity FROM ll_procurement_receipt",Long.class)).isEqualTo(6L);
}
@Test void internationalShipmentIsNotRecountedAndDomesticRemainingKeepsOriginalCartonSources() {
receipt(101,"OVERSEAS",null,10,0,0);international();receipt(102,"DOMESTIC",201L,3,1,1);
var remaining=service.read("9","PO-1").remaining();assertThat(remaining).hasSize(5);assertThat(remaining).noneMatch(r->r.sourceId()!=null && r.sourceId()==101);
assertThat(remaining).contains(new ProcurementControlService.Remaining("TRANSIT_REMAINDER",201L,"C1",2),new ProcurementControlService.Remaining("TRANSIT_REMAINDER",202L,"C2",4));
close(List.of(resolution("GOOD",102L,3,"RETURNED_TO_SUPPLIER"),resolution("DAMAGED",102L,1,"DISPOSED"),resolution("EXTRA",102L,1,"RETURNED_TO_SUPPLIER"),resolution("TRANSIT_REMAINDER",201L,2,"LOSS_CONFIRMED"),resolution("TRANSIT_REMAINDER",202L,4,"RETURNED_TO_SENDER")));
assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
assertThat(jdbc.queryForObject("SELECT overseas_shipped_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull();
}
@Test void unresolvedMoneyCannotBeErasedByClosing() {
doThrow(ProcurementLifecycleService.conflict("资金未决")).when(finance).requireShipmentReady(11);
assertThatThrownBy(()->close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")))).hasMessageContaining("资金未决");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isZero();assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("WAIT_OVERSEAS_RECEIPT");
}
@Test void evidenceBackedBlockerMustBeResolvedBeforePlannedOperationsAndClosure() {
tx.execute(s->service.note("9","PO-1","7",new Note(version(),"note","PURCHASE",null,true,1001,"internal evidence","Waiting for agreed handling")));
assertThatThrownBy(()->lifecycle.requireNoBlockers(orders.findNo("9","PO-1"))).hasMessageContaining("阻断异常");
assertThatThrownBy(()->close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")))).hasMessageContaining("阻断异常");
long id=jdbc.queryForObject("SELECT id FROM ll_procurement_control_note",Long.class);
tx.execute(s->service.resolve("9","PO-1","7",id,new Resolve(version(),"resolve",1001,"customer confirms supplier retention")));
lifecycle.requireNoBlockers(orders.findNo("9","PO-1"));assertThat(close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED"))).status()).isEqualTo("EXCEPTION_CLOSED");
assertThat(jdbc.queryForObject("SELECT reason FROM ll_procurement_control_note",String.class)).isEqualTo("internal evidence");
}
@Test void warehouseCorrectionBeforePackingChangesEffectiveWarehouseAndPreservesOriginalReceipt() {
receipt(101,"OVERSEAS",null,10,0,0);warehouse();
tx.execute(s->service.correctWarehouse("9","PO-1","7",new WarehouseCorrection(version(),"warehouse",92,1001,"typed wrong warehouse")));
assertThat(jdbc.queryForObject("SELECT actual_warehouse_id FROM ll_procurement_flow",Long.class)).isEqualTo(92L);
assertThat(jdbc.queryForObject("SELECT warehouse_id FROM ll_procurement_receipt",Long.class)).isEqualTo(91L);
assertThat(jdbc.queryForObject("SELECT original_value_json FROM ll_procurement_control_note",String.class)).contains("91");
var snapshot=service.read("9","PO-1");
assertThat(snapshot.notes().get(0).get("original_value_json")).isInstanceOf(com.fasterxml.jackson.databind.JsonNode.class);
assertThat(((com.fasterxml.jackson.databind.JsonNode)snapshot.notes().get(0).get("original_value_json")).get("warehouseId").asLong()).isEqualTo(91);
assertThat(jdbc.queryForObject("SELECT blocking FROM ll_procurement_control_note",Boolean.class)).isFalse();
}
@Test void warehouseAfterDownstreamAndPostShipmentCorrectionsOnlyAppendFacts() {
receipt(101,"OVERSEAS",null,10,0,0);warehouse();international();
tx.execute(s->service.correctWarehouse("9","PO-1","7",new WarehouseCorrection(version(),"warehouse",92,1001,"observed later")));
assertThat(jdbc.queryForObject("SELECT actual_warehouse_id FROM ll_procurement_flow",Long.class)).isEqualTo(91L);
assertThat(jdbc.queryForObject("SELECT blocking FROM ll_procurement_control_note",Boolean.class)).isTrue();
jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11");
tx.execute(s->service.note("9","PO-1","7",new Note(version(),"after","SHIPMENT",301L,false,1001,"post handoff correction","Shipment correction recorded")));
assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER");assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
}
@Test void wrongOrderProofOrFactAndDuplicateRemainingSourceAreRejected() {
assertThatThrownBy(()->service.read("10","PO-1")).hasMessageContaining("当前客户");
assertThatThrownBy(()->tx.execute(s->service.note("9","PO-1","7",new Note(version(),"other","SHIPMENT",999L,false,1001,"other fact",null)))).hasMessageContaining("不属于");
var line=resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED");assertThatThrownBy(()->close(List.of(line,line))).hasMessageContaining("恰好覆盖");
assertThatThrownBy(()->tx.execute(s->service.close("9","PO-1","7",new Close(version(),"other-proof",List.of(line),999,"no further charges","closed","Agreement")))).hasMessageContaining("凭证不属于");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isZero();
}
private void warehouse(){jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=91,actual_warehouse_code='OLD',actual_warehouse_name='Original warehouse' WHERE order_id=11");}
private void international(){jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,401,'C1',20,30,40,6,6),(202,11,401,'C2',20,30,40,4,4)");jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(301,11,'9','PS-1','OVERSEAS',401,?,'{}','7')",LocalDateTime.now().minusHours(2));jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(301,201,6,'carrier','T1','{}'),(301,202,4,'carrier','T2','{}')");}
private void receipt(long id,String leg,Long carton,long good,long damaged,long extra){jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(?,11,'9',?,?,?,91,?,?,?,?, '7')",id,"PR-"+id,leg,carton,good,damaged,extra,LocalDateTime.now().minusHours(1));}
private long version(){return orders.findNo("9","PO-1").getVersion();}
private Resolution resolution(String kind,Long source,long quantity,String decision){return new Resolution(kind,source,quantity,decision,"verified actual destination",1001);}
private Close request(List<Resolution> lines){return new Close(version(),"close-"+version(),lines,1001,"known fees resolved; no further customer charges","agreed stop","Procurement closed by agreement");}
private ProcurementControlService.Snapshot close(List<Resolution> lines){return tx.execute(s->service.close("9","PO-1","7",request(lines)));}
}
@@ -0,0 +1,204 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real fee rows, attempt rows, locking and rollback; only external boundaries are mocked. */
class ProcurementFinancePersistenceTest {
JdbcTemplate jdbc;
TransactionTemplate tx;
OverseasProcurementFeeMapper fees;
OverseasProcurementOrder order;
OverseasFeeSourceService sources;
WalletCommandOutboxService commands;
ProcurementFinanceService service;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds); tx = new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String name : new String[]{"ll_overseas_procurement_fee", "ll_procurement_wallet_attempt"}) {
var match = Pattern.compile("CREATE TABLE IF NOT EXISTS " + name + " \\(.*?\\) ENGINE=InnoDB[^;]*;", Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as("source schema contains %s",name).isTrue();
jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;", ");"));
}
jdbc.execute("CREATE TABLE ll_event_outbox(aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),status VARCHAR(32))");
var config=new MybatisConfiguration(); config.addMapper(OverseasProcurementFeeMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
fees=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasProcurementFeeMapper.class);
var orders=mock(OverseasProcurementOrderMapper.class); var files=mock(OverseasProcurementFileMapper.class);
order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");
order.setOrderNo("PO-1");order.setPurchaseQuantity(10L);order.setInboundQuantity(10L);order.setStatus("WAIT_CHARGES");
when(orders.lockNo("9","PO-1")).thenReturn(order);when(orders.selectById(11L)).thenReturn(order);
var voucher=new OverseasProcurementFile();voucher.setFileId(1L);voucher.setFileName("voucher.pdf");voucher.setCategory("VOUCHER");
when(files.selectOne(any())).thenReturn(voucher);
var converter=mock(OverseasFeeCurrencyConverter.class);
when(converter.convert(eq("CNY"),any(),any())).thenAnswer(i -> new OverseasFeeCurrencyConverter.Conversion("CNY",i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1)));
sources=mock(OverseasFeeSourceService.class);
var source=new OverseasFeeSource();source.setSourceFeeId(901L);
when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenReturn(source);
commands=mock(WalletCommandOutboxService.class);AtomicInteger sequence=new AtomicInteger();
when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()))
.thenAnswer(i -> "command-"+sequence.incrementAndGet());
when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong()))
.thenAnswer(i -> "command-"+sequence.incrementAndGet());
service=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,new ObjectMapper().findAndRegisterModules());
}
@Test void actualFeePublishesOnlyAfterSuccessfulConsumption() {
var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","100");
var frozen=result(fee,"FREEZE","SUCCEEDED","FROZEN","100");handle(frozen);
verifyNoInteractions(sources);
var pending=fees.selectById(fee.getId());assertThat(pending.getWalletStatus()).isEqualTo("SETTLE_PENDING");
var settled=result(pending,"SETTLE","SUCCEEDED","SETTLED","100");handle(settled);handle(settled);
var saved=fees.selectById(fee.getId());assertThat(saved.getWalletStatus()).isEqualTo("SETTLED");
assertThat(saved.getSourceFeeId()).isEqualTo(901L);
verify(sources).publishSettled(eq("7"),anyString(),any(),any(),argThat(ref -> ref.commandEventId().equals(settled.commandEventId())));
assertThat(service.sourceLinked(saved)).isFalse();
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
assertThat(service.sourceLinked(saved)).isTrue();
}
@Test void fullInboundNeverSettlesEstimateAndActualUsesOriginalHold() {
var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100");
handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100"));
assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("FROZEN");
verify(commands,never()).publish(eq("SETTLE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
var request=request(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"POSITIVE","60");
var actual=tx.execute(s -> service.confirmInternational("9","PO-1","7",request));
assertThat(actual.getWalletBusinessNo()).isEqualTo(estimate.getFeeNo());
handle(result(actual,"SETTLE_ACTUAL","SUCCEEDED","SETTLED","60"));
assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("100");
assertThat(fees.selectById(actual.getId()).getAmount()).isEqualByComparingTo("60");
assertThat(tx.execute(s -> service.confirmInternational("9","PO-1","7",request)).getId()).isEqualTo(actual.getId());
verify(sources).publishSettled(anyString(),anyString(),argThat(r -> r.businessNo().equals(estimate.getFeeNo()) && r.amount().compareTo(new BigDecimal("60"))==0),any(),any());
}
@Test void zeroActualReleasesEstimateWithoutCreatingAnActualReceivable() {
var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100");
handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100"));
var actual=tx.execute(s -> service.confirmInternational("9","PO-1","7",request(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"ZERO","0")));
handle(result(actual,"RELEASE","SUCCEEDED","RELEASED","100"));
assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("RELEASED");
assertThat(service.sourceLinked(fees.selectById(actual.getId()))).isTrue();
verifyNoInteractions(sources);
}
@Test void unknownRequestCannotRetryAndOldFailedResultCannotOverwriteNewAttempt() {
var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10");
assertThatThrownBy(() -> tx.execute(s -> service.retry("9","PO-1",fee.getId()))).hasMessageContaining("未明确失败");
var failed=result(fee,"FREEZE","FAILED",null,"10");handle(failed);
var retry=tx.execute(s -> service.retry("9","PO-1",fee.getId()));
assertThat(retry.getWalletCommandEventId()).isNotEqualTo(failed.commandEventId());
handle(failed);
assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("FREEZE_PENDING");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=?",Integer.class,fee.getId())).isEqualTo(2);
}
@Test void sourceFailureRollsBackResultProjectionAndRetriesWithoutAnotherDebit() {
var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","10"));
var pending=fees.selectById(fee.getId());var result=result(pending,"SETTLE","SUCCEEDED","SETTLED","10");
when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenThrow(new IllegalStateException("source unavailable"));
assertThatThrownBy(() -> handle(result)).hasMessage("source unavailable");
assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("SETTLE_PENDING");
assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_wallet_attempt WHERE command_event_id=?",String.class,result.commandEventId())).isEqualTo("PENDING");
verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
}
@Test void freeSampleDoesNotPublishZeroMoneyAndCannotBeChangedByReplay() {
var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"ZERO","0");
assertThat(fee.getWalletStatus()).isEqualTo("ZERO");assertThat(service.sourceLinked(fee)).isTrue();
assertThatThrownBy(() -> confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","1")).hasMessageContaining("已确认");
verifyNoInteractions(commands,sources);
}
@Test void repriceWaitsForReleaseAndCreatesANewImmutableVersion() {
var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100");
handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100"));
tx.executeWithoutResult(s -> service.releaseEstimate("9","PO-1",estimate.getId()));
assertThatThrownBy(() -> confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","80")).hasMessageContaining("已确认");
handle(result(fees.selectById(estimate.getId()),"RELEASE","SUCCEEDED","RELEASED","100"));
var revised=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","80");
assertThat(revised.getFeeVersion()).isEqualTo(2);
assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("100");
assertThat(fees.selectById(estimate.getId()).getActive()).isFalse();
}
@Test void refundUsesOriginalConsumptionAndIsIdempotentByBusinessRequest() {
var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","100");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100"));
handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100"));
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
var refund=tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key"));
assertThat(tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")).getId()).isEqualTo(refund.getId());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","another-key")))
.hasMessageContaining("未完成的调整或退款");
handle(new WalletCommandCompletedV1(refund.getWalletCommandEventId(),"REFUND",9,"PROCUREMENT_FEE",refund.getFeeNo(),
"SUCCEEDED","REFUNDED",new BigDecimal("20"),null,null,Instant.parse("2026-10-09T10:00:00Z"),null,"33"));
assertThat(fees.selectById(refund.getId()).getWalletStatus()).isEqualTo("REFUNDED");
assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("SETTLED");
verify(commands).publishRefund(eq(9L),eq("PROCUREMENT_FEE"),eq(refund.getFeeNo()),any(),eq("32"),eq("correction"),anyLong());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("81"),1,"correction","too-much")))
.hasMessageContaining("可退余额");
}
@Test void finalHandoffWaitsForConsumptionSourceAndAnyPendingRefund() {
var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","100");
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100"));
handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100"));
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("来源关联");
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
service.requireShipmentReady(11);
tx.execute(s->service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","pending-refund"));
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金");
assertThatThrownBy(()->service.requireAdjustmentsComplete(11)).hasMessageContaining("财务调整或退款");
}
@Test void unusedFrozenEstimateMustBeReleasedBeforeFinalHandoff() {
var fee=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100"));
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("尚未结清或释放");
tx.executeWithoutResult(s->service.releaseEstimate("9","PO-1",fee.getId()));
handle(result(fees.selectById(fee.getId()),"RELEASE","SUCCEEDED","RELEASED","100"));
service.requireShipmentReady(11);
}
private OverseasProcurementFee confirm(ProcurementFeePurpose purpose,String determination,String amount) {
return tx.execute(s -> service.confirm("9","PO-1","7",request(purpose,determination,amount)));
}
private ProcurementFinanceService.Confirmation request(ProcurementFeePurpose purpose,String determination,String amount) {
return new ProcurementFinanceService.Confirmation(purpose,purpose.name(),purpose.label(),determination,new BigDecimal(amount),"CNY",LocalDate.of(2026,10,9),1L,"confirmed basis");
}
private WalletCommandCompletedV1 result(OverseasProcurementFee fee,String operation,String outcome,String state,String amount) {
return new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),outcome,state,new BigDecimal(amount),
"FAILED".equals(outcome)?"FIN-WALLET-INSUFFICIENT":null,null,Instant.parse("2026-10-09T10:00:00Z"),"SUCCEEDED".equals(outcome)?"31":null,"SUCCEEDED".equals(outcome)?"32":null);
}
private void handle(WalletCommandCompletedV1 result) { tx.executeWithoutResult(s -> service.handle(result)); }
}
@@ -0,0 +1,188 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
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.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementPhysicalPersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;
ProcurementPhysicalService service;ProcurementFinanceService finance;OverseasProcurementFee payment;
@BeforeEach void setup()throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command",
"ll_procurement_budget_authorization","ll_procurement_receipt","ll_procurement_disposition","ll_procurement_manifest","ll_procurement_carton","ll_procurement_carton_source","ll_procurement_shipment","ll_procurement_shipment_item")) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample");
order.setProductName("Sample");order.setProductSpecification("A");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");
order.setContactName("buyer");order.setContactPhone("13800000000");order.setStatus("WAIT_OVERSEAS_RECEIPT");order.setReviewedAt(LocalDateTime.now().minusDays(3));
order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order);
jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_confirmed_at) VALUES(11,'9',?,10,?)",LocalDateTime.now().minusDays(2),LocalDateTime.now().minusDays(2));
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','VOUCHER',1001,'evidence.pdf','application/pdf',10,'7')");
var warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.required(91)).thenReturn(new ReceivingWarehouse(91L,"LAX","receiving","US","ACTIVE"));
finance=mock(ProcurementFinanceService.class);payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED");
when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true);
var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,new ObjectMapper().findAndRegisterModules());
service=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc);
}
@Test void splitReceiptsAndCartonSourcesRemainConservedWithoutWms() {
var first=receive(Leg.OVERSEAS,null,6,0,0,"r1");long source1=first.receipts().get(0).id();
var second=receive(Leg.OVERSEAS,null,4,0,0,"r2");long source2=second.receipts().get(1).id();
finish(Leg.OVERSEAS);
var draft=pack(List.of(carton("C1",new Source(source1,5)),carton("C2",new Source(source1,1),new Source(source2,4))));
var confirmed=tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked")));
assertThat(confirmed.status()).isEqualTo("WAIT_LABELS");assertThat(confirmed.packingStatus()).isEqualTo("CONFIRMED");
assertThat(confirmed.cartons()).extracting(ProcurementPhysicalService.CartonRow::quantity).containsExactly(5L,5L);
assertThat(confirmed.cartons().get(1).sources()).hasSize(2);
assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isEqualTo(10L);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_name='ll_inventory_balance'",Integer.class)).isZero();
}
@Test void unresolvedDamageExtraAndShortageBlockCompletionUntilEvidenceExists() {
var receipt=receive(Leg.OVERSEAS,null,6,2,1,"r1").receipts().get(0);
assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("破损或多到");
dispose(receipt.id(),"DAMAGED",2,"DISPOSE_DAMAGED");dispose(receipt.id(),"EXTRA",1,"RETURN_EXTRA");
assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("未收齐");
dispose(null,"SHORTAGE",2,"ACCEPT_SHORTAGE");
assertThat(finish(Leg.OVERSEAS).status()).isEqualTo("WAIT_PACKING");
assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isEqualTo(6L);
verify(finance,never()).refund(anyString(),anyString(),anyString(),anyLong(),any(),anyLong(),anyString(),anyString());
}
@Test void overReceiptAndOtherOrderEvidenceAreRejectedWithoutCounting() {
var receipt=receive(Leg.OVERSEAS,null,8,2,0,"r1").receipts().get(0);
assertThatThrownBy(()->receive(Leg.OVERSEAS,null,1,0,0,"r2")).hasMessageContaining("超过来源");
assertThatThrownBy(()->tx.execute(s->service.dispose("9","PO-1","7",new Disposition(version(),"dispose",Leg.OVERSEAS,null,receipt.id(),"DAMAGED",2,"DISPOSE_DAMAGED",999,"wrong file"))))
.hasMessageContaining("凭证不属于");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_receipt",Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_disposition",Integer.class)).isZero();
}
@Test void draftsAreAllowedBeforePaymentButConfirmationWaitsForMoneyAndAllSources() {
when(finance.sourceLinked(payment)).thenReturn(false);
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
var draft=pack(List.of(carton("C1",new Source(receipt.id(),10))));
assertThatThrownBy(()->tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked"))))
.hasMessageContaining("样品货款");
when(finance.sourceLinked(payment)).thenReturn(true);
tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked")));
assertThat(service.read("9","PO-1").packingStatus()).isEqualTo("CONFIRMED");
}
@Test void quantityDoubleAllocationAndExcessPrecisionNeverPersist() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);
assertThatThrownBy(()->pack(List.of(carton("C1",new Source(receipt.id(),6)),carton("C2",new Source(receipt.id(),6))))).hasMessageContaining("重复分配");
var bad=new Carton("C1",new BigDecimal("1.001"),BigDecimal.TEN,BigDecimal.TEN,BigDecimal.ONE,List.of(new Source(receipt.id(),10)));
assertThatThrownBy(()->pack(List.of(bad))).hasMessageContaining("最多两位");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero();
}
@Test void repackingCreatesANewVersionAndCannotRewriteAnActualDispatch() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
var first=pack(List.of(carton("C1",new Source(receipt.id(),10))));
tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",first.packingVersion(),"checked")));
var second=pack(List.of(carton("C1",new Source(receipt.id(),5)),carton("C2",new Source(receipt.id(),5))));
assertThat(second.packingVersion()).isEqualTo(2);assertThat(second.packingStatus()).isEqualTo("DRAFT");
assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_manifest WHERE version=1",String.class)).isEqualTo("SUPERSEDED");
assertThat(jdbc.queryForObject("SELECT packing_version FROM ll_procurement_flow WHERE order_id=11",Integer.class)).isNull();
jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now());
assertThatThrownBy(()->pack(List.of(carton("C1",new Source(receipt.id(),10))))).hasMessageContaining("已实际交寄");
}
@Test void domesticReceiptsMustMatchEachOriginalShippedCarton() {
jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,6,'carrier','T1','{}'),(201,102,4,'carrier','T2','{}')");
assertThatThrownBy(()->receive(Leg.DOMESTIC,999L,1,0,0,"wrong")).hasMessageContaining("不属于本单");
receive(Leg.DOMESTIC,101L,4,0,0,"d1");assertThatThrownBy(()->finish(Leg.DOMESTIC)).hasMessageContaining("未收齐");
receive(Leg.DOMESTIC,101L,2,0,0,"d2");receive(Leg.DOMESTIC,102L,4,0,0,"d3");
assertThat(finish(Leg.DOMESTIC).status()).isEqualTo("WAIT_CHARGES");
assertThat(service.read("9","PO-1").receipts()).hasSize(3);
}
@Test void concurrentReceiversCannotCountTheSameWholePurchaseTwice()throws Exception {
ExecutorService executor=Executors.newFixedThreadPool(2);CountDownLatch start=new CountDownLatch(1);
try {
var one=executor.submit(()->attemptReceipt(start,"one"));var two=executor.submit(()->attemptReceipt(start,"two"));start.countDown();
assertThat((one.get(10,TimeUnit.SECONDS)?1:0)+(two.get(10,TimeUnit.SECONDS)?1:0)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT SUM(good_quantity) FROM ll_procurement_receipt",Long.class)).isEqualTo(10L);
}finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,TimeUnit.SECONDS)).isTrue();}
}
@Test void overseasCorrectionRequiresWithdrawalAndKeepsOriginalReceiptAndPacking() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
var packing=pack(List.of(carton("C1",new Source(receipt.id(),10))));
tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",packing.packingVersion(),"checked")));
assertThatThrownBy(()->correct(receipt.id(),Leg.OVERSEAS,null,9,1)).hasMessageContaining("须先撤回");
tx.execute(s->service.withdrawPacking("9","PO-1","7",new Command(version(),"withdraw","record correction")));
var corrected=correct(receipt.id(),Leg.OVERSEAS,null,9,1);
assertThat(corrected.receipts()).hasSize(2);assertThat(corrected.receipts().get(0).active()).isFalse();
assertThat(corrected.receipts().get(0).goodQuantity()).isEqualTo(10);
assertThat(corrected.receipts().get(1).goodQuantity()).isEqualTo(9);
assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_carton",Long.class)).isEqualTo(10L);
assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_manifest",String.class)).isEqualTo("SUPERSEDED");
assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("破损");
}
@Test void domesticCorrectionDoesNotUndoInternationalShipmentAndReopensCharges() {
jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,10,'carrier','T1','{}')");
var receipt=receive(Leg.DOMESTIC,101L,10,0,0,"d1").receipts().get(0);finish(Leg.DOMESTIC);
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11");
correct(receipt.id(),Leg.DOMESTIC,101L,8,2);
assertThat(jdbc.queryForObject("SELECT overseas_shipped_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull();
assertThat(jdbc.queryForObject("SELECT domestic_received_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull();
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull();
assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
assertThat(jdbc.queryForObject("SELECT SUM(good_quantity) FROM ll_procurement_receipt WHERE active=1",Long.class)).isEqualTo(8L);
verifyNoInteractions(finance);
}
private ProcurementPhysicalService.Snapshot correct(long id,Leg leg,Long source,long good,long damaged) {
return tx.execute(s->service.correctReceipt("9","PO-1","7",id,new Correction(new Receipt(version(),"correction",leg,
leg==Leg.OVERSEAS?91L:null,source,good,damaged,0,LocalDateTime.now().minusHours(1),"evidence-backed correction"),1001)));
}
private boolean attemptReceipt(CountDownLatch start,String key)throws Exception {
if(!start.await(5,TimeUnit.SECONDS))throw new AssertionError("timeout");
try{tx.execute(s->service.receive("9","PO-1","7",new Receipt(0L,key,Leg.OVERSEAS,91L,null,10,0,0,LocalDateTime.now().minusHours(1),"actual")));return true;}
catch(ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}
}
private long version(){return orders.findNo("9","PO-1").getVersion();}
private ProcurementPhysicalService.Snapshot receive(Leg leg,Long source,long good,long damaged,long extra,String key){return tx.execute(s->service.receive("9","PO-1","7",new Receipt(version(),key,leg,leg==Leg.OVERSEAS?91L:null,source,good,damaged,extra,LocalDateTime.now().minusHours(1),"actual receipt")));}
private ProcurementPhysicalService.Snapshot finish(Leg leg){return tx.execute(s->service.finishReceipt("9","PO-1","7",new FinishReceipt(version(),"finish-"+leg+"-"+version(),leg,"verified")));}
private ProcurementPhysicalService.Snapshot pack(List<Carton> cartons){return tx.execute(s->service.savePacking("9","PO-1","7",new Packing(version(),"packing-"+version(),cartons,"actual packing")));}
private Carton carton(String no,Source... sources){return new Carton(no,new BigDecimal("20.00"),new BigDecimal("30.00"),new BigDecimal("40.00"),new BigDecimal("5.125"),List.of(sources));}
private void dispose(Long receipt,String kind,long quantity,String decision){tx.execute(s->service.dispose("9","PO-1","7",new Disposition(version(),"disposition-"+kind,Leg.OVERSEAS,null,receipt,kind,quantity,decision,1001,"customer and physical proof")));}
}
@@ -0,0 +1,155 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementPurchasePersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;
ProcurementLifecycleService service;ProcurementFinanceService finance;
OverseasFeeCurrencyConverter converter;ProcurementWarehouseDirectory warehouses;
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:new String[]{"ll_overseas_procurement_order","ll_procurement_flow","ll_procurement_command","ll_procurement_budget_authorization"}) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
orders=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasProcurementOrderMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");
order.setPurchaseCategory("sample");order.setProductName("Sample");order.setProductSpecification("A");order.setPurchaseQuantity(10L);
order.setBudgetUnitPrice(new BigDecimal("10"));order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000");
order.setStatus("WAIT_PURCHASE");order.setReviewedAt(LocalDateTime.now().minusDays(3));order.setVersion(0L);
order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order);
var files=mock(OverseasProcurementFileMapper.class);var file=new OverseasProcurementFile();file.setCategory("VOUCHER");
when(files.selectOne(any())).thenReturn(file);
finance=mock(ProcurementFinanceService.class);var payment=new AtomicReference<OverseasProcurementFee>();
when(finance.current(eq(11L),eq(ProcurementFeePurpose.SAMPLE_PAYMENT),eq("SAMPLE_PAYMENT"))).thenAnswer(i->payment.get());
when(finance.confirm(anyString(),anyString(),anyString(),any())).thenAnswer(i->{
var fee=new OverseasProcurementFee();fee.setFeeNo("PF-1");fee.setWalletStatus("FREEZE_PENDING");payment.set(fee);return fee;
});
converter=mock(OverseasFeeCurrencyConverter.class);
when(converter.convert(anyString(),any(),any())).thenAnswer(i->new OverseasFeeCurrencyConverter.Conversion(i.getArgument(0),i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1)));
warehouses=mock(ProcurementWarehouseDirectory.class);
service=new ProcurementLifecycleService(orders,files,warehouses,finance,converter,jdbc,new ObjectMapper().findAndRegisterModules());
}
@Test void purchaseRecordsFactAndChargesOnceWithoutWaitingForInbound() {
Purchase request=purchase("80","Sample");
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",request));
assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isTrue();
assertThat(result.status()).isEqualTo("WAIT_OVERSEAS_RECEIPT");assertThat(result.version()).isEqualTo(1);
assertThat(result.purchase().walletStatus()).isEqualTo("FREEZE_PENDING");
tx.execute(s->service.recordPurchase("9","PO-1","7",request));
verify(finance).confirm(eq("9"),eq("PO-1"),eq("7"),argThat(r->r.originalAmount().compareTo(new BigDecimal("80"))==0));
assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command",Integer.class)).isEqualTo(1);
}
@Test void overBudgetFactSurvivesUntilAuthorizedAndCanThenConfirm() {
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("120","Sample")));
assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse();
assertThat(result.purchase().issue()).contains("超过");verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
tx.execute(s->service.authorizeBudget("9","PO-1","8",new Budget(1L,"budget-1",new BigDecimal("130"),"CNY",1,"customer approval")));
var confirmed=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(2L,"confirm-1","authorized")));
assertThat(confirmed.purchase().confirmed()).isTrue();assertThat(confirmed.purchase().issue()).isNull();
assertThat(jdbc.queryForObject("SELECT previous_amount FROM ll_procurement_budget_authorization",BigDecimal.class)).isEqualByComparingTo("100");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("120");
}
@Test void missingRateKeepsActualPurchaseAndDoesNotInventCharge() {
when(converter.convert(anyString(),any(),any())).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"rate missing"));
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse();
assertThat(result.purchase().issue()).contains("汇率");verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void amountBeyondSourceCapacityKeepsPurchaseFactWithoutCharging() {
jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=9999999999999999 WHERE id=11");
var request=purchase("100000000000000.00","Sample");
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",request));
assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse();
assertThat(result.purchase().issue()).contains("超出", "已保留真实购买事实");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("100000000000000.00");
tx.execute(s->service.recordPurchase("9","PO-1","7",request));
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command",Integer.class)).isEqualTo(1);
}
@Test void wrongGoodsCannotBeAuthorizedByIncreasingBudget() {
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Wrong item")));
assertThat(result.purchase().issue()).contains("商品");
tx.execute(s->service.authorizeBudget("9","PO-1","8",new Budget(1L,"budget-1",new BigDecimal("200"),"CNY",1,"customer approval")));
var confirmed=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(2L,"confirm-1","authorized")));
assertThat(confirmed.purchase().confirmed()).isFalse();verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void cancellationCannotErasePurchasedFacts() {
tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThatThrownBy(()->tx.execute(s->service.cancel("9","PO-1","7",new Command(1L,"cancel","reason")))).hasMessageContaining("购买后不能取消");
assertThat(service.read("9","PO-1").purchase().recorded()).isTrue();
}
@Test void beforePurchaseCancellationIsIdempotentAndPreventsNewPurchases() {
var request=new Command(0L,"cancel","customer cancellation");
tx.execute(s->service.cancel("9","PO-1","7",request));tx.execute(s->service.cancel("9","PO-1","7",request));
assertThat(service.read("9","PO-1").status()).isEqualTo("CANCELLED");
assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")))).isInstanceOf(ResponseStatusException.class);
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void warehouseSelectionUsesAuthorityAndCannotChangeAfterFirstReceipt() {
when(warehouses.required(91)).thenReturn(new ReceivingWarehouse(91L,"LAX","receiving","US","ACTIVE"));
tx.execute(s->service.selectWarehouse("9","PO-1","7",new Warehouse(0L,"warehouse",91,"plan")));
assertThat(orders.findNo("9","PO-1").getPurchaseWarehouseName()).isEqualTo("receiving");
jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=91 WHERE order_id=11");
assertThatThrownBy(()->tx.execute(s->service.selectWarehouse("9","PO-1","7",new Warehouse(1L,"warehouse-2",92,"change")))).hasMessageContaining("首次实收");
verify(warehouses,never()).required(92);
}
@Test void otherCustomerCannotRecordOrReadThisOrder() {
assertThatThrownBy(()->tx.execute(s->service.recordPurchase("10","PO-1","7",purchase("80","Sample")))).hasMessageContaining("当前客户");
assertThatThrownBy(()->service.read("10","PO-1")).hasMessageContaining("当前客户");
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void reconfirmingRecordedPurchaseDoesNotRegressPhysicalProgress() {
tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
jdbc.update("UPDATE ll_overseas_procurement_order SET status='INTERNATIONAL_TRANSIT' WHERE id=11");
var result=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-again","checked")));
assertThat(result.status()).isEqualTo("INTERNATIONAL_TRANSIT");
verify(finance,times(1)).confirm(anyString(),anyString(),anyString(),any());
}
private Purchase purchase(String amount,String name) {
return new Purchase(0L,"purchase-1",name,"A",10,new BigDecimal(amount),"CNY",LocalDateTime.now().minusDays(1).withNano(0),
LocalDateTime.now().plusDays(3).withNano(0),1,"supplier purchase");
}
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class ProcurementQueryServiceTest {
final ProcurementControlPersistenceTest fixture=new ProcurementControlPersistenceTest();
ProcurementQueryService query;ProcurementTransportService transport;OverseasProcurementService base;
ObjectMapper json=new ObjectMapper().findAndRegisterModules();
@BeforeEach void setup()throws Exception {
fixture.setup();var jdbc=fixture.jdbc;
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String name:List.of("ll_procurement_budget_authorization","ll_procurement_manifest","ll_procurement_carton_source","ll_procurement_label_set","ll_procurement_label_item")) {
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);assertThat(m.find()).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc);
transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json);
base=mock(OverseasProcurementService.class);
when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of()));
query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc);
}
@Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception {
fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11");
fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,confirmed_at,created_by) VALUES(301,11,'9',1,'CONFIRMED',CURRENT_TIMESTAMP,'7')");
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'BOX-A',20,30,40,5.125,10)");
fixture.jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(401,11,'9','PS-1','OVERSEAS',301,CURRENT_TIMESTAMP,'{}','7')");
fixture.jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(401,201,10,'Express','TRACK-1','{\"privateFile\":1001}')");
fixture.jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,client_summary,created_by) VALUES(501,11,'9','PN-1','PURCHASE',0,1001,'private evidence','Customer-approved summary','internal-operator')");
fixture.jdbc.update("INSERT INTO ll_procurement_budget_authorization(id,order_id,customer_id,previous_amount,previous_currency,authorized_amount,currency,voucher_file_id,reason,reviewed_by) VALUES(601,11,'9',100,'CNY',150,'CNY',1001,'private budget discussion','reviewer')");
var fee=new OverseasProcurementFee();fee.setId(1L);fee.setFeeNo("PF-1");fee.setPurpose("SAMPLE_PAYMENT");fee.setFeeItemName("样品货款");fee.setFeeVersion(1);fee.setActive(true);fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");fee.setWalletErrorMessage("private database error");fee.setVoucherFileName("private.pdf");fee.setVoucherFileId(1001L);fee.setWalletLedgerId("private-ledger");
when(fixture.finance.all(11)).thenReturn(List.of(fee));
var result=query.customer("9","PO-1");assertThat(result.budget().totalAmount()).isEqualByComparingTo("150");assertThat(result.purchaseNeedsAttention()).isTrue();
assertThat(result.boxes()).singleElement().satisfies(b->assertThat(b.cartonNo()).isEqualTo("BOX-A"));
assertThat(result.shipments()).singleElement().satisfies(s->assertThat(s.trackingNo()).isEqualTo("TRACK-1"));
var output=json.writeValueAsString(result);assertThat(output).contains("BOX-A","TRACK-1","PF-1","Customer-approved summary")
.doesNotContain("private","voucherFileId","walletLedgerId","createdBy","internal-operator","reason","purchaseFacts");
verifyNoInteractions(base);
}
@Test void platformCombinesDataAndAuthoritativeShippingBlockReasonsWithoutGrantingOtherActions() {
var permissions=Set.of("platform:overseas-business:purchase:receive-overseas","platform:overseas-business:purchase:ship-customer");
var result=query.platform("9","PO-1",permissions);
assertThat(result.internalRemark()).isEqualTo("internal remark");assertThat(result.files()).hasSize(1);
assertThat(result.controls().remaining()).hasSize(1);
assertThat(result.actions()).filteredOn(a->a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a->assertThat(a.allowed()).isTrue());
assertThat(result.actions()).filteredOn(a->a.key().equals("SHIP_CUSTOMER")).singleElement().satisfies(a->{assertThat(a.allowed()).isFalse();assertThat(a.reason()).contains("样品货款");});
assertThat(result.actions()).filteredOn(a->a.key().equals("FINANCE_ADJUST")).singleElement().satisfies(a->assertThat(a.reason()).isEqualTo("无此操作权限"));
}
@Test void anotherCustomerCannotLoadEvenPublicProjection() {
assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base);
}
@Test void draftAndSupersededPackingAreNotDisplayedAsConfirmedCustomerBoxes() {
fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'SUPERSEDED','7'),(302,11,'9',2,'DRAFT','7')");
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)");
assertThat(query.customer("9","PO-1").boxes()).isEmpty();
}
}
@@ -0,0 +1,173 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg;
import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ProcurementTransportPersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;
ProcurementTransportService service;ProcurementFinanceService finance;OverseasProcurementFee estimate;
final Address warehouse=new Address("Overseas","1234567890","US operating address");
final Address domestic=new Address("Domestic","13800000000","Shanghai receiving address");
final Address customer=new Address("Customer","13900000000","Hangzhou customer address");
final LocalDateTime start=LocalDateTime.now().minusHours(2);
@BeforeEach void setup()throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
// H2 CAST(varchar AS JSON) creates a JSON string; FORMAT JSON matches MySQL's object parsing.
jdbc=new JdbcTemplate(ds){@Override public int update(String sql,Object...args){return super.update(sql.replace("CAST(? AS JSON)","? FORMAT JSON"),args);}};
tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command",
"ll_procurement_receipt","ll_procurement_manifest","ll_procurement_carton","ll_procurement_carton_source","ll_procurement_shipment","ll_procurement_shipment_item",
"ll_procurement_label_set","ll_procurement_label_item","ll_procurement_estimate_review","ll_procurement_repack")) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample");
order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");
order.setContactName(customer.name());order.setContactPhone(customer.phone());order.setReceivingAddress(customer.address());order.setStatus("WAIT_LABELS");order.setReviewedAt(start.minusDays(1));
order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(10L);orders.insert(order);
jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_confirmed_at,actual_warehouse_id,overseas_received_at,packing_version) VALUES(11,'9',?,10,?,91,?,1)",start.minusDays(1),start.minusDays(1),start);
jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(101,11,'9','PR-1','OVERSEAS',91,10,0,0,?,'7')",start);
jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'CONFIRMED','7')");
jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'C1',20,30,40,6.125,6),(202,11,301,'C2',20,30,40,4.125,4)");
jdbc.update("INSERT INTO ll_procurement_carton_source VALUES(201,101,6),(202,101,4)");
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','LABEL',1001,'labels.pdf','application/pdf',10,'7'),(502,11,'9','EXCEPTION',1002,'proof.pdf','application/pdf',10,'7')");
var warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.sender(91)).thenReturn(warehouse);
finance=mock(ProcurementFinanceService.class);var payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED");
when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true);
estimate=new OverseasProcurementFee();estimate.setId(801L);estimate.setAmount(BigDecimal.ZERO);estimate.setWalletStatus("ZERO");
when(finance.current(11,ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE")).thenReturn(estimate);
var json=new ObjectMapper().findAndRegisterModules();
var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json);
var physical=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc);
service=new ProcurementTransportService(lifecycle,physical,finance,warehouses,jdbc,json);
}
@Test void entireBatchShipsOnceWithFrozenAddressLabelsAndSources() {
prepare();var request=shipRequest(Leg.OVERSEAS,"ship");
var result=tx.execute(s->service.ship("9","PO-1","7",request));
assertThat(result.status()).isEqualTo("INTERNATIONAL_TRANSIT");
assertThat(result.shipments()).hasSize(1);assertThat(result.shipments().get(0).id()).isPositive();
assertThat(tx.execute(s->service.ship("9","PO-1","7",request)).shipments()).hasSize(1);
assertThatThrownBy(()->ship(Leg.OVERSEAS,"another")).hasMessageContaining("本段已实际交寄");
assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
String address=jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment",String.class);
assertThat(address).contains("US operating address","Shanghai receiving address");
assertThat(jdbc.queryForList("SELECT label_snapshot FROM ll_procurement_shipment_item",String.class)).allSatisfy(v->assertThat(v).contains("receiptId","fileId","cartonNo"));
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void crossOrderFilesWrongPagesMissingCartonsAndUnappliedCartonsCannotShip() {
assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(new Label(201,999,1,"carrier","T1"),label(202)))).hasMessageContaining("凭证不属于");
assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(label(201)))).hasMessageContaining("全部可寄");
jdbc.update("UPDATE ll_overseas_procurement_file SET content_type='image/png' WHERE file_id=1001");
assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(new Label(201,1001,2,"carrier","T1"),label(202)))).hasMessageContaining("页码");
save(Leg.OVERSEAS,List.of(label(201),label(202)));review(BigDecimal.ZERO);
assertThatThrownBy(()->ship(Leg.OVERSEAS,"before-apply")).hasMessageContaining("贴单");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero();
}
@Test void replacementLabelNeedsNewReviewAndNewAppliedFactsWithoutAnotherHold() {
prepare();save(Leg.OVERSEAS,List.of(label(201),label(202)));
assertThatThrownBy(()->ship(Leg.OVERSEAS,"stale")).hasMessageContaining("尚未核对有效预估");
review(BigDecimal.ZERO);
assertThatThrownBy(()->ship(Leg.OVERSEAS,"not-applied")).hasMessageContaining("贴单");
apply();ship(Leg.OVERSEAS,"ship");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_label_set",Integer.class)).isEqualTo(2);
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void unresolvedManagementBlockerStopsBothActionEligibilityAndActualHandoff() {
prepare();
jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,created_by) VALUES(701,11,'9','PN-1','PURCHASE',1,1002,'customer handling unresolved','7')");
assertThat(service.shipmentBlockReason(orders.findNo("9","PO-1"),Leg.OVERSEAS)).contains("阻断异常");
assertThatThrownBy(()->ship(Leg.OVERSEAS,"blocked")).hasMessageContaining("阻断异常");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero();
jdbc.update("UPDATE ll_procurement_control_note SET resolved_at=CURRENT_TIMESTAMP,resolved_by='7',resolution_reason='resolved',resolution_voucher_id=1002 WHERE id=701");
assertThat(service.shipmentBlockReason(orders.findNo("9","PO-1"),Leg.OVERSEAS)).isNull();
assertThat(ship(Leg.OVERSEAS,"after-resolution").shipments()).hasSize(1);
}
@Test void changedPriceReleasesFirstAndPendingMoneyStillBlocksShipping() {
prepare();estimate.setAmount(new BigDecimal("100.00"));estimate.setWalletStatus("FROZEN");
review(new BigDecimal("120.00"));verify(finance).releaseEstimate("9","PO-1",801L);
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
estimate.setWalletStatus("RELEASE_PENDING");
assertThatThrownBy(()->review(new BigDecimal("120.00"))).hasMessageContaining("原预估资金未完成");
assertThatThrownBy(()->ship(Leg.OVERSEAS,"pending")).hasMessageContaining("预估国际运费");
}
@Test void differentRequestsFromTwoOperatorsCannotCreateTwoShipments()throws Exception {
prepare();long version=version();int labels=labelVersion(Leg.OVERSEAS);var executor=Executors.newFixedThreadPool(2);var startGate=new CountDownLatch(1);
try {
var futures=new ArrayList<Future<Boolean>>();
for(String key:List.of("one","two"))futures.add(executor.submit(()->{startGate.await();try{tx.execute(s->service.ship("9","PO-1",key,new Ship(version,key,Leg.OVERSEAS,labels,List.of(201L,202L),start.plusHours(1),"actual")));return true;}catch(ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}}));
startGate.countDown();int count=0;for(var future:futures)if(future.get(10,TimeUnit.SECONDS))count++;assertThat(count).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isEqualTo(1);
}finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,TimeUnit.SECONDS)).isTrue();}
}
@Test void domesticRepackInvalidatesOldLabelsWithoutChangingOriginalShipmentOrQuantity() {
domesticReceipts();save(Leg.DOMESTIC,List.of(label(201),label(202)));
tx.execute(s->service.repack("9","PO-1","7",new Repack(version(),"repack",201,new BigDecimal("25.00"),new BigDecimal("30.00"),new BigDecimal("40.00"),new BigDecimal("5.125"),1002,"damaged box")));
var current=service.read("9","PO-1");
assertThat(current.overseasCargo()).filteredOn(c->c.cartonId()==201).singleElement().satisfies(c->{
assertThat(c.lengthCm()).isEqualByComparingTo("20");assertThat(c.quantity()).isEqualTo(6);assertThat(c.packagingRecordId()).isNull();
});
assertThat(current.domesticCargo()).filteredOn(c->c.cartonId()==201).singleElement().satisfies(c->{
assertThat(c.lengthCm()).isEqualByComparingTo("25");assertThat(c.grossWeightKg()).isEqualByComparingTo("5.125");
assertThat(c.quantity()).isEqualTo(5);assertThat(c.packagingRecordId()).isNotNull();assertThat(c.sources()).containsExactly(new com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Source(102,5));
});
assertThatThrownBy(()->ship(Leg.DOMESTIC,"stale-label")).hasMessageContaining("旧面单");
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull();
save(Leg.DOMESTIC,List.of(label(201),label(202)));jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11");
var result=ship(Leg.DOMESTIC,"domestic");assertThat(result.status()).isEqualTo("SHIPPED_TO_CUSTOMER");
assertThat(jdbc.queryForObject("SELECT SUM(i.quantity) FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.leg='DOMESTIC'",Long.class)).isEqualTo(9L);
assertThat(jdbc.queryForObject("SELECT SUM(i.quantity) FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.leg='OVERSEAS'",Long.class)).isEqualTo(10L);
verify(finance).requireShipmentReady(11);
}
@Test void domesticHandoffRequiresConfirmedAndSettledChargesAndCustomerChangeEvidence() {
domesticReceipts();
var badAddress=new Address("Changed","13600000000","Other street");
assertThatThrownBy(()->tx.execute(s->service.saveLabels("9","PO-1","7",new Labels(version(),"changed-address",Leg.DOMESTIC,1,badAddress,null,List.of(label(201),label(202)),"change")))).hasMessageContaining("确认凭证");
save(Leg.DOMESTIC,List.of(label(201),label(202)));
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=11");
assertThatThrownBy(()->ship(Leg.DOMESTIC,"unknown-charge")).hasMessageContaining("整单费用尚未确认");
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11");
doThrow(ProcurementLifecycleService.conflict("未决资金请求")).when(finance).requireShipmentReady(11);
assertThatThrownBy(()->ship(Leg.DOMESTIC,"funds-pending")).hasMessageContaining("未决资金");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment WHERE leg='DOMESTIC'",Integer.class)).isZero();
}
private void domesticReceipts(){prepare();ship(Leg.OVERSEAS,"international");jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=?,charges_confirmed_at=? WHERE order_id=11",start.plusMinutes(70),start.plusMinutes(80));
jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(102,11,'9','PR-2','DOMESTIC',201,5,1,0,?,'7'),(103,11,'9','PR-3','DOMESTIC',202,4,0,0,?,'7')",start.plusMinutes(65),start.plusMinutes(65));}
private void prepare(){save(Leg.OVERSEAS,List.of(label(201),label(202)));review(BigDecimal.ZERO);apply();}
private long version(){return orders.findNo("9","PO-1").getVersion();}
private int labelVersion(Leg leg){return jdbc.queryForObject("SELECT MAX(version) FROM ll_procurement_label_set WHERE order_id=11 AND leg=?",Integer.class,leg.name());}
private Label label(long carton){return new Label(carton,1001,1,"carrier","TRACK-"+carton);}
private ProcurementTransportService.Snapshot save(Leg leg,List<Label> labels){return tx.execute(s->service.saveLabels("9","PO-1","7",new Labels(version(),"labels-"+version(),leg,1,leg==Leg.OVERSEAS?domestic:customer,null,labels,"checked labels")));}
private void review(BigDecimal amount){tx.execute(s->service.reviewEstimate("9","PO-1","7",new Estimate(version(),"estimate-"+version(),labelVersion(Leg.OVERSEAS),amount,LocalDate.now(),"quoted")));}
private void apply(){tx.execute(s->service.applyLabels("9","PO-1","7",new ApplyLabels(version(),"apply-"+version(),labelVersion(Leg.OVERSEAS),List.of(201L,202L),start.plusMinutes(30),"attached")));}
private Ship shipRequest(Leg leg,String key){return new Ship(version(),key,leg,labelVersion(leg),List.of(201L,202L),start.plusMinutes(leg==Leg.OVERSEAS?60:100),"actual handoff");}
private ProcurementTransportService.Snapshot ship(Leg leg,String key){return tx.execute(s->service.ship("9","PO-1","7",shipRequest(leg,key)));}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.*;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class ProcurementWarehouseDirectoryTest {
private final DomesticWarehouseDirectoryClient client=mock(DomesticWarehouseDirectoryClient.class);
private final ProcurementWarehouseDirectory directory=new ProcurementWarehouseDirectory(client);
private final Address address=new Address("OPERATING","US","CA","Los Angeles",null,"90001","123 Warehouse Street",null);
private void receiving(){
ReflectionTestUtils.setField(directory,"token","test-internal");
when(client.receivingWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new ReceivingWarehouse(91L,"LAX","Receiving","US","ACTIVE")));
}
@Test void senderUsesAuthoritativeSharedWarehouseContactAndOperatingAddress() {
receiving();when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(91L,0L,"LAX","Receiving","OVERSEAS","US","ACTIVE","Receiver","+1 1234567890"),List.of(address))));
var result=directory.sender(91);assertThat(result.name()).isEqualTo("Receiver");assertThat(result.phone()).isEqualTo("+1 1234567890");
assertThat(result.address()).contains("123 Warehouse Street","90001","Los Angeles");
}
@Test void missingContactAndAmbiguousOperatingAddressesAreNotInvented() {
receiving();when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(91L,0L,"LAX","Receiving","OVERSEAS","US","ACTIVE",null,null),List.of(address))));
assertThatThrownBy(()->directory.sender(91)).hasMessageContaining("资料");
when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(91L,0L,"LAX","Receiving","OVERSEAS","US","ACTIVE","Receiver","1234567890"),List.of(address,address))));
assertThatThrownBy(()->directory.sender(91)).hasMessageContaining("唯一作业地址");
}
@Test void mismatchingOrCustomerOwnedMasterCannotSupplySender() {
receiving();when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(92L,9L,"LAX","Other","OVERSEAS","US","ACTIVE","Receiver","1234567890"),List.of(address))));
assertThatThrownBy(()->directory.sender(91)).hasMessageContaining("暂不可用");
}
}
@@ -102,7 +102,7 @@ class FbaWarehouseValidatorTest {
private DomesticWarehouseDirectoryClient.Detail detail(String type, String status, String code, String name) {
return new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name,
type, "US", status),
type, "US", status, null, null),
List.of(new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles",
null, "90001", "123 Logistics Ave", null)));
}
@@ -83,7 +83,7 @@ class WarehouseTransferReceiptCoordinatorTest {
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE", null, null), List.of())));
when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive(), "Remote reserve must not hold local transaction locks");
reserveCalls.incrementAndGet(); long pallet = call.getArgument(2), wh = call.getArgument(1), location = call.getArgument(3);
@@ -253,10 +253,10 @@ class WarehouseTransferReceiptCoordinatorTest {
}
@Test void inactiveOrForeignTypeFbaMasterStopsBeforeResourceOccupation() {
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "OVERSEAS", "US", "ACTIVE"), List.of())));
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "OVERSEAS", "US", "ACTIVE", null, null), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "DISABLED"), List.of())));
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "DISABLED", null, null), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void inactiveReceivingWarehouseOrForeignPalletCannotCreateReceiptCommand() {
@@ -271,7 +271,7 @@ class WarehouseTransferReceiptCoordinatorTest {
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
when(warehouse.resolvePallet(20L)).thenReturn(pallet(20, "IDLE"));
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(31L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
new DomesticWarehouseDirectoryClient.Warehouse(31L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE", null, null), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void sourceWithoutShippedOwnershipIsRejectedBeforeAnyRemoteCall() {
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.wallet;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.WalletCommandRequestedV1;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class WalletCommandOutboxServiceTest {
@Test
void replayRetainsEventIdentityButRejectsDifferentFinancialFacts() throws Exception {
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
Map<String, LastlegEventOutbox> saved = new HashMap<>();
when(mapper.findByEventKey(anyString())).thenAnswer(i -> saved.get(i.getArgument(0)));
when(mapper.insert(any(LastlegEventOutbox.class))).thenAnswer(i -> {
LastlegEventOutbox row = i.getArgument(0); saved.put(row.getEventKey(), row); return 1;
});
WalletCommandOutboxService service = new WalletCommandOutboxService(mapper, json);
String first = service.publishRefund(9, "PROCUREMENT_FEE", "RF-1", new BigDecimal("20"), "72", "correction", 1);
assertThat(service.publishRefund(9, "PROCUREMENT_FEE", "RF-1", new BigDecimal("20.00"), "72", "correction", 1))
.isEqualTo(first);
assertThatThrownBy(() -> service.publishRefund(9, "PROCUREMENT_FEE", "RF-1", new BigDecimal("21"), "72", "correction", 1))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> service.publishRefund(9, "PROCUREMENT_FEE", "RF-1", new BigDecimal("20"), "73", "correction", 1))
.isInstanceOf(IllegalStateException.class);
ArgumentCaptor<LastlegEventOutbox> row = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(row.capture());
EventEnvelope event = json.readValue(row.getValue().getPayloadJson(), EventEnvelope.class);
WalletCommandRequestedV1 command = json.treeToValue(event.payload(), WalletCommandRequestedV1.class);
assertThat(command.originalLedgerId()).isEqualTo("72");
assertThat(command.operation()).isEqualTo("REFUND");
}
@Test
void refundCannotUseUnreferencedGenericCommand() {
var mapper = mock(LastlegEventOutboxMapper.class);
var service = new WalletCommandOutboxService(mapper, new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> service.publish("REFUND", 9, "PROCUREMENT_FEE", "RF-1", BigDecimal.TEN, "reason", 1))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(mapper);
}
}
+3
View File
@@ -1,5 +1,8 @@
# 后端脚本管理与全量 HTTP 验收索引
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
`db/local-four-flows-schema-incremental-20261009.sql`:本地退货换标、仓储转运、贴标出库及退货出库结构比对所得的 MySQL 8 定向增量。显式 `USE baiyue_lastleg`,补齐预约结束时间/版本、转运换标来源绑定、分配 SKU 快照及转运收货幂等命令表;字段按存在性判断,表使用 `CREATE TABLE IF NOT EXISTS`,可重复执行,不删除或回填业务数据。使用操作者显式选择的本地数据库连接,无内置凭据;完整执行输出 `LOCAL_FLOW_SCHEMA_READY`。失败保留已完成的 DDL 与原错误,核对后重跑;不自动清理业务记录,不是全库迁移或生产验收入口。
`db/return-tail-order-number-incremental-20261009.sql` 将已验证关联的退货尾程单号及退货单尾程号快照统一为退货单号。显式 `USE baiyue_lastleg`,可重复执行,只更新同客户、关联订单 ID 和来源退货号均一致的 `RETURN_RELABEL` 单据;不改变业务 ID、库存或履约状态。执行前核对目标数据库,执行后核验查询应为空,异常关系保留供核实。已同步海外业务统一增量脚本。
+6 -2
View File
@@ -1,5 +1,7 @@
# Admin 测试环境 HTTP 模块
`procurement-sample-fulfillment.mjs` 是采购样品完整正常路径的内部验收助手,钱包综合模块也调用同一流程,并将采购请求证据独占保存于 `reports/procurement-wallet-checkpoints/<marker>.json`;客户会话在 finally 退出。由 `../procurement-sample-test-env.mjs` 注入本轮独立已充值客户、客户会话、有效收货仓、受控上传及权威钱包读取。创建新样本,执行正额货款、两次海外收货、两箱装箱、预估冻结、国际交寄、逐箱国内收货、实际核费及寄客户,逐阶段核对钱包现金/冻结和费用来源,关键请求原键重放。每次请求前必须 checkpoint;未知结果停写保留样本,不自动新键重试或清理。已接入显式 TEST 入口;未执行时不计作真实 HTTP 通过,也不覆盖异常、退款或权限全部矩阵。离线合同 `procurement-sample-fulfillment-contract.test.mjs` 验证前置缺失、余额不足、请求前持久化及未知结果停写。
`phase5-root-fulfillment` 可由总入口显式 `--prepare-domestic-pda-h5 true` 仅准备本轮国内H5待拣任务。调用 `phase3-order-splitting.loadOneFragment` 到正式计划确认、待拣任务及箱唛就绪即返回,不领取、不拣货、不继续海外;结果固定 `PREPARED_NOT_VERIFIED`,不算全链验收。默认完整链不变,范围及授权见根脚本README。
`fleet-capacity-pool-schema-contract.test.mjs` 是离线数据库合同:验证发布与持久命令唯一约束、唯一商业履约来源、可空实际数量、服务与统一基线一致、非破坏性结构补齐及独立销售/调度权限;不访问环境、不证明真实履约已通过。
@@ -269,7 +271,7 @@ export async function run(context) {
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购两箱完整履约、货款实扣/预估冻结/实际核费、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs`、`pda-picker-fixture-contract.test.mjs`(长场景名保留完整运行标识,避免造数账号跨轮重复) |
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
@@ -279,7 +281,7 @@ export async function run(context) {
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;依赖本轮海外签收,保留有有效余量根订单的 `DELIVERED`;完成订单样本经 `phase5-root-fulfillment.mjs` 两票真实签收和只读完成 Outbox 核验产生,不调用已移除的人工 `/complete`,并继续验证装柜完成、报关和订舱状态组合 |
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` |
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
@@ -431,3 +433,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。
下载中心导出共用内部助手:`download-center.mjs` 的 `verifyDownloadCenterExport` 由尾程订单、海外库存及主数据模块调用,创建 File XLSX 任务、等待终态、下载并核对字段及本轮业务数据;环境和写入授权沿所属模块。离线 `download-center-fixture.mjs` 仅生成合同测试使用的 XLSX 字节;`download-center-contract.test.mjs` 校验失败不继续下载及文件内容验证,不访问环境。
采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。
@@ -29,11 +29,10 @@ function fixture(options = {}) {
const credentials = options.credentials === false ? {} : { username: 'merchant', password: 'secret' }
function purchaseDetail() {
return { summary: state.purchase, productUrl: state.purchase?.productUrl ?? null,
domesticCarrier: state.purchase?.domesticCarrier ?? null, domesticTrackingNo: state.purchase?.domesticTrackingNo ?? null,
estimatedArrivalAt: state.purchase?.estimatedArrivalAt ?? null, remark: state.purchase?.remark ?? null,
histories: state.purchaseHistories }
return { summary: state.purchase, remark: state.purchase?.remark ?? null, histories: state.purchaseHistories }
}
function purchaseCustomer() { return { order: state.purchase, fees: [], progress: [], boxes: [], shipments: [], exceptionSummaries: [] } }
function purchasePlatform() { return { customerView: purchaseCustomer(), internalRemark: state.purchase?.remark ?? null, reviewHistory: state.purchaseHistories } }
function truckDetail() { return { summary: state.truck, remark: state.truck?.remark ?? null, histories: state.truckHistories } }
function looseboardDetail() { return { summary: state.looseboard, remark: state.looseboard?.remark ?? null, histories: state.looseboardHistories } }
@@ -49,15 +48,15 @@ function fixture(options = {}) {
if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') {
assert.equal(token, 'merchant-overseas-token')
const hash = JSON.stringify(body)
if (state.purchase) return hash === state.purchaseHash ? response(purchaseDetail()) : response(null, 'A0409')
if (state.purchase) return hash === state.purchaseHash ? response(purchaseCustomer()) : response(null, 'A0409')
state.purchaseHash = hash
state.purchase = { id: '30001', orderNo: 'OP1', customerId: '10001', customerName: body.customerName,
purchaseWarehouseId: null, purchaseWarehouseName: null, purchaseCategory: body.purchaseCategory,
productName: body.productName, productSpecification: body.productSpecification, productUrl: body.productUrl,
purchaseQuantity: body.purchaseQuantity, inboundQuantity: 0, inventoryQuantity: 0,
purchaseQuantity: body.purchaseQuantity,
status: 'PENDING_REVIEW', version: 0, remark: body.remark }
state.purchaseHistories.push({ operationType: 'CREATE', fromStatus: null, toStatus: 'PENDING_REVIEW', remark: marker })
return response(purchaseDetail())
return response(purchaseCustomer())
}
if (requestPath.includes('/customer-options')) return response(state.purchase
? [{ customerId: '10001', customerName: '客户甲' }] : [])
@@ -71,24 +70,19 @@ function fixture(options = {}) {
if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409')
if (state.purchase.status !== 'PENDING_REVIEW') return response(null, 'A0409')
if (Number(body.version) !== state.purchase.version) return response(null, 'A0409')
const next = body.decision === 'REJECT' ? 'REJECTED' : 'APPROVED'
const next = body.decision === 'REJECT' ? 'REJECTED' : 'WAIT_PURCHASE'
state.purchase = { ...state.purchase, status: next, version: state.purchase.version + 1 }
state.purchaseHistories.push({ operationType: 'REVIEW', fromStatus: 'PENDING_REVIEW', toStatus: next, remark: body.remark })
const result = structuredClone(purchaseDetail())
state.purchaseReviews.set(body.idempotencyKey, { hash, result })
return response(result)
}
if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response(purchaseDetail())
if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response(purchasePlatform())
if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') {
if (Number(body.version) !== state.purchase.version) return response(null, 'A0409')
const patch = { ...body }; delete patch.version
for (const key of ['productSpecification', 'productUrl', 'domesticCarrier', 'domesticTrackingNo', 'remark']) {
if (Object.hasOwn(patch, key)) patch[key] = blankToNull(patch[key])
}
if (Object.hasOwn(patch, 'purchaseWarehouseId') && blankToNull(patch.purchaseWarehouseId) == null) {
patch.purchaseWarehouseId = null; patch.purchaseWarehouseName = null
}
state.purchase = { ...state.purchase, ...patch, version: state.purchase.version + 1 }
if (Object.keys(body).some(key => !['version', 'remark'].includes(key))) return response(null, 'A0400')
if (!Object.hasOwn(body, 'remark') || blankToNull(body.remark) === state.purchase.remark) return response(purchaseDetail())
state.purchase = { ...state.purchase, remark: blankToNull(body.remark), version: state.purchase.version + 1 }
state.purchaseHistories.push({ operationType: 'UPDATE', fromStatus: state.purchase.status,
toStatus: state.purchase.status, changeDetail: JSON.stringify({ marker }) })
return response(purchaseDetail())
@@ -180,7 +174,8 @@ function fixture(options = {}) {
} : async function(method,requestPath,body) {
calls.push({ channel: 'domestic', method, path: requestPath, body })
if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers))
if (requestPath === '/api/auth/login') return response({ accessToken: 'merchant-overseas-token' })
if (requestPath === '/api/auth/login') return response({ accessToken: 'merchant-overseas-token', refreshToken: 'merchant-refresh-token' })
if (requestPath === '/api/auth/logout') { assert.equal(body.refreshToken, 'merchant-refresh-token'); return response(null) }
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) {
state.warehouseStatus = 'INACTIVE'; return response(null)
@@ -222,19 +217,21 @@ test('module is context injected, blocks missing prerequisites and excludes forb
assert.doesNotMatch(source, /request\('DELETE'/)
})
test('complete procurement truck and looseboard lifecycles pass with legal marker terminals', async () => {
test('procurement creation review boundaries and transport cancellation paths pass with legal marker terminals', async () => {
const context = fixture()
await run(context)
assert.equal(context.steps.every((step) => step.passed), true,
context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
assert.equal(context.gaps.length, 0)
assert.equal(context.blockers.length, 0)
assert.equal(context.calls.filter(call => call.path === '/api/auth/login').length, 1)
assert.equal(context.calls.filter(call => call.path === '/api/auth/logout').length, 1)
const procurementWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET')
assert.deepEqual(procurementWrites.map((call) => call.method),
['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST', 'POST', 'POST'])
assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 4)
assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.productSpecification === null && call.body.productUrl === ''))
['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST', 'POST', 'POST'])
assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 5)
assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.remark === null))
const procurementReviews = procurementWrites.filter((call) => call.path.includes('/review?'))
assert.equal(procurementReviews.length, 4)
assert.ok(procurementReviews.every((call) => Number.isInteger(Number(call.body.version))
@@ -326,7 +323,7 @@ test('candidate blocker still rejects partial procurement and retires partner wa
test('contract locks candidates three-state idempotency histories legal terminals and trace evidence', () => {
for (const token of ['/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE',
'/overseas-procurements/customer-options?limit=200', '/overseas-procurements/warehouse-options',
'/api/merchant/v1/last-leg/overseas-procurements', '显式 null 与空串清空', '字段缺失不修改',
'/api/merchant/v1/last-leg/overseas-procurements', '采购备注显式清空', '缺失备注不修改且版本不漂移',
'同幂等键同载荷精确重放', '同幂等键异载荷返回冲突', '旧 version 返回 A0409',
'状态正向推进', '状态逆向回退返回冲突', '流水覆盖', "status: 'CANCELLED'",
'/review', 'truck-review-approve', 'looseboard-status-dispatched',
@@ -9,7 +9,6 @@ function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${c
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function query(path, params) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) if (value !== '' && value != null) search.set(key, String(value)); return `${path}?${search}` }
function summary(data) { return data?.summary && typeof data.summary === 'object' ? data.summary : data }
function detailValue(data, field) { return data && Object.hasOwn(data, field) ? data[field] : summary(data)?.[field] }
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response).endsWith('409')) }
function versionConflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' }
function longId(value) { return /^[1-9]\d*$/.test(id(value)) }
@@ -60,7 +59,7 @@ function partnerWarehousePayload(context) {
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null,
latitude: null, longitude: null },
capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true,
capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: 'Receiving', enabled: true,
cutoffTime: null, description: `${context.marker} procurement acceptance` }],
}
}
@@ -82,13 +81,13 @@ function procurementPayload(context, activeCustomer) {
customerName: activeCustomer?.tenantName || activeCustomer?.name || `Customer-${id(activeCustomer?.id)}`,
purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 采购商品`,
productSpecification: 'Phase4 acceptance', productUrl: null, purchaseQuantity: 10,
budgetUnitPrice: 1.25, currency: 'USD', receivingAddress: 'Los Angeles',
budgetUnitPrice: 1.25, currency: 'USD', receivingAddress: '广东省深圳市南山区科技园测试路 1 号',
contactName: '采购验收联系人', contactPhone: '13800000002', remark: context.marker,
idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255),
}
}
async function loginMerchant(context, activeCustomer) {
async function loginMerchant(context, activeCustomer, session) {
const fixture = context.context?.merchantQualificationFixture
if (!nonEmpty(fixture?.username) || !nonEmpty(fixture?.password)) {
return blocker(context, '采购客户登录前置完整', null,
@@ -97,6 +96,7 @@ async function loginMerchant(context, activeCustomer) {
const login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS)
const token = auth?.accessToken
session.accessToken = token; session.refreshToken = auth?.refreshToken
context.assert(MODULE, '采购客户登录返回统一Token', login, nonEmpty(token),
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(token)}; ${trace(login)}`, OPTIONS)
return nonEmpty(token) ? token : null
@@ -105,22 +105,10 @@ async function loginMerchant(context, activeCustomer) {
async function createMerchantProcurement(context, activeCustomer, token) {
const path = '/api/merchant/v1/last-leg/overseas-procurements'
const payload = procurementPayload(context, activeCustomer)
let activeToken = token
let response = await context.request('POST', path, payload, activeToken, {})
// A newly approved customer is projected to the overseas region asynchronously. The
// domestic login can therefore succeed a few seconds before the overseas gateway has
// received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact
// security-projection conflict and always issue a fresh short-lived overseas token.
for (let attempt = 1; attempt <= 12
&& response?.http === 403 && codeOf(response) === 'A0403'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
const refreshedToken = await loginMerchant(context, activeCustomer)
if (!refreshedToken) break
activeToken = refreshedToken
response = await context.request('POST', path, payload, activeToken, {})
}
const activeToken = token
const response = await context.request('POST', path, payload, activeToken, {})
const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
const value = summary(detail)
const value = detail?.order
const resource = nonEmpty(value?.orderNo) && longId(value?.customerId)
? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId),
version: Number(value.version), finalStatus: value.status, retained: true }
@@ -134,7 +122,7 @@ async function createMerchantProcurement(context, activeCustomer, token) {
const replayResponse = await context.request('POST', path, payload, activeToken, {})
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
const replay = summary(replayDetail)
const replay = replayDetail?.order
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version),
`orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS)
@@ -145,7 +133,7 @@ async function createMerchantProcurement(context, activeCustomer, token) {
return { resource, payload }
}
async function prerequisites(context) {
async function prerequisites(context, session) {
const tenants = await domesticGet(context, '查询 ACTIVE 客户候选', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
assertPage(context, 'ACTIVE 客户候选', tenants)
const activeCustomers = records(tenants.data).filter((item) => longId(item?.id) && item?.status === 'ACTIVE')
@@ -155,7 +143,7 @@ async function prerequisites(context) {
const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
const token = await loginMerchant(context, activeCustomer)
const token = await loginMerchant(context, activeCustomer, session)
if (!token) return null
const partnerWarehouse = await createPartnerWarehouse(context)
if (!partnerWarehouse) return null
@@ -173,18 +161,17 @@ async function prerequisites(context) {
const procurementCustomer = Array.isArray(customerOptions.data)
? customerOptions.data.find((item) => id(item?.customerId) === id(activeCustomer.id)) : null
const warehouse = Array.isArray(warehouseOptions.data)
? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id)
|| warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null
? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id) : null
if (!procurementCustomer) return blocker(context, '采购客户候选包含本轮客户', customerOptions.response,
`本轮客户 ${id(activeCustomer.id)} 未进入采购客户候选,禁止继续写验收`)
if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response,
'采购仓候选为空,禁止跳过采购三态清空')
'采购仓候选为空,禁止跳过采购备注与审核边界')
return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions,
partnerWarehouse, purchase, token }
}
function assertProcurementHistory(context, result) {
const histories = Array.isArray(result.data?.histories) ? result.data.histories : []
const histories = Array.isArray(result.data?.reviewHistory) ? result.data.reviewHistory : []
const operations = new Set(histories.map((item) => item?.operationType))
context.assert(MODULE, '采购流水覆盖创建编辑与审核', result.response,
['CREATE', 'UPDATE', 'REVIEW'].every((type) => operations.has(type))
@@ -207,55 +194,42 @@ async function procurementFlow(context, prerequisite) {
const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}`, { customerId })
const detail = await overseas(context, 'GET', '查询本轮采购订单详情', path)
const before = summary(detail.data)
const before = detail.data?.customerView?.order
context.assert(MODULE, '采购详情归属和初始版本正确', detail.response,
before?.orderNo === owned.orderNo && id(before?.customerId) === customerId && Number(before?.version) === 0,
`orderNo=${id(before?.orderNo) || 'missing'}, version=${before?.version ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
const setResult = await overseas(context, 'PATCH', '采购可空字段设置非空值', path,
{ version: Number(before?.version), productSpecification: `${context.marker}-SPEC`,
productUrl: `phase4-product-${context.marker}`, purchaseWarehouseId: warehouseId,
purchaseWarehouseName: prerequisite.warehouse.warehouseName, domesticCarrier: `${context.marker}-CARRIER`,
domesticTrackingNo: `${context.marker}-TRACK`, estimatedArrivalAt: '2026-12-31T12:00:00', remark: context.marker })
const setSummary = summary(setResult.data)
context.assert(MODULE, '采购三态第一态设置值并递增版本', setResult.response,
setSummary?.productSpecification === `${context.marker}-SPEC`
&& detailValue(setResult.data, 'productUrl')?.includes(context.marker)
&& id(setSummary?.purchaseWarehouseId) === warehouseId
&& Number(setSummary?.version) === Number(before?.version) + 1,
`version=${setSummary?.version ?? 'missing'}, warehouseId=${id(setSummary?.purchaseWarehouseId) || 'missing'}; ${trace(setResult.response)}`, OPTIONS)
const setResult = await overseas(context, 'PATCH', '设置采购内部备注', path,
{ version: Number(before?.version), remark: `${context.marker}-edited` })
const setSummary = setResult.data?.summary
context.assert(MODULE, '采购备注设置并递增版本', setResult.response,
setResult.data?.remark === `${context.marker}-edited` && Number(setSummary?.version) === Number(before?.version) + 1,
trace(setResult.response), OPTIONS)
const omittedResult = await overseas(context, 'PATCH', '采购可空字段缺失保持原值', path,
{ version: Number(setSummary?.version), domesticCarrier: `${context.marker}-CARRIER-2` })
const omittedSummary = summary(omittedResult.data)
context.assert(MODULE, '采购三态第二态字段缺失不修改', omittedResult.response,
omittedSummary?.productSpecification === setSummary?.productSpecification
&& detailValue(omittedResult.data, 'productUrl') === detailValue(setResult.data, 'productUrl')
&& id(omittedSummary?.purchaseWarehouseId) === warehouseId
&& Number(omittedSummary?.version) === Number(setSummary?.version) + 1,
`version=${omittedSummary?.version ?? 'missing'}, specification=${omittedSummary?.productSpecification || 'missing'}; ${trace(omittedResult.response)}`, OPTIONS)
const clearedResult = await overseas(context, 'PATCH', '采购可空字段显式 null 与空串清空', path,
{ version: Number(omittedSummary?.version), productSpecification: null, productUrl: '', purchaseWarehouseId: null,
purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: '', estimatedArrivalAt: null, remark: null })
const clearedSummary = summary(clearedResult.data)
context.assert(MODULE, '采购三态第三态显式清空全部生效', clearedResult.response,
clearedSummary?.productSpecification == null && detailValue(clearedResult.data, 'productUrl') == null
&& clearedSummary?.purchaseWarehouseId == null && clearedSummary?.purchaseWarehouseName == null
&& detailValue(clearedResult.data, 'domesticCarrier') == null
&& detailValue(clearedResult.data, 'domesticTrackingNo') == null
&& detailValue(clearedResult.data, 'estimatedArrivalAt') == null
&& Number(clearedSummary?.version) === Number(omittedSummary?.version) + 1,
`version=${clearedSummary?.version ?? 'missing'}, specification=${clearedSummary?.productSpecification ?? 'null'}, warehouseId=${clearedSummary?.purchaseWarehouseId ?? 'null'}; ${trace(clearedResult.response)}`, OPTIONS)
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' })
const omittedResult = await overseas(context, 'PATCH', '采购备注缺失保持原值', path,
{ version: Number(setSummary?.version) })
context.assert(MODULE, '缺失备注不修改且版本不漂移', omittedResult.response,
omittedResult.data?.remark === `${context.marker}-edited` && Number(omittedResult.data?.summary?.version) === Number(setSummary?.version),
trace(omittedResult.response), OPTIONS)
const clearedResult = await overseas(context, 'PATCH', '采购备注显式清空', path,
{ version: Number(setSummary?.version), remark: null })
const clearedSummary = clearedResult.data?.summary
context.assert(MODULE, '采购备注清空只变更本字段', clearedResult.response,
clearedResult.data?.remark == null && Number(clearedSummary?.version) === Number(setSummary?.version) + 1,
trace(clearedResult.response), OPTIONS)
const forbidden = await context.request('PATCH', path,
{ version: Number(clearedSummary?.version), domesticTrackingNo: 'FORBIDDEN' })
context.assert(MODULE, '通用编辑拒绝旧履约字段', forbidden,
forbidden?.http === 400 || (forbidden?.http === 200 && codeOf(forbidden) === 'A0400'), trace(forbidden), OPTIONS)
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), remark: 'STALE-DO-NOT-APPLY' })
expectedConflict(context, '采购旧 version 返回 A0409', stale, true)
const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path)
const afterConflictSummary = summary(afterConflict.data)
context.assert(MODULE, '采购旧 version 冲突未造成部分更新', afterConflict.response,
afterConflictSummary?.productSpecification == null
const afterConflictSummary = afterConflict.data?.customerView?.order
context.assert(MODULE, '采购拒绝请求不改变商品与版本', afterConflict.response,
afterConflictSummary?.productSpecification === before?.productSpecification
&& afterConflict.data?.internalRemark == null
&& Number(afterConflictSummary?.version) === Number(clearedSummary?.version),
`version=${afterConflictSummary?.version ?? 'missing'}, specification=${afterConflictSummary?.productSpecification ?? 'null'}; ${trace(afterConflict.response)}`, OPTIONS)
trace(afterConflict.response), OPTIONS)
const rejected = await overseas(context, 'POST', '驳回本轮采购订单并进入合法终态',
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
@@ -279,12 +253,12 @@ async function procurementFlow(context, prerequisite) {
const reviewHashConflict = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict)
const reverseReview = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { version: Number(rejectedSummary?.version), decision: 'APPROVE',
idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255),
purchaseWarehouseId: warehouseId, idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255),
remark: 'REVERSE-DO-NOT-APPLY' })
expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview)
const final = await overseas(context, 'GET', '采购最终状态与流水回查', path)
const finalSummary = summary(final.data)
const finalSummary = final.data?.customerView?.order
context.assert(MODULE, '采购 marker 资源最终保持合法终态', final.response,
finalSummary?.status === 'REJECTED' && Number(finalSummary?.version) === Number(rejectedSummary?.version),
`status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS)
@@ -536,8 +510,8 @@ async function finalizePendingPurchase(context, resource) {
const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}`,
{ customerId: resource.customerId })
const current = await overseas(context, 'GET', '采购最终处置前回查本轮订单', path)
const currentSummary = summary(current.data)
if (['REJECTED', 'APPROVED'].includes(currentSummary?.status)) {
const currentSummary = current.data?.customerView?.order
if (['REJECTED', 'CANCELLED'].includes(currentSummary?.status)) {
resource.finalStatus = currentSummary.status; resource.version = Number(currentSummary.version); resource.retained = false
return
}
@@ -555,7 +529,7 @@ async function finalizePendingPurchase(context, resource) {
remark: `${context.marker}-prerequisite-cleanup` })
const rejectedSummary = summary(rejected.data)
const verified = await overseas(context, 'GET', '回查未完成采购订单最终状态', path)
const finalSummary = summary(verified.data)
const finalSummary = verified.data?.customerView?.order
const passed = rejectedSummary?.status === 'REJECTED' && finalSummary?.status === 'REJECTED'
&& Number(finalSummary?.version) === Number(rejectedSummary?.version)
context.assert(MODULE, '采购本轮测试数据最终合法驳回', verified.response, passed,
@@ -567,8 +541,9 @@ async function finalizePendingPurchase(context, resource) {
export async function run(context) {
let prerequisite = null
const session = {}
try {
prerequisite = await prerequisites(context)
prerequisite = await prerequisites(context, session)
if (!prerequisite) return
await procurementFlow(context, prerequisite)
const customerId = id(prerequisite.activeCustomer.id)
@@ -576,7 +551,16 @@ export async function run(context) {
await looseboardFlow(context, customerId)
} finally {
const registry = context.createdResources.phase4OverseasBusiness
await finalizePendingPurchase(context, registry?.purchase)
await retirePartnerWarehouse(context, registry?.partnerWarehouse)
try {
await finalizePendingPurchase(context, registry?.purchase)
await retirePartnerWarehouse(context, registry?.partnerWarehouse)
} finally {
if (nonEmpty(session.refreshToken)) {
const loggedOut = await context.request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken || '')
context.apiSuccess(MODULE, '退出本轮采购客户会话', loggedOut, trace(loggedOut), OPTIONS)
} else if (nonEmpty(session.accessToken)) {
blocker(context, '采购客户会话退出凭据缺失', null, '登录响应缺少 refreshToken,无法确认会话退出')
}
}
}
}
@@ -55,6 +55,7 @@ function fixture() {
}
if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/finance-fee-sources?')) return response([])
if (method === 'POST' && path === '/api/platform/v1/last-leg/finance-fee-sources') {
if (body.businessType === 'OVERSEAS_PROCUREMENT') return response(null, 'A0409')
const key = headers['Idempotency-Key']
const hash = JSON.stringify(body)
const existing = commands.get(`CREATE:${key}`)
@@ -134,13 +135,13 @@ test('module uses only injected real HTTP clients and never fabricates database/
]) assert.ok(source.includes(token), token)
})
test('five fee types traverse Lastleg lifecycle and reach Finance through real HTTP', async () => {
test('four generic fee types publish while procurement bypass is rejected', async () => {
const context = fixture()
await run(context)
const registry = context.createdResources.phase4OverseasFinanceCrossRegion
assert.equal(registry.sources.length, 5)
assert.equal(registry.sources.length, 4)
assert.deepEqual(new Set(registry.sources.map((item) => item.businessType)), new Set([
'OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE',
'OVERSEAS_INBOUND', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE',
]))
const warehouseCreate = context.calls.find((call) => call.method === 'POST'
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources'
@@ -163,15 +164,16 @@ test('five fee types traverse Lastleg lifecycle and reach Finance through real H
const creates = context.calls.filter((call) => call.method === 'POST'
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources')
assert.equal(creates.length, 12)
assert.equal(creates.length, 11)
assert.ok(context.steps.find((step) => step.name === '费用创建同键异载荷冲突')?.passed)
assert.ok(context.steps.find((step) => step.name === '海外费用列表不会泄露其他客户来源')?.passed)
assert.ok(context.steps.find((step) => step.name === '其他客户不能关联本客户业务创建费用')?.passed)
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).length, 5)
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).length, 4)
assert.ok(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).every((step) => step.passed))
assert.ok(context.steps.find((step) => step.name === '统一Gateway不公开内部事件运维接口')?.passed)
assert.deepEqual(context.steps.filter((step) => !step.passed), [])
assert.ok(context.steps.find(step => step.name === '采购费用禁止绕过履约实扣创建通用来源')?.passed)
assert.equal(context.scenarios[0].deploymentBlocked, false)
assert.equal(context.scenarios[0].success, true)
})
@@ -222,17 +222,22 @@ export async function run(context) {
const fixtures = await resolveFixtures(context)
const definitions = [
{ label: '海外入仓', businessType: 'OVERSEAS_INBOUND', sourceType: 'OVERSEAS_INBOUND_FEE', fixture: fixtures.inbound },
{ label: '海外采购', businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', fixture: fixtures.procurement },
{ label: '美国卡车', businessType: 'US_TRUCK', sourceType: 'OVERSEAS_TRUCK_FEE', fixture: fixtures.truck },
{ label: '本土散板', businessType: 'LOCAL_LOOSEBOARD', sourceType: 'OVERSEAS_LOOSEBOARD_FEE', fixture: fixtures.looseboard },
{ label: '海外仓服务', businessType: 'WAREHOUSE_SERVICE', sourceType: 'OVERSEAS_WAREHOUSE_FEE', fixture: fixtures.warehouse },
]
if (fixtures.procurement) {
const definition = { label: '海外采购', businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE' }
const denied = await context.request('POST', LASTLEG_BASE, feePayload(context,definition,fixtures.procurement,0), undefined,
{ 'Idempotency-Key': `${context.marker}-procurement-bypass-denied`.slice(0,128) })
context.assert(MODULE, '采购费用禁止绕过履约实扣创建通用来源', denied, conflict(denied), trace(denied), OPTIONS)
}
const sources = []
for (const [index, definition] of definitions.entries()) {
const source = await createApprove(context, definition, definition.fixture, index)
if (source) sources.push(source)
}
context.assert(MODULE, '五类海外费用均创建、提交并审核', null, sources.length === definitions.length,
context.assert(MODULE, '四类通用海外费用均创建、提交并审核', null, sources.length === definitions.length,
`created=${sources.length}, expected=${definitions.length}`, OPTIONS)
const warehouseSource = sources.find((item) => item.definition.businessType === 'WAREHOUSE_SERVICE')
context.assert(MODULE, '仓储服务费用仅关联客户和仓库', null,
@@ -21,10 +21,7 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
'creditExpiresOn',
'/accounts/${encodeURIComponent(customerId)}/adjustments',
"const CLIENT_FINANCE = '/api/client/finance/v1'",
"['VOUCHER', 'RECEIPT']",
"walletStatus === 'FROZEN'",
"walletStatus === 'SETTLED'",
"['FREEZE_PENDING', 'FROZEN']",
"['FREEZE', 'CONSUMPTION', 'UNFREEZE']",
'item.sourceNo === businessNo',
"'RELEASED'",
@@ -44,8 +41,13 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
"deliveryMethod: 'TRUCK'",
]) assert.ok(source.includes(token), token)
assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/)
assert.match(source, /voucherFileId: id\(files\.find\(\(item\) => item\.category === 'VOUCHER'\)\?\.fileId\)/)
assert.doesNotMatch(source, /voucherFileId:\s*Number\(/)
assert.match(source, /verifyProcurementSample\(procurementContext/)
const sample = readFileSync(new URL('./procurement-sample-fulfillment.mjs', import.meta.url), 'utf8')
assert.match(sample, /walletStatus === 'SETTLED'/)
assert.match(sample, /sourceLinked/)
assert.match(source, /Number\(procurement.settledAmountCny\) - 0.01/)
assert.match(source, /procurement.walletBusinessNos/)
assert.doesNotMatch(source, /inboundQuantity|PENDING_OUTBOUND/)
assert.doesNotMatch(source, /customerOrderNo:[^\n]+overseasWarehouseId:/)
assert.doesNotMatch(source, /internal\/finance|jdbc|mysql|process\.env|https?:\/\//i)
})
@@ -1,3 +1,6 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { verifyProcurementSample } from './procurement-sample-fulfillment.mjs'
const MODULE = 'phase4-overseas-wallet-and-pallet-cost'
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -47,9 +50,9 @@ function assert(context, name, response, passed, evidence = '') {
async function merchantLogin(context, fixture) {
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
const auth = success(response) ? response.body.data : null
assert(context, '客户账号同时取得国内和海外短时Token', response,
Boolean(auth?.accessToken) && Boolean(auth?.accessToken),
`customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.accessToken)}`)
assert(context, '客户账号取得统一会话与退出凭据', response,
Boolean(auth?.accessToken) && Boolean(auth?.refreshToken),
`customerId=${fixture.tenantId}, session=${Boolean(auth?.accessToken)}, refresh=${Boolean(auth?.refreshToken)}`)
return auth
}
@@ -96,79 +99,46 @@ async function prepareWallet(context, customerId) {
return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null
}
async function procurementFlow(context, fixture, token) {
const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements'
const payload = {
customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`,
productSpecification: 'full acceptance wallet', productUrl: null, purchaseQuantity: 2,
budgetUnitPrice: 1, currency: 'USD', receivingAddress: 'Los Angeles',
contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker,
idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255),
async function procurementFlow(context, fixture, token, warehouseId) {
if (!/^[A-Za-z0-9_-]{1,80}$/.test(context.marker)) throw new Error('采购验收批次不适合作为独立证据标识')
const directory = new URL('../../reports/procurement-wallet-checkpoints/', import.meta.url)
await mkdir(directory, { recursive: true })
const output = new URL(`${context.marker}.json`, directory)
const procurementContext = Object.create(context)
let initialized = false
procurementContext.checkpoint = async () => {
const proof = context.createdResources.procurementSample || { marker: context.marker, status: 'PREPARING' }
await writeFile(output, JSON.stringify(proof, null, 2) + '\n', { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
}
const created = await context.request('POST', merchantPath, payload, token, {})
const detail = created?.body?.data
const orderNo = detail?.summary?.orderNo
assert(context, '客户创建钱包采购单', created,
success(created) && id(detail?.summary?.customerId) === id(fixture.tenantId) && detail?.summary?.status === 'PENDING_REVIEW',
`orderNo=${orderNo || 'missing'}, status=${detail?.summary?.status || 'missing'}`)
if (!success(created) || !orderNo) return null
assert(context, '采购单号按客户拼音首字母加北京时间日期和四位序号生成', created,
fixture.expectedProcurementPrefix ? matchesProcurementNo(orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(orderNo),
`orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`)
await procurementContext.checkpoint()
const customerId = id(fixture.tenantId)
const approved = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), {
version: Number(detail.summary.version), decision: 'APPROVE',
idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker,
})
assert(context, '平台审核通过钱包采购单', approved,
success(approved) && approved.body.data?.summary?.status === 'APPROVED',
`status=${approved.body?.data?.summary?.status || 'missing'}`)
if (!success(approved)) return null
const files = []
for (const category of ['VOUCHER', 'RECEIPT']) {
const uploaded = await context.requestMultipart('POST',
query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
assert(context, `采购单上传${category}文件`, uploaded,
success(uploaded) && id(uploaded.body.data?.fileId) !== '' && uploaded.body.data?.category === category,
`fileId=${id(uploaded.body?.data?.fileId) || 'missing'}`)
if (success(uploaded)) files.push(uploaded.body.data)
const upload = async (orderNo, category) => {
const proof = context.createdResources.procurementSample
const entry = { category, orderNo, status: 'SENDING' }
proof.uploads ??= []; proof.uploads.push(entry); await procurementContext.checkpoint()
let response
try {
response = await context.requestMultipart('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
} catch (error) { entry.status = 'UNKNOWN_RESULT'; await procurementContext.checkpoint(); throw error }
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http, code: response?.body?.code, fileId: response?.body?.data?.fileId, requestId: response?.requestId, traceId: response?.traceId })
await procurementContext.checkpoint()
assert(context, `采购受控上传${category}`, response, success(response) && Boolean(response.body.data?.fileId))
if (!success(response) || !response.body.data?.fileId) throw new Error('采购凭证上传未确认成功,不自动重试')
return response.body.data
}
try {
const proof = await verifyProcurementSample(procurementContext, { marker: context.marker, customerId,
customerName: fixture.tenantName, customerToken: token, warehouseId, upload,
readWallet: async () => { const result = await platformAccount(context, customerId); if (!success(result.response) || !result.account) throw new Error('本轮客户钱包查询失败'); return result.account } })
assert(context, '采购正常全链路及资金完成', null, proof.status === 'PASSED', `orderNo=${proof.orderNo}`)
assert(context, '采购单号日期和客户前缀正确', null,
fixture.expectedProcurementPrefix ? matchesProcurementNo(proof.orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(proof.orderNo), `orderNo=${proof.orderNo}`)
return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny }
} finally {
for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed)
}
if (files.length !== 2) return null
const fee = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), {
feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01,
voucherFileId: id(files.find((item) => item.category === 'VOUCHER')?.fileId), remark: context.marker,
})
const feeNo = fee?.body?.data?.feeNo
assert(context, '采购费用创建并开始钱包冻结', fee,
success(fee) && Boolean(feeNo) && ['FREEZE_PENDING', 'FROZEN'].includes(fee.body.data?.walletStatus),
`feeNo=${feeNo || 'missing'}, walletStatus=${fee.body?.data?.walletStatus || 'missing'}`)
if (!success(fee) || !feeNo) return null
const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId })
const frozen = await poll(async () => context.request('GET', detailPath),
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN')
const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
assert(context, '采购费用跨区冻结到账', frozen.result,
frozenFee?.walletStatus === 'FROZEN', `feeNo=${feeNo}, pollRequests=${frozen.attempts}, walletStatus=${frozenFee?.walletStatus || 'missing'}`)
if (frozenFee?.walletStatus !== 'FROZEN') return null
const current = frozen.result.body.data.summary
const settledOrder = await context.request('PATCH', detailPath, {
version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity),
})
assert(context, '采购全量入库推进待出库并触发结算', settledOrder,
success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND',
`status=${settledOrder.body?.data?.summary?.status || 'missing'}`)
const settled = await poll(async () => context.request('GET', detailPath),
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED')
const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
assert(context, '采购费用跨区结算到账', settled.result,
settledFee?.walletStatus === 'SETTLED', `feeNo=${feeNo}, pollRequests=${settled.attempts}, walletStatus=${settledFee?.walletStatus || 'missing'}`)
return { orderNo, feeNo, freezePollRequests: frozen.attempts, settlePollRequests: settled.attempts }
}
function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
@@ -276,7 +246,7 @@ async function relabelFlow(context, token, warehouseId, suffix, terminalStatus)
async function verifyClientWallet(context, token, fixture, wallet, procurement, relabels) {
const account = await context.request('GET', `${CLIENT_FINANCE}/account`, undefined, token)
const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - 0.02)
const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - Number(procurement.settledAmountCny) - 0.01)
assert(context, '客户工作台余额与冻结结算结果一致', account,
success(account) && money(account.body.data?.actualBalance) === expectedActual
&& money(account.body.data?.frozenBalance) === money(wallet.before.frozenBalance)
@@ -284,7 +254,7 @@ async function verifyClientWallet(context, token, fixture, wallet, procurement,
`expectedActual=${expectedActual}, actual=${account.body?.data?.actualBalance ?? 'missing'}, frozen=${account.body?.data?.frozenBalance ?? 'missing'}, creditUsed=${account.body?.data?.creditUsed ?? 'missing'}`)
const ledger = await context.request('GET', `${CLIENT_FINANCE}/account/ledger?page=1&size=100`, undefined, token)
const entries = rows(ledger?.body?.data)
const businessNos = [procurement.feeNo, ...relabels.map((item) => item.relabelNo)]
const businessNos = [...procurement.walletBusinessNos, ...relabels.map((item) => item.relabelNo)]
assert(context, '客户账本包含充值、冻结、结算和释放明细', ledger,
success(ledger) && entries.some((item) => item.changeType === 'RECHARGE')
&& businessNos.every((businessNo) => entries.some((item) => item.sourceNo === businessNo))
@@ -357,8 +327,10 @@ export async function run(context) {
if (!fixture?.tenantId || !warehouseId || completedFacts.length === 0) return
const auth = await merchantLogin(context, fixture)
if (!auth?.accessToken || !auth?.refreshToken) return
try {
const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken) : null
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken, warehouseId) : null
const settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
const relabels = [settledRelabel, releasedRelabel].filter(Boolean)
@@ -374,4 +346,9 @@ export async function run(context) {
context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed),
deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
assert(context, '退出本轮钱包客户会话', logout, success(logout))
}
}

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