feat(procurement): implement sample fulfillment and settled fee workflow
This commit is contained in:
+2
-1
@@ -56,6 +56,7 @@ public record BusinessFeeSourceChangedV1(
|
|||||||
String reviewStatus,
|
String reviewStatus,
|
||||||
String publicationStatus,
|
String publicationStatus,
|
||||||
String sourceStatus,
|
String sourceStatus,
|
||||||
String sourceHash
|
String sourceHash,
|
||||||
|
WalletSettlementReference walletSettlement
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -15,7 +15,9 @@ public record WalletCommandCompletedV1(
|
|||||||
BigDecimal amount,
|
BigDecimal amount,
|
||||||
String errorCode,
|
String errorCode,
|
||||||
String errorMessage,
|
String errorMessage,
|
||||||
Instant completedAt
|
Instant completedAt,
|
||||||
|
String walletHoldId,
|
||||||
|
String walletLedgerId
|
||||||
) {
|
) {
|
||||||
public static final int VERSION = 1;
|
public static final int VERSION = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -3,7 +3,13 @@ package com.baiyue.common.event;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */
|
/**
|
||||||
|
* 海外业务向 Finance 发起的客户资金指令。
|
||||||
|
* FREEZE 的 amount 为预留金额;SETTLE 全额结算;RELEASE 释放原冻结。
|
||||||
|
* SETTLE_ACTUAL 的 amount 为最终正额 CNY 实扣,原子补差或释放余款,原冻结金额保留。
|
||||||
|
* 实际零元使用 RELEASE,不发送零元结算。结算和释放按原业务冻结身份关联。
|
||||||
|
* REFUND 使用独立业务退款号并携带 originalLedgerId,关联原成功消费,不创建充值。
|
||||||
|
*/
|
||||||
public record WalletCommandRequestedV1(
|
public record WalletCommandRequestedV1(
|
||||||
String operation,
|
String operation,
|
||||||
long customerId,
|
long customerId,
|
||||||
@@ -12,7 +18,8 @@ public record WalletCommandRequestedV1(
|
|||||||
BigDecimal amount,
|
BigDecimal amount,
|
||||||
String remark,
|
String remark,
|
||||||
String idempotencyKey,
|
String idempotencyKey,
|
||||||
Instant requestedAt
|
Instant requestedAt,
|
||||||
|
String originalLedgerId
|
||||||
) {
|
) {
|
||||||
public static final int VERSION = 1;
|
public static final int VERSION = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -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
@@ -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 种子核对;本文件存在不表示已执行。
|
`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` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。
|
`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 | 表数 |
|
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||||
|---|---|---|---:|
|
|---|---|---|---:|
|
||||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
|
| 国内 | `头程数据库统一增量-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,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||||
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
|
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
|
||||||
@@ -37,7 +51,7 @@
|
|||||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||||
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
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='异常关闭逐来源剩余货物最终去向,不产生新发运';
|
||||||
+4
@@ -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
|
-- 服务 schema:baiyue_order
|
||||||
CREATE DATABASE IF NOT EXISTS baiyue_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
CREATE DATABASE IF NOT EXISTS baiyue_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
USE baiyue_order;
|
USE baiyue_order;
|
||||||
|
|
||||||
CREATE TABLE ord_customer_address (
|
CREATE TABLE ord_customer_address (
|
||||||
@@ -8284,4 +8286,33 @@ CREATE TABLE event_delivery_attempt (
|
|||||||
KEY idx_delivery_attempt_outcome (outcome,started_at)
|
KEY idx_delivery_attempt_outcome (outcome,started_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
|
) 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;
|
SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables;
|
||||||
|
|||||||
+267
-11
@@ -805,6 +805,7 @@ CREATE TABLE ll_finance_fee_source (
|
|||||||
paid_at datetime(3) DEFAULT NULL COMMENT '直接已付时间',
|
paid_at datetime(3) DEFAULT NULL COMMENT '直接已付时间',
|
||||||
payment_method varchar(32) COLLATE utf8mb4_unicode_ci 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 '直接已付流水号',
|
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快照',
|
payment_voucher_file_ids json DEFAULT NULL COMMENT '直接已付凭证文件ID快照',
|
||||||
business_date date NOT NULL COMMENT '费用业务日期',
|
business_date date NOT NULL COMMENT '费用业务日期',
|
||||||
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY',
|
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拣货任务';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务';
|
||||||
|
|
||||||
CREATE TABLE ll_overseas_procurement_fee (
|
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',
|
id bigint NOT NULL COMMENT '主键ID',
|
||||||
order_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',
|
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 '费用项名称',
|
fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称',
|
||||||
amount decimal(20,2) NOT NULL COMMENT 'CNY金额',
|
amount decimal(20,2) NOT NULL COMMENT 'CNY金额',
|
||||||
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种',
|
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种',
|
||||||
voucher_file_id bigint NOT NULL COMMENT '凭证文件ID',
|
voucher_file_id bigint NULL COMMENT '凭证文件ID',
|
||||||
voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '凭证文件名',
|
voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NULL COMMENT '凭证文件名',
|
||||||
remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明',
|
remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明',
|
||||||
wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT 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 '资金错误码',
|
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_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人',
|
||||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
|
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),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
|
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
|
||||||
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
|
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'))
|
CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期';
|
) 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',
|
id bigint NOT NULL COMMENT '主键ID',
|
||||||
order_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',
|
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_id bigint NOT NULL COMMENT 'File服务文件ID',
|
||||||
file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名快照',
|
file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名快照',
|
||||||
content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT 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),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_procurement_file (order_id,file_id),
|
UNIQUE KEY uk_ll_procurement_file (order_id,file_id),
|
||||||
KEY idx_ll_procurement_file_order (customer_id,order_id,category,created_at),
|
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='海外采购签收文件及费用凭证';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证';
|
||||||
|
|
||||||
CREATE TABLE ll_overseas_procurement_order (
|
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 '联系人电话',
|
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_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库ID',
|
||||||
purchase_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库名称',
|
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 '采购单据累计入库数量',
|
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 '预计到达时间',
|
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间',
|
||||||
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
|
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
|
||||||
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL 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)
|
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
|
||||||
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
|
) 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),
|
INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_PROCUREMENT_RECEIPT(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_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),
|
CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
LOADING_CUSTOMS_DECLARATION(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),
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-2
@@ -130,6 +130,14 @@ public class BusinessFeeSourceEventService {
|
|||||||
}
|
}
|
||||||
positive(value.sourceFeeId(), "sourceFeeId");
|
positive(value.sourceFeeId(), "sourceFeeId");
|
||||||
positive(value.customerId(), "customerId");
|
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 ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
|
||||||
if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType()))
|
if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType()))
|
||||||
throw error("FIN-EVT-012", "海外柜费来源不一致");
|
throw error("FIN-EVT-012", "海外柜费来源不一致");
|
||||||
@@ -160,7 +168,7 @@ public class BusinessFeeSourceEventService {
|
|||||||
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
|
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
|
||||||
value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
|
value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
|
||||||
value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(),
|
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)));
|
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
|
||||||
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
|
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
|
||||||
}
|
}
|
||||||
@@ -189,7 +197,40 @@ public class BusinessFeeSourceEventService {
|
|||||||
|
|
||||||
private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) {
|
private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) {
|
||||||
if (!"RECEIVABLE".equals(source.direction())) return;
|
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);
|
feeSourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -300,6 +300,113 @@ public class FinanceWalletService {
|
|||||||
return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false);
|
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,
|
private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo,
|
||||||
String idempotencyKey, WalletHoldActionRequest request, boolean settle) {
|
String idempotencyKey, WalletHoldActionRequest request, boolean settle) {
|
||||||
requirePositive(customerId, "客户");
|
requirePositive(customerId, "客户");
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ import java.util.Set;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class WalletCommandEventService {
|
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(
|
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 JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceWalletService walletService;
|
private final FinanceWalletService walletService;
|
||||||
@@ -56,10 +57,9 @@ public class WalletCommandEventService {
|
|||||||
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||||
// Wallet methods join this transaction: money, the Inbox outcome and
|
// Wallet methods join this transaction: money, the Inbox outcome and
|
||||||
// the result Outbox commit together, or all roll back.
|
// the result Outbox commit together, or all roll back.
|
||||||
WalletHold hold = execute(command);
|
ExecutionResult execution = execute(command);
|
||||||
if (hold == null) throw new IllegalStateException("资金操作未返回处理结果");
|
|
||||||
finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED",
|
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);
|
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
||||||
});
|
});
|
||||||
} catch (FinanceEventException exception) {
|
} catch (FinanceEventException exception) {
|
||||||
@@ -70,7 +70,7 @@ public class WalletCommandEventService {
|
|||||||
requireLockedIdentity(envelope);
|
requireLockedIdentity(envelope);
|
||||||
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||||
finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED",
|
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);
|
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()) {
|
WalletHold hold = switch (command.operation()) {
|
||||||
case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(),
|
case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(),
|
||||||
new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark()));
|
new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark()));
|
||||||
case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(),
|
case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(),
|
||||||
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
|
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(),
|
case "RELEASE" -> walletService.release(command.customerId(), command.businessType(), command.businessNo(),
|
||||||
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
|
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
|
||||||
default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效");
|
default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效");
|
||||||
};
|
};
|
||||||
if ("SETTLE".equals(command.operation())) {
|
if (hold == null) throw new IllegalStateException("资金操作未返回处理结果");
|
||||||
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 (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());
|
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) {
|
private WalletCommandRequestedV1 validate(EventEnvelope envelope) {
|
||||||
@@ -118,14 +130,22 @@ public class WalletCommandEventService {
|
|||||||
String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT);
|
String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT);
|
||||||
if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType())
|
if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType())
|
||||||
|| blank(value.businessNo()) || blank(value.idempotencyKey())
|
|| 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", "资金指令字段无效");
|
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())) {
|
if (!String.valueOf(value.customerId()).equals(envelope.tenantId())) {
|
||||||
throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致");
|
throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致");
|
||||||
}
|
}
|
||||||
return new WalletCommandRequestedV1(operation, value.customerId(), value.businessType(), value.businessNo(),
|
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 (FinanceEventException exception) { throw exception; }
|
||||||
catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); }
|
catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); }
|
||||||
}
|
}
|
||||||
@@ -159,9 +179,10 @@ public class WalletCommandEventService {
|
|||||||
return count != null && count > 0;
|
return count != null && count > 0;
|
||||||
}
|
}
|
||||||
private WalletCommandCompletedV1 completed(String eventId, WalletCommandRequestedV1 command, String result,
|
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(),
|
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) {
|
private String json(Object value) {
|
||||||
try { return objectMapper.writeValueAsString(value); }
|
try { return objectMapper.writeValueAsString(value); }
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class BusinessFeeSourceEventServiceTest {
|
|||||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"),
|
when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"),
|
||||||
eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of());
|
eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"),
|
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=?"),
|
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),
|
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());
|
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
|
||||||
|
|
||||||
@@ -65,21 +65,21 @@ class BusinessFeeSourceEventServiceTest {
|
|||||||
|
|
||||||
private EventEnvelope eventWithoutInbound() {
|
private EventEnvelope eventWithoutInbound() {
|
||||||
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG",
|
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG",
|
||||||
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
|
"OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001",
|
||||||
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
|
"WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null,
|
||||||
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
|
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
|
||||||
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
|
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
|
||||||
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
|
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(
|
String sourceHash = EventPayloadHash.sha256(
|
||||||
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
|
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
|
||||||
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
|
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
|
||||||
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
|
"OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001",
|
||||||
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
|
"WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null,
|
||||||
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
|
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
|
||||||
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
|
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
|
||||||
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
|
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);
|
JsonNode node = json.valueToTree(payload);
|
||||||
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||||
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
|
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("INSERT INTO fin_cost"), any(Object[].class));
|
||||||
verify(jdbc, never()).update(startsWith("UPDATE 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));
|
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
|
@Test
|
||||||
void losingInboxInsertMustRecheckThePersistedHash() {
|
void losingInboxInsertMustRecheckThePersistedHash() {
|
||||||
EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00"));
|
EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00"));
|
||||||
@@ -133,9 +165,44 @@ class WalletCommandEventServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) {
|
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,
|
WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId,
|
||||||
"OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费",
|
"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);
|
JsonNode payload = mapper.valueToTree(command);
|
||||||
return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED,
|
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,
|
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) {
|
private EventEnvelope event(String id, String amount) {
|
||||||
var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1",
|
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);
|
var payload = mapper.valueToTree(command);
|
||||||
return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION,
|
return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION,
|
||||||
Instant.now(), "31", "WALLET_HOLD", "PO-1", 1,
|
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); }
|
||||||
|
}
|
||||||
+200
@@ -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;
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
||||||
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
||||||
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
|
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
|
||||||
- 海外采购客户下单固定进入 `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`。
|
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
|
||||||
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
|
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -12,6 +12,15 @@ import java.util.List;
|
|||||||
@FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient",
|
@FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient",
|
||||||
url = "${baiyue.clients.domestic-warehouse.url:}")
|
url = "${baiyue.clients.domestic-warehouse.url:}")
|
||||||
public interface DomesticWarehouseDirectoryClient {
|
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")
|
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
|
||||||
ApiResponse<Detail> activeWarehouse(
|
ApiResponse<Detail> activeWarehouse(
|
||||||
@PathVariable("warehouseId") long warehouseId,
|
@PathVariable("warehouseId") long warehouseId,
|
||||||
@@ -20,7 +29,7 @@ public interface DomesticWarehouseDirectoryClient {
|
|||||||
record Detail(Warehouse warehouse, List<Address> addresses) { }
|
record Detail(Warehouse warehouse, List<Address> addresses) { }
|
||||||
|
|
||||||
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
|
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,
|
record Address(String addressType, String countryCode, String stateProvince, String city,
|
||||||
String district, String postalCode, String addressLine1, String addressLine2) { }
|
String district, String postalCode, String addressLine1, String addressLine2) { }
|
||||||
|
|||||||
+2
@@ -43,6 +43,8 @@ public class OverseasFeeSource {
|
|||||||
private String paymentMethod;
|
private String paymentMethod;
|
||||||
private String paymentReferenceNo;
|
private String paymentReferenceNo;
|
||||||
@TableField(typeHandler = JacksonTypeHandler.class) private List<String> paymentVoucherFileIds;
|
@TableField(typeHandler = JacksonTypeHandler.class) private List<String> paymentVoucherFileIds;
|
||||||
|
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||||
|
private com.baiyue.common.event.WalletSettlementReference walletSettlement;
|
||||||
private LocalDate businessDate;
|
private LocalDate businessDate;
|
||||||
private String currency;
|
private String currency;
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
|
|||||||
+1
@@ -9,5 +9,6 @@ import org.apache.ibatis.annotations.Select;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface OverseasFeeSourceMapper extends BaseMapper<OverseasFeeSource> {
|
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")
|
@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);
|
OverseasFeeSource lockCurrent(@Param("sourceFeeId") long sourceFeeId);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ public class OverseasFeeOutboxService {
|
|||||||
value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
|
value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
|
||||||
value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
|
value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
|
||||||
value.getReviewStatus(),
|
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); }
|
private static String text(Long value) { return value == null ? null : String.valueOf(value); }
|
||||||
|
|||||||
+51
-4
@@ -79,14 +79,20 @@ public class OverseasFeeSourceService {
|
|||||||
if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送");
|
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
|
@Transactional
|
||||||
public OverseasFeeSource create(String operatorId, String key, SaveRequest request) {
|
public OverseasFeeSource create(String operatorId, String key, SaveRequest request) {
|
||||||
|
requireNonProcurement(request.sourceType(),request.businessType());
|
||||||
OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request);
|
OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request);
|
||||||
if (replay != null) return current(replay.getSourceFeeId());
|
if (replay != null) return current(replay.getSourceFeeId());
|
||||||
validate(request);
|
validate(request);
|
||||||
businessGuard.requireOwned(request);
|
businessGuard.requireOwned(request);
|
||||||
long sourceFeeId = IdWorker.getId();
|
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);
|
mapper.insert(value);
|
||||||
command(operatorId, "CREATE", key, request, sourceFeeId, 1);
|
command(operatorId, "CREATE", key, request, sourceFeeId, 1);
|
||||||
return value;
|
return value;
|
||||||
@@ -108,11 +114,45 @@ public class OverseasFeeSourceService {
|
|||||||
return value;
|
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
|
@Transactional
|
||||||
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
|
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
|
||||||
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
|
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
|
||||||
if (replay != null) return current(replay.getSourceFeeId());
|
if (replay != null) return current(replay.getSourceFeeId());
|
||||||
OverseasFeeSource value = lock(sourceFeeId);
|
OverseasFeeSource value = lock(sourceFeeId);
|
||||||
|
requireNonProcurement(value.getSourceType(),value.getBusinessType());
|
||||||
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus()))
|
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus()))
|
||||||
throw conflict("只有草稿或已驳回费用可以提交");
|
throw conflict("只有草稿或已驳回费用可以提交");
|
||||||
value.setReviewStatus("PENDING"); value.setReviewReason(null);
|
value.setReviewStatus("PENDING"); value.setReviewReason(null);
|
||||||
@@ -126,6 +166,7 @@ public class OverseasFeeSourceService {
|
|||||||
OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request);
|
OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request);
|
||||||
if (replay != null) return current(replay.getSourceFeeId());
|
if (replay != null) return current(replay.getSourceFeeId());
|
||||||
OverseasFeeSource value = lock(sourceFeeId);
|
OverseasFeeSource value = lock(sourceFeeId);
|
||||||
|
requireNonProcurement(value.getSourceType(),value.getBusinessType());
|
||||||
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
|
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
|
||||||
throw conflict("只有待审核的有效费用可以审核");
|
throw conflict("只有待审核的有效费用可以审核");
|
||||||
boolean approved = "APPROVE".equals(request.decision());
|
boolean approved = "APPROVE".equals(request.decision());
|
||||||
@@ -143,6 +184,8 @@ public class OverseasFeeSourceService {
|
|||||||
OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request);
|
OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request);
|
||||||
if (replay != null) return current(replay.getSourceFeeId());
|
if (replay != null) return current(replay.getSourceFeeId());
|
||||||
OverseasFeeSource old = lock(sourceFeeId);
|
OverseasFeeSource old = lock(sourceFeeId);
|
||||||
|
if (old.getWalletSettlement() != null) throw conflict("已扣费用只能通过关联的财务差额或退款调整");
|
||||||
|
requireNonProcurement(old.getSourceType(),old.getBusinessType());
|
||||||
if (cancellation.requested(old)) throw conflict("该费用已申请作废,请先处理财务结果");
|
if (cancellation.requested(old)) throw conflict("该费用已申请作废,请先处理财务结果");
|
||||||
if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus())
|
if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus())
|
||||||
|| old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整");
|
|| old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整");
|
||||||
@@ -157,7 +200,7 @@ public class OverseasFeeSourceService {
|
|||||||
|| !Objects.equals(old.getContainerNo(), text(request.fee().containerNo())))
|
|| !Objects.equals(old.getContainerNo(), text(request.fee().containerNo())))
|
||||||
throw conflict("调整不得改变费用来源或业务归属");
|
throw conflict("调整不得改变费用来源或业务归属");
|
||||||
old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old);
|
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");
|
next.setReviewStatus("PENDING");
|
||||||
mapper.insert(next);
|
mapper.insert(next);
|
||||||
command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion());
|
command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion());
|
||||||
@@ -169,6 +212,8 @@ public class OverseasFeeSourceService {
|
|||||||
OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request);
|
OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request);
|
||||||
if (replay != null) return current(replay.getSourceFeeId());
|
if (replay != null) return current(replay.getSourceFeeId());
|
||||||
OverseasFeeSource value = lock(sourceFeeId);
|
OverseasFeeSource value = lock(sourceFeeId);
|
||||||
|
requireNonProcurement(value.getSourceType(),value.getBusinessType());
|
||||||
|
if (value.getWalletSettlement() != null) throw conflict("已扣费用不能作废,请使用关联退款");
|
||||||
if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())
|
if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())
|
||||||
|| value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废");
|
|| value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废");
|
||||||
if (cancellation.requested(value)) return cancellation.describe(List.of(value)).get(0);
|
if (cancellation.requested(value)) return cancellation.describe(List.of(value)).get(0);
|
||||||
@@ -183,7 +228,8 @@ public class OverseasFeeSourceService {
|
|||||||
return value;
|
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();
|
OverseasFeeSource value = new OverseasFeeSource();
|
||||||
value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version);
|
value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version);
|
||||||
value.setCustomerId(request.customerId().trim()); value.setCustomerName(text(request.customerName()));
|
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.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
|
||||||
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
|
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
|
||||||
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
|
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.setCurrency("CNY"); value.setAmount(conversion.settlementAmount());
|
||||||
value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
|
value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
|
||||||
value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());
|
value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());
|
||||||
|
|||||||
+6
-4
@@ -24,13 +24,15 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
|
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
|
||||||
public class MerchantOverseasProcurementController {
|
public class MerchantOverseasProcurementController {
|
||||||
@Resource private OverseasProcurementService service;
|
@Resource private OverseasProcurementService service;
|
||||||
|
@Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')")
|
||||||
public ApiResponse<OverseasProcurementResponses.Detail> create(
|
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> create(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody OverseasProcurementRequests.Create request) {
|
@Valid @RequestBody OverseasProcurementRequests.Create request) {
|
||||||
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
|
@GetMapping
|
||||||
@@ -42,9 +44,9 @@ public class MerchantOverseasProcurementController {
|
|||||||
|
|
||||||
@GetMapping("/{orderNo}")
|
@GetMapping("/{orderNo}")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')")
|
@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) {
|
@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) {
|
private String customer(AuthenticatedUser user) {
|
||||||
|
|||||||
+3
-21
@@ -32,6 +32,7 @@ import java.util.List;
|
|||||||
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
|
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
|
||||||
public class PlatformOverseasProcurementController {
|
public class PlatformOverseasProcurementController {
|
||||||
@Resource private OverseasProcurementService service;
|
@Resource private OverseasProcurementService service;
|
||||||
|
@Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
||||||
@@ -67,11 +68,11 @@ public class PlatformOverseasProcurementController {
|
|||||||
@GetMapping("/{orderNo}")
|
@GetMapping("/{orderNo}")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
||||||
@Operation(summary = "Get an overseas procurement order", description = "Required authority: " + 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,
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||||
@PathVariable String orderNo) {
|
@PathVariable String orderNo) {
|
||||||
requirePlatform(user);
|
requirePlatform(user);
|
||||||
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
|
return ApiResponse.success(queryService.platform(requiredCustomer(customerId), orderNo,user.permissions()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{orderNo}/review")
|
@PostMapping("/{orderNo}/review")
|
||||||
@@ -94,25 +95,6 @@ public class PlatformOverseasProcurementController {
|
|||||||
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
|
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) {
|
private void requirePlatform(AuthenticatedUser user) {
|
||||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||||
|
|||||||
+77
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -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,"运输段与操作入口不一致");}
|
||||||
|
}
|
||||||
+10
-33
@@ -22,11 +22,11 @@ public final class OverseasProcurementRequests {
|
|||||||
@Size(max = 500) String productSpecification,
|
@Size(max = 500) String productSpecification,
|
||||||
@Size(max = 2000) String productUrl,
|
@Size(max = 2000) String productUrl,
|
||||||
@NotNull @Positive Long purchaseQuantity,
|
@NotNull @Positive Long purchaseQuantity,
|
||||||
@DecimalMin(value = "0.00") BigDecimal budgetUnitPrice,
|
@NotNull @DecimalMin(value = "0.00") @jakarta.validation.constraints.Digits(integer=16,fraction=2) BigDecimal budgetUnitPrice,
|
||||||
@Size(min = 3, max = 3) String currency,
|
@NotBlank @jakarta.validation.constraints.Pattern(regexp="CNY|USD") String currency,
|
||||||
@Size(max = 1000) String receivingAddress,
|
@NotBlank @Size(max = 1000) String receivingAddress,
|
||||||
@NotBlank @Size(max = 128) String contactName,
|
@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,
|
@Size(max = 1000) String remark,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey) {}
|
@NotBlank @Size(max = 255) String idempotencyKey) {}
|
||||||
|
|
||||||
@@ -51,38 +51,15 @@ public final class OverseasProcurementRequests {
|
|||||||
@NotNull @Min(0) Long version,
|
@NotNull @Min(0) Long version,
|
||||||
@NotBlank String decision,
|
@NotBlank String decision,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
@Size(max = 1000) String remark) {}
|
@Size(max = 1000) String remark,
|
||||||
|
@Positive Long purchaseWarehouseId) {}
|
||||||
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) {}
|
|
||||||
|
|
||||||
public record Update(
|
public record Update(
|
||||||
@NotNull @Min(0) Long version,
|
@NotNull @Min(0) Long version,
|
||||||
@Size(max = 128) String purchaseCategory,
|
JsonNode remark) {
|
||||||
@Size(max = 255) String productName,
|
@com.fasterxml.jackson.annotation.JsonAnySetter
|
||||||
JsonNode productSpecification,
|
public void rejectUnknownField(String name, JsonNode value) {
|
||||||
JsonNode productUrl,
|
throw new IllegalArgumentException("采购通用编辑仅支持备注,业务字段请使用对应履约操作");
|
||||||
@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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-6
@@ -7,14 +7,11 @@ import java.util.List;
|
|||||||
public final class OverseasProcurementResponses {
|
public final class OverseasProcurementResponses {
|
||||||
private OverseasProcurementResponses() {}
|
private OverseasProcurementResponses() {}
|
||||||
|
|
||||||
public record OutboundDetail(String id, long quantity, String carrier, String trackingNo, LocalDateTime shippedAt, String remark) {}
|
|
||||||
|
|
||||||
public record Summary(
|
public record Summary(
|
||||||
Long id, String orderNo, String customerId, String customerName,
|
Long id, String orderNo, String customerId, String customerName,
|
||||||
String purchaseCategory, String productName, String productSpecification,
|
String purchaseCategory, String productName, String productSpecification,
|
||||||
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
|
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
|
||||||
String purchaseWarehouseId, String purchaseWarehouseName,
|
String purchaseWarehouseId, String purchaseWarehouseName,
|
||||||
long inboundQuantity, long outboundQuantity, long inventoryQuantity,
|
|
||||||
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||||
|
|
||||||
public record CustomerOption(String customerId, String customerName) {}
|
public record CustomerOption(String customerId, String customerName) {}
|
||||||
@@ -38,10 +35,9 @@ public final class OverseasProcurementResponses {
|
|||||||
public record Detail(
|
public record Detail(
|
||||||
Summary summary, String productUrl, String receivingAddress,
|
Summary summary, String productUrl, String receivingAddress,
|
||||||
String contactName, String contactPhone,
|
String contactName, String contactPhone,
|
||||||
String domesticCarrier, String domesticTrackingNo,
|
|
||||||
String overseasCarrier, String overseasTrackingNo,
|
|
||||||
LocalDateTime estimatedArrivalAt, String remark,
|
LocalDateTime estimatedArrivalAt, String remark,
|
||||||
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
|
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
|
||||||
List<History> histories, List<OutboundDetail> outboundDetails,
|
List<History> histories,
|
||||||
List<FileInfo> files, List<Fee> fees) {}
|
List<FileInfo> files, List<Fee> fees) {}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -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) { }
|
||||||
|
}
|
||||||
+25
@@ -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) { }
|
||||||
|
}
|
||||||
+23
@@ -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) { }
|
||||||
|
}
|
||||||
+37
@@ -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) { }
|
||||||
|
}
|
||||||
+38
@@ -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) { }
|
||||||
|
}
|
||||||
+21
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@TableName("ll_overseas_procurement_fee")
|
@TableName("ll_overseas_procurement_fee")
|
||||||
@@ -14,6 +15,24 @@ public class OverseasProcurementFee {
|
|||||||
private Long orderId;
|
private Long orderId;
|
||||||
private String customerId;
|
private String customerId;
|
||||||
private String feeNo;
|
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 feeItemCode;
|
||||||
private String feeItemName;
|
private String feeItemName;
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
@@ -22,7 +41,9 @@ public class OverseasProcurementFee {
|
|||||||
private String voucherFileName;
|
private String voucherFileName;
|
||||||
private String remark;
|
private String remark;
|
||||||
private String walletStatus;
|
private String walletStatus;
|
||||||
|
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||||
private String walletErrorCode;
|
private String walletErrorCode;
|
||||||
|
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||||
private String walletErrorMessage;
|
private String walletErrorMessage;
|
||||||
private String walletCommandEventId;
|
private String walletCommandEventId;
|
||||||
private String createdBy;
|
private String createdBy;
|
||||||
|
|||||||
-5
@@ -30,11 +30,6 @@ public class OverseasProcurementOrder {
|
|||||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId;
|
||||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName;
|
||||||
private Long inboundQuantity;
|
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;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
|
||||||
private String status;
|
private String status;
|
||||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
|
||||||
|
|||||||
+12
-1
@@ -37,7 +37,7 @@ public class HttpProcurementFileGateway implements ProcurementFileGateway {
|
|||||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
@Override public String getFilename() { return file.getOriginalFilename(); }
|
||||||
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(file.getContentType()));
|
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(file.getContentType()));
|
||||||
} catch (Exception exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购文件无法读取", exception); }
|
} 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}",
|
Envelope response = client.post().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
|
||||||
Long.parseLong(customerId), businessType, orderId)
|
Long.parseLong(customerId), businessType, orderId)
|
||||||
.header("X-Internal-Service-Token", token).header("X-Operator-Id", String.valueOf(operatorId))
|
.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());
|
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
|
||||||
}
|
}
|
||||||
private record Envelope(String code, String message, Metadata data) { }
|
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) { }
|
private record Metadata(Long fileId, String fileName, String contentType, Long sizeBytes) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -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
@@ -2,5 +2,7 @@ package com.baiyue.lastleg.procurement.gateway;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
public interface ProcurementFileGateway {
|
public interface ProcurementFileGateway {
|
||||||
UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file);
|
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) { }
|
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -2,4 +2,9 @@ package com.baiyue.lastleg.procurement.mapper;
|
|||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
|
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+31
-180
@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.baiyue.common.core.api.PageResult;
|
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.OverseasProcurementRequests;
|
||||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
||||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
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.OverseasProcurementFileMapper;
|
||||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
|
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
|
||||||
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
|
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.OverseasProcurementOrderMapper;
|
||||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
|
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
|
||||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||||
@@ -39,7 +33,6 @@ import java.math.BigDecimal;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.time.format.DateTimeParseException;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -49,24 +42,24 @@ import java.util.Set;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class OverseasProcurementService implements WalletResultHandler {
|
public class OverseasProcurementService {
|
||||||
@Resource private ProcurementOrderNumberGenerator orderNoGenerator;
|
@Resource private ProcurementOrderNumberGenerator orderNoGenerator;
|
||||||
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
|
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
|
||||||
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
|
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
|
||||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
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";
|
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 OverseasProcurementOrderMapper orders;
|
||||||
@Resource private OverseasProcurementStatusHistoryMapper histories;
|
@Resource private OverseasProcurementStatusHistoryMapper histories;
|
||||||
@Resource private PartnerWarehouseDirectory warehouseDirectory;
|
@Resource private ProcurementWarehouseDirectory warehouseDirectory;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
@Resource private OverseasProcurementFileMapper procurementFiles;
|
@Resource private OverseasProcurementFileMapper procurementFiles;
|
||||||
@Resource private OverseasProcurementFeeMapper procurementFees;
|
@Resource private OverseasProcurementFeeMapper procurementFees;
|
||||||
@Resource private ProcurementFileGateway fileGateway;
|
@Resource private ProcurementFileGateway fileGateway;
|
||||||
@Resource private WalletCommandOutboxService walletCommands;
|
|
||||||
@Resource private OverseasFeeSourceService feeSources;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
|
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");
|
if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200");
|
||||||
return orders.customerOptions(trim(keyword), limit).stream()
|
return orders.customerOptions(trim(keyword), limit).stream()
|
||||||
.map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(),
|
.map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(),
|
||||||
Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId())))
|
Objects.requireNonNullElse(trim(value.getCustomerName()), "—")))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() {
|
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() {
|
||||||
return warehouseDirectory.options().stream()
|
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()))
|
value.warehouseCode(), value.warehouseName(), value.countryCode()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -159,21 +152,20 @@ public class OverseasProcurementService implements WalletResultHandler {
|
|||||||
if (order == null) throw notFound("Overseas procurement order not found");
|
if (order == null) throw notFound("Overseas procurement order not found");
|
||||||
return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(),
|
return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(),
|
||||||
order.getReceivingAddress(), order.getContactName(), order.getContactPhone(),
|
order.getReceivingAddress(), order.getContactName(), order.getContactPhone(),
|
||||||
order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(),
|
order.getEstimatedArrivalAt(), order.getRemark(),
|
||||||
order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(),
|
|
||||||
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
|
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
|
||||||
histories.listOrder(customerId, order.getId()).stream().map(value ->
|
histories.listOrder(customerId, order.getId()).stream().map(value ->
|
||||||
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
|
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||||
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
|
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
|
||||||
value.getChangeDetail(), value.getRemark(),
|
value.getChangeDetail(), value.getRemark(),
|
||||||
value.getCreatedAt())).toList(), outboundDetails(order), files(order), fees(order));
|
value.getCreatedAt())).toList(), files(order), fees(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public OverseasProcurementResponses.FileInfo uploadFile(String customerId, String orderNo, String operatorId,
|
public OverseasProcurementResponses.FileInfo uploadFile(String customerId, String orderNo, String operatorId,
|
||||||
String category, MultipartFile file) {
|
String category, MultipartFile file) {
|
||||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
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);
|
ProcurementFileGateway.UploadedFile uploaded = fileGateway.upload(customerId, order.getId(), Long.parseLong(operatorId), category, file);
|
||||||
OverseasProcurementFile row = new OverseasProcurementFile();
|
OverseasProcurementFile row = new OverseasProcurementFile();
|
||||||
row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); row.setCategory(category);
|
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);
|
return file(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
public record DownloadedFile(String fileName,String contentType,byte[] content) { }
|
||||||
public OverseasProcurementResponses.Fee createFee(String customerId, String orderNo, String operatorId,
|
public DownloadedFile downloadFile(String customerId,String orderNo,long fileId) {
|
||||||
OverseasProcurementRequests.CreateFee request) {
|
var order=orders.findNo(customerId,orderNo);
|
||||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
if(order==null)throw notFound("采购订单不存在或不属于当前客户");
|
||||||
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can add fees");
|
var file=procurementFiles.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>()
|
||||||
OverseasProcurementFile voucher = procurementFiles.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>()
|
.eq(OverseasProcurementFile::getCustomerId,customerId).eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getFileId,fileId));
|
||||||
.eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, customerId)
|
if(file==null)throw notFound("文件未关联本采购订单");
|
||||||
.eq(OverseasProcurementFile::getCategory, "VOUCHER").eq(OverseasProcurementFile::getFileId, request.voucherFileId()));
|
var content=fileGateway.download(customerId,order.getId(),file.getCategory(),fileId);
|
||||||
if (voucher == null) throw badRequest("Fee voucher does not belong to this procurement order");
|
return new DownloadedFile(file.getFileName(),content.contentType(),content.content());
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<OverseasProcurementResponses.FileInfo> files(OverseasProcurementOrder order) {
|
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,
|
public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId,
|
||||||
OverseasProcurementRequests.Review request) {
|
OverseasProcurementRequests.Review request) {
|
||||||
String next = switch (request.decision().trim().toUpperCase()) {
|
String next = switch (request.decision().trim().toUpperCase()) {
|
||||||
case "APPROVE", APPROVED -> APPROVED;
|
case "APPROVE" -> WAIT_PURCHASE;
|
||||||
case "REJECT", REJECTED -> REJECTED;
|
case "REJECT" -> REJECTED;
|
||||||
default -> throw badRequest("Review decision must be APPROVE or REJECT");
|
default -> throw badRequest("Review decision must be APPROVE or REJECT");
|
||||||
};
|
};
|
||||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
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 (!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 (!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();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
order.setStatus(next);
|
order.setStatus(next);
|
||||||
order.setReviewedBy(operatorId);
|
order.setReviewedBy(operatorId);
|
||||||
@@ -292,23 +251,7 @@ public class OverseasProcurementService implements WalletResultHandler {
|
|||||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
OverseasProcurementOrder order = lock(customerId, orderNo);
|
||||||
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
|
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
|
||||||
Map<String, Object> before = editableSnapshot(order);
|
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);
|
patchText(request.remark(), 1000, "remark", order::setRemark);
|
||||||
if (request.outboundDetails() != null) order.setOutboundDetails(normalizeOutbound(request.outboundDetails(), order.getInboundQuantity()));
|
|
||||||
Map<String, Object> after = editableSnapshot(order);
|
Map<String, Object> after = editableSnapshot(order);
|
||||||
if (before.equals(after)) return detail(customerId, orderNo);
|
if (before.equals(after)) return detail(customerId, orderNo);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -316,28 +259,9 @@ public class OverseasProcurementService implements WalletResultHandler {
|
|||||||
orders.updateById(order);
|
orders.updateById(order);
|
||||||
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
||||||
null, changeDetail(before, after), null, now);
|
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);
|
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) {
|
private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) {
|
||||||
if (!hash.equals(order.getRequestHash())) {
|
if (!hash.equals(order.getRequestHash())) {
|
||||||
throw conflict("Idempotency key was reused with different procurement data");
|
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) {
|
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();
|
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(),
|
return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(),
|
||||||
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
|
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
|
||||||
order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(),
|
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());
|
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,
|
private static void patchText(JsonNode node, int maxLength, String field,
|
||||||
java.util.function.Consumer<String> setter) {
|
java.util.function.Consumer<String> setter) {
|
||||||
if (node != null) setter.accept(nullableText(node, maxLength, field));
|
if (node != null) setter.accept(nullableText(node, maxLength, field));
|
||||||
@@ -438,55 +332,12 @@ public class OverseasProcurementService implements WalletResultHandler {
|
|||||||
return value;
|
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) {
|
private static Map<String, Object> editableSnapshot(OverseasProcurementOrder order) {
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String,Object> result=new LinkedHashMap<>();
|
||||||
result.put("purchaseCategory", order.getPurchaseCategory());
|
result.put("remark",order.getRemark());
|
||||||
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());
|
|
||||||
return result;
|
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) {
|
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
|
||||||
Map<String, Object> changes = new LinkedHashMap<>();
|
Map<String, Object> changes = new LinkedHashMap<>();
|
||||||
before.forEach((field, oldValue) -> {
|
before.forEach((field, oldValue) -> {
|
||||||
|
|||||||
+110
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -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();}
|
||||||
|
}
|
||||||
+14
@@ -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; }
|
||||||
|
}
|
||||||
+532
@@ -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); }
|
||||||
|
}
|
||||||
+244
@@ -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);}
|
||||||
|
}
|
||||||
+332
@@ -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();}
|
||||||
|
}
|
||||||
+132
@@ -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();}
|
||||||
|
}
|
||||||
+277
@@ -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);}}
|
||||||
|
}
|
||||||
+53
@@ -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_LIST = "platform:overseas-business:purchase:list";
|
||||||
public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit";
|
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_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_LIST = "platform:overseas-business:truck:list";
|
||||||
public static final String TRUCK_CREATE = "platform:overseas-business:truck:create";
|
public static final String TRUCK_CREATE = "platform:overseas-business:truck:create";
|
||||||
public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit";
|
public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit";
|
||||||
|
|||||||
+33
-8
@@ -27,12 +27,41 @@ public class WalletCommandOutboxService {
|
|||||||
|
|
||||||
public String publish(String operation, long customerId, String businessType, String businessNo,
|
public String publish(String operation, long customerId, String businessType, String businessNo,
|
||||||
BigDecimal amount, String remark, long aggregateVersion) {
|
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);
|
LastlegEventOutbox existing = mapper.findByEventKey(eventKey);
|
||||||
if (existing != null) return eventId(existing.getPayloadJson());
|
if (existing != null) {
|
||||||
String idempotencyKey = businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion;
|
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,
|
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);
|
JsonNode payload = objectMapper.valueToTree(value);
|
||||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED,
|
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED,
|
||||||
WalletCommandRequestedV1.VERSION, Instant.now(),
|
WalletCommandRequestedV1.VERSION, Instant.now(),
|
||||||
@@ -49,10 +78,6 @@ public class WalletCommandOutboxService {
|
|||||||
return envelope.eventId();
|
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) {
|
private String json(Object value) {
|
||||||
try { return objectMapper.writeValueAsString(value); }
|
try { return objectMapper.writeValueAsString(value); }
|
||||||
catch (Exception exception) { throw new IllegalStateException("资金指令事件序列化失败", exception); }
|
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业务已付',
|
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 '直接已付方式',
|
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快照',
|
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',
|
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',
|
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',
|
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 '预算单价',
|
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 '收货地址',
|
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 '联系人电话',
|
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 '采购单据累计入库数量',
|
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 '业务状态',
|
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 '请求内容哈希',
|
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 '审核备注',
|
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 (
|
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',
|
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 '文件大小',
|
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 '上传时间',
|
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),
|
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='海外采购签收文件及费用凭证';
|
) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee (
|
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',
|
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 '费用项名称',
|
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',
|
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) NOT NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明',
|
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_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 '登记人',
|
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 '更新时间',
|
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),
|
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='海外采购费用明细及资金生命周期';
|
) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期';
|
||||||
|
|
||||||
|
|
||||||
@@ -3218,3 +3237,240 @@ CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
|
|||||||
PRIMARY KEY(operator_id),
|
PRIMARY KEY(operator_id),
|
||||||
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
|
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
|
||||||
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
|
) 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' } } }
|
content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } }
|
||||||
/api/platform/v1/last-leg/overseas-procurements/warehouse-options:
|
/api/platform/v1/last-leg/overseas-procurements/warehouse-options:
|
||||||
get:
|
get:
|
||||||
summary: 查询启用的海外合作采购仓候选
|
summary: 查询有效公共海外收货仓候选
|
||||||
description: 权限 platform:overseas-business:purchase:list;复用服务端缓存目录,浏览器不访问国内内部 API。
|
description: 权限 platform:overseas-business:purchase:list;固定国内权威公共 OVERSEAS、ACTIVE、RECEIVING 仓,浏览器不访问内部 API。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -1027,10 +1027,10 @@ paths:
|
|||||||
summary: 查询海外采购订单详情及操作流水
|
summary: 查询海外采购订单详情及操作流水
|
||||||
description: 权限 platform:overseas-business:purchase:list。
|
description: 权限 platform:overseas-business:purchase:list。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
|
responses: { '200': { description: 后台采购聚合详情及服务端操作资格, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementPlatformResponse' } } } } }
|
||||||
patch:
|
patch:
|
||||||
summary: 编辑海外采购订单
|
summary: 编辑海外采购订单
|
||||||
description: 权限 platform:overseas-business:purchase:edit;可选字段缺失表示不修改,显式 null 或空串表示清空。采购仓名称由 warehouse ID 对应候选快照决定。采购出库不属于本 API。
|
description: 权限 platform:overseas-business:purchase:edit;仅编辑内部备注,字段缺失表示不修改,显式 null 或空串表示清空。商品、仓库、数量和运输字段由各履约节点维护,提交这些旧字段会被拒绝。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -1246,7 +1246,7 @@ paths:
|
|||||||
description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
|
description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } }
|
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:
|
get:
|
||||||
summary: 客户分页查询自己的海外采购订单
|
summary: 客户分页查询自己的海外采购订单
|
||||||
description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。
|
description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。
|
||||||
@@ -1264,11 +1264,11 @@ paths:
|
|||||||
responses: { '200': { description: 当前客户采购订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementPageResponse' } } } } }
|
responses: { '200': { description: 当前客户采购订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementPageResponse' } } } } }
|
||||||
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:
|
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:
|
||||||
get:
|
get:
|
||||||
summary: 客户查询自己的海外采购订单详情及流水
|
summary: 客户查询自己的采购履约、箱明细、运单及费用
|
||||||
description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。
|
description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
|
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:
|
/api/merchant/v1/last-leg/us-truck-orders:
|
||||||
get:
|
get:
|
||||||
summary: 客户分页查询自己的美国本土卡订单
|
summary: 客户分页查询自己的美国本土卡订单
|
||||||
@@ -3220,11 +3220,6 @@ components:
|
|||||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||||
- type: object
|
- type: object
|
||||||
properties: { data: { $ref: '#/components/schemas/ProcurementPage' } }
|
properties: { data: { $ref: '#/components/schemas/ProcurementPage' } }
|
||||||
ProcurementDetailResponse:
|
|
||||||
allOf:
|
|
||||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
|
||||||
- type: object
|
|
||||||
properties: { data: { $ref: '#/components/schemas/ProcurementDetail' } }
|
|
||||||
TruckPageResponse:
|
TruckPageResponse:
|
||||||
allOf:
|
allOf:
|
||||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||||
@@ -3327,7 +3322,7 @@ components:
|
|||||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } }
|
properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } }
|
||||||
ProcurementSummary:
|
ProcurementSummary:
|
||||||
type: object
|
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:
|
properties:
|
||||||
id: { type: string, pattern: '^[0-9]+$' }
|
id: { type: string, pattern: '^[0-9]+$' }
|
||||||
orderNo: { type: string }
|
orderNo: { type: string }
|
||||||
@@ -3341,43 +3336,10 @@ components:
|
|||||||
currency: { type: string, nullable: true }
|
currency: { type: string, nullable: true }
|
||||||
purchaseWarehouseId: { type: string, nullable: true }
|
purchaseWarehouseId: { type: string, nullable: true }
|
||||||
purchaseWarehouseName: { type: string, nullable: true }
|
purchaseWarehouseName: { type: string, nullable: true }
|
||||||
inboundQuantity: { type: integer, format: int64 }
|
status: { type: string, description: 以 ProcurementStatus 中的真实履约阶段返回,不按通用出库数量推断 }
|
||||||
outboundQuantity: { type: integer, format: int64 }
|
|
||||||
inventoryQuantity: { type: integer, format: int64 }
|
|
||||||
status: { type: string, description: PENDING_REVIEW/APPROVED/REJECTED 或按审核后出库数量派生的 PENDING_OUTBOUND/PARTIALLY_OUTBOUND/OUTBOUNDED }
|
|
||||||
version: { type: integer, format: int64 }
|
version: { type: integer, format: int64 }
|
||||||
createdAt: { type: string, format: date-time }
|
createdAt: { type: string, format: date-time }
|
||||||
updatedAt: { 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:
|
TruckPage:
|
||||||
allOf:
|
allOf:
|
||||||
- { $ref: '#/components/schemas/PageMetadata' }
|
- { $ref: '#/components/schemas/PageMetadata' }
|
||||||
@@ -3720,18 +3682,20 @@ components:
|
|||||||
actualVolumeCbm: { type: number, nullable: true }
|
actualVolumeCbm: { type: number, nullable: true }
|
||||||
status: { type: string }
|
status: { type: string }
|
||||||
version: { type: integer }
|
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:
|
ProcurementReview:
|
||||||
type: object
|
type: object
|
||||||
required: [version, decision, idempotencyKey]
|
required: [version, decision, idempotencyKey]
|
||||||
|
description: 审核通过必须选择有效公共海外收货仓;拒绝不要求仓库。
|
||||||
properties:
|
properties:
|
||||||
version: { type: integer, format: int64, minimum: 0 }
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
decision: { type: string, enum: [APPROVE, REJECT] }
|
decision: { type: string, enum: [APPROVE, REJECT] }
|
||||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
|
purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true }
|
||||||
ProcurementCreate:
|
ProcurementCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey]
|
required: [purchaseCategory, productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey]
|
||||||
properties:
|
properties:
|
||||||
customerName: { type: string, nullable: true, maxLength: 255 }
|
customerName: { type: string, nullable: true, maxLength: 255 }
|
||||||
purchaseCategory: { type: string, maxLength: 128 }
|
purchaseCategory: { type: string, maxLength: 128 }
|
||||||
@@ -3739,9 +3703,9 @@ components:
|
|||||||
productSpecification: { type: string, nullable: true, maxLength: 500 }
|
productSpecification: { type: string, nullable: true, maxLength: 500 }
|
||||||
productUrl: { type: string, nullable: true, maxLength: 2000 }
|
productUrl: { type: string, nullable: true, maxLength: 2000 }
|
||||||
purchaseQuantity: { type: integer, format: int64, minimum: 1 }
|
purchaseQuantity: { type: integer, format: int64, minimum: 1 }
|
||||||
budgetUnitPrice: { type: number, nullable: true, minimum: 0 }
|
budgetUnitPrice: { type: number, minimum: 0, multipleOf: 0.01, description: 客户授权的货款预算单价;零表示仅授权免费样品 }
|
||||||
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
|
currency: { type: string, enum: [CNY, USD] }
|
||||||
receivingAddress: { type: string, nullable: true, maxLength: 1000 }
|
receivingAddress: { type: string, minLength: 1, maxLength: 1000, description: 最终客户国内收件完整地址 }
|
||||||
contactName: { type: string, maxLength: 128 }
|
contactName: { type: string, maxLength: 128 }
|
||||||
contactPhone: { type: string, maxLength: 64 }
|
contactPhone: { type: string, maxLength: 64 }
|
||||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
@@ -3749,35 +3713,12 @@ components:
|
|||||||
NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 }
|
NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 }
|
||||||
ProcurementUpdate:
|
ProcurementUpdate:
|
||||||
type: object
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: 只维护内部备注;商品、数量、仓库、实收和运输必须通过独立节点操作。
|
||||||
required: [version]
|
required: [version]
|
||||||
properties:
|
properties:
|
||||||
version: { type: integer, format: int64, minimum: 0 }
|
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' }
|
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:
|
CustomerOptionResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ paths:
|
|||||||
responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } }
|
responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } }
|
||||||
post:
|
post:
|
||||||
summary: 创建海外业务费用来源草稿
|
summary: 创建海外业务费用来源草稿
|
||||||
description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。
|
description: 采购费用仅由采购履约或财务节点实扣后自动发布,本入口拒绝采购来源或采购业务类型。客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }]
|
parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } }
|
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}:
|
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}:
|
||||||
post:
|
post:
|
||||||
summary: 提交、审核、调整或作废海外业务费用来源
|
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: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { in: path, name: sourceFeeId, required: true, schema: { type: string } }
|
- { 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]
|
required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount]
|
||||||
properties:
|
properties:
|
||||||
customerId: { type: string }
|
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] }
|
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: { $ref: '#/components/schemas/BusinessType' }
|
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 }
|
businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
|
||||||
businessNo: { type: string, maxLength: 128 }
|
businessNo: { type: string, maxLength: 128 }
|
||||||
containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
|
containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -30,7 +30,7 @@ class DomesticWarehouseDirectoryClientContractTest {
|
|||||||
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents())
|
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents())
|
||||||
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
|
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
|
||||||
.containsExactly("id:Long", "tenantId:Long", "warehouseCode:String", "warehouseName:String",
|
.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())
|
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents())
|
||||||
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
|
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
|
||||||
.containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String",
|
.containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String",
|
||||||
|
|||||||
+2
-2
@@ -59,7 +59,7 @@ class DispatchBolPrintServiceTest {
|
|||||||
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
|
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
|
||||||
null, "91730", "10767 7th Street", null);
|
null, "91730", "10767 7th Street", null);
|
||||||
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
|
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(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
|
||||||
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
|
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
|
||||||
.thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address))));
|
.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",
|
new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse",
|
||||||
"NYEXX2609110215", null, null, 1)), null);
|
"NYEXX2609110215", null, null, 1)), null);
|
||||||
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
|
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(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
|
||||||
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
|
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
|
||||||
.thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of())));
|
.thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of())));
|
||||||
|
|||||||
+48
@@ -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("快照不完整");
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -60,7 +60,7 @@ class MerchantOverseasOpenApiContractTest {
|
|||||||
.contains("merchant:overseas-warehouse:return-order:create")
|
.contains("merchant:overseas-warehouse:return-order:create")
|
||||||
.contains("merchant:overseas-warehouse:return-order:relabel")
|
.contains("merchant:overseas-warehouse:return-order:relabel")
|
||||||
.contains("merchant:warehouse:inbound:list")
|
.contains("merchant:warehouse:inbound:list")
|
||||||
.contains("#/components/schemas/ProcurementDetailResponse")
|
.contains("procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse")
|
||||||
.contains("#/components/schemas/TruckDetailResponse")
|
.contains("#/components/schemas/TruckDetailResponse")
|
||||||
.contains("#/components/schemas/LooseboardDetailResponse")
|
.contains("#/components/schemas/LooseboardDetailResponse")
|
||||||
.contains("#/components/schemas/InboundPlanDetailResponse")
|
.contains("#/components/schemas/InboundPlanDetailResponse")
|
||||||
@@ -68,6 +68,10 @@ class MerchantOverseasOpenApiContractTest {
|
|||||||
.contains("overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }")
|
.contains("overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }")
|
||||||
.contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE")
|
.contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE")
|
||||||
.contains("version 必填").contains("幂等冲突");
|
.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:")
|
assertThat(yaml).contains("/api/platform/v1/last-leg/inbound-plans:")
|
||||||
.contains("/api/platform/v1/last-leg/inbound-plans/{planNo}:")
|
.contains("/api/platform/v1/last-leg/inbound-plans/{planNo}:")
|
||||||
.contains("overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实")
|
.contains("overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实")
|
||||||
|
|||||||
+3
-1
@@ -26,6 +26,8 @@ class OverseasProcurementInventoryBoundaryTest {
|
|||||||
String responses = Files.readString(Path.of(
|
String responses = Files.readString(Path.of(
|
||||||
"src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java"),
|
"src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java"),
|
||||||
StandardCharsets.UTF_8);
|
StandardCharsets.UTF_8);
|
||||||
assertTrue(responses.contains("outboundQuantity"));
|
assertFalse(responses.contains("outboundQuantity"));
|
||||||
|
assertFalse(responses.contains("inventoryQuantity"));
|
||||||
|
assertFalse(responses.contains("OutboundDetail"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -22,7 +22,9 @@ class OverseasProcurementSchemaContractTest {
|
|||||||
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
|
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
|
||||||
assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL"));
|
assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL"));
|
||||||
assertTrue(sql.contains("review_request_hash CHAR(64) 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("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
|
||||||
assertTrue(sql.contains("change_detail JSON NULL"));
|
assertTrue(sql.contains("change_detail JSON NULL"));
|
||||||
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
|
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");
|
String order = tableBlock(lower, "ll_overseas_procurement_order");
|
||||||
assertTrue(order.contains("review_idempotency_key varchar(255)"));
|
assertTrue(order.contains("review_idempotency_key varchar(255)"));
|
||||||
assertTrue(order.contains("review_request_hash char(64)"));
|
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)"));
|
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_overseas_procurement"));
|
||||||
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_wallet_event_inbox"));
|
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_wallet_event_inbox"));
|
||||||
|
|||||||
+40
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-4
@@ -20,6 +20,7 @@ import static org.mockito.Mockito.verify;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class OverseasProcurementControllerTest {
|
class OverseasProcurementControllerTest {
|
||||||
@Mock OverseasProcurementService service;
|
@Mock OverseasProcurementService service;
|
||||||
|
@Mock com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
|
||||||
private MerchantOverseasProcurementController merchant;
|
private MerchantOverseasProcurementController merchant;
|
||||||
private PlatformOverseasProcurementController platform;
|
private PlatformOverseasProcurementController platform;
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ class OverseasProcurementControllerTest {
|
|||||||
platform = new PlatformOverseasProcurementController();
|
platform = new PlatformOverseasProcurementController();
|
||||||
ReflectionTestUtils.setField(merchant, "service", service);
|
ReflectionTestUtils.setField(merchant, "service", service);
|
||||||
ReflectionTestUtils.setField(platform, "service", service);
|
ReflectionTestUtils.setField(platform, "service", service);
|
||||||
|
ReflectionTestUtils.setField(merchant,"queryService",queryService);
|
||||||
|
ReflectionTestUtils.setField(platform,"queryService",queryService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -37,10 +40,19 @@ class OverseasProcurementControllerTest {
|
|||||||
merchant.page(user(100L, "MERCHANT", Set.of()), query);
|
merchant.page(user(100L, "MERCHANT", Set.of()), query);
|
||||||
verify(service).page("100", query.scoped());
|
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
|
@Test
|
||||||
void reviewRequiresExplicitPermission() {
|
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,
|
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||||
() -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request));
|
() -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request));
|
||||||
assertEquals(403, error.getStatusCode().value());
|
assertEquals(403, error.getStatusCode().value());
|
||||||
@@ -56,7 +68,7 @@ class OverseasProcurementControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformReviewUsesExplicitTargetTenant() {
|
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)),
|
platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)),
|
||||||
"100", "HC1", request);
|
"100", "HC1", request);
|
||||||
verify(service).review("100", "HC1", "7", request);
|
verify(service).review("100", "HC1", "7", request);
|
||||||
@@ -64,8 +76,7 @@ class OverseasProcurementControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformUpdatePassesAuditedOperator() {
|
void platformUpdatePassesAuditedOperator() {
|
||||||
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
|
var request = new OverseasProcurementRequests.Update(0L, null);
|
||||||
null, null, null, null, null, null, null, null, null);
|
|
||||||
platform.update(user(0L, "PLATFORM", Set.of(OverseasProcurementService.MANAGE_PERMISSION)),
|
platform.update(user(0L, "PLATFORM", Set.of(OverseasProcurementService.MANAGE_PERMISSION)),
|
||||||
"100", "HC1", request);
|
"100", "HC1", request);
|
||||||
verify(service).update("100", "HC1", "7", request);
|
verify(service).update("100", "HC1", "7", request);
|
||||||
|
|||||||
+141
@@ -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");}
|
||||||
|
}
|
||||||
+38
-111
@@ -42,7 +42,7 @@ import static org.mockito.Mockito.when;
|
|||||||
class OverseasProcurementServiceTest {
|
class OverseasProcurementServiceTest {
|
||||||
@Mock OverseasProcurementOrderMapper orders;
|
@Mock OverseasProcurementOrderMapper orders;
|
||||||
@Mock OverseasProcurementStatusHistoryMapper histories;
|
@Mock OverseasProcurementStatusHistoryMapper histories;
|
||||||
@Mock PartnerWarehouseDirectory warehouseDirectory;
|
@Mock ProcurementWarehouseDirectory warehouseDirectory;
|
||||||
@Mock ProcurementOrderNumberGenerator orderNoGenerator;
|
@Mock ProcurementOrderNumberGenerator orderNoGenerator;
|
||||||
@Mock OverseasProcurementFileMapper procurementFiles;
|
@Mock OverseasProcurementFileMapper procurementFiles;
|
||||||
@Mock OverseasProcurementFeeMapper procurementFees;
|
@Mock OverseasProcurementFeeMapper procurementFees;
|
||||||
@@ -62,9 +62,6 @@ class OverseasProcurementServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
|
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
|
||||||
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
|
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
|
||||||
ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
|
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(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001");
|
||||||
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
|
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
|
||||||
lenient().when(procurementFees.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"));
|
new BigDecimal("26.50"), "USD", "Ningbo", "Buyer", "13800000000", null, "key-1"));
|
||||||
|
|
||||||
assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status());
|
assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status());
|
||||||
assertEquals(0, detail.summary().inboundQuantity());
|
|
||||||
assertEquals(0, detail.summary().outboundQuantity());
|
|
||||||
assertEquals(0, detail.summary().inventoryQuantity());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void outboundQuantityAndDisplayStatusFollowOutboundDetails() {
|
void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() {
|
||||||
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 90, 0);
|
OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0);
|
||||||
when(orders.findNo("100", "HC1")).thenReturn(order);
|
order.setRemark("old");
|
||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
order.setEstimatedArrivalAt(LocalDateTime.of(2026,10,10,12,0));
|
||||||
|
|
||||||
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");
|
|
||||||
when(orders.lockNo("100", "HC1")).thenReturn(order);
|
when(orders.lockNo("100", "HC1")).thenReturn(order);
|
||||||
when(orders.findNo("100", "HC1")).thenReturn(order);
|
when(orders.findNo("100", "HC1")).thenReturn(order);
|
||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
var request = new OverseasProcurementRequests.Update(0L, null, null,
|
service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(0L, new ObjectMapper().nullNode()));
|
||||||
new ObjectMapper().nullNode(), new ObjectMapper().nullNode(), null,
|
assertEquals(null, order.getRemark());
|
||||||
null, null, null, null, null, null, null, null);
|
assertEquals(LocalDateTime.of(2026,10,10,12,0), order.getEstimatedArrivalAt());
|
||||||
|
assertEquals(100L, order.getInboundQuantity());
|
||||||
service.update("100", "HC1", "7", request);
|
assertEquals(List.of("version", "remark"), java.util.Arrays.stream(OverseasProcurementRequests.Update.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList());
|
||||||
|
|
||||||
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")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void omittedOptionalFieldKeepsExistingValue() {
|
void omittedRemarkKeepsExistingValueAndStaleVersionCannotWrite() {
|
||||||
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
|
OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0);
|
||||||
order.setProductUrl("https://example.test/keep");
|
order.setRemark("keep");
|
||||||
when(orders.lockNo("100", "HC1")).thenReturn(order);
|
when(orders.lockNo("100", "HC1")).thenReturn(order);
|
||||||
when(orders.findNo("100", "HC1")).thenReturn(order);
|
when(orders.findNo("100", "HC1")).thenReturn(order);
|
||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
|
service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(0L, null));
|
||||||
null, null, null, null, null, null, null, null, null);
|
assertEquals("keep", order.getRemark());
|
||||||
|
|
||||||
service.update("100", "HC1", "7", request);
|
|
||||||
|
|
||||||
assertEquals("https://example.test/keep", order.getProductUrl());
|
|
||||||
verify(orders, never()).updateById(org.mockito.ArgumentMatchers.<OverseasProcurementOrder>any());
|
verify(orders, never()).updateById(org.mockito.ArgumentMatchers.<OverseasProcurementOrder>any());
|
||||||
}
|
assertThrows(ResponseStatusException.class, () -> service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(2L, null)));
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -218,25 +132,26 @@ class OverseasProcurementServiceTest {
|
|||||||
when(orders.lockNo("100", "HC1")).thenReturn(order);
|
when(orders.lockNo("100", "HC1")).thenReturn(order);
|
||||||
when(orders.findNo("100", "HC1")).thenReturn(order);
|
when(orders.findNo("100", "HC1")).thenReturn(order);
|
||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
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);
|
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, approved.summary().version());
|
||||||
assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version());
|
assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version());
|
||||||
verify(histories).insert(org.mockito.ArgumentMatchers
|
verify(histories).insert(org.mockito.ArgumentMatchers
|
||||||
.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
|
.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
|
||||||
"REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId())
|
"REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId())
|
||||||
&& "approve-1".equals(value.getIdempotencyKey())
|
&& "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",
|
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",
|
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",
|
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
|
||||||
new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse")));
|
new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse", 10L)));
|
||||||
assertEquals(OverseasProcurementService.APPROVED, order.getStatus());
|
assertEquals(OverseasProcurementService.WAIT_PURCHASE, order.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
|
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
|
||||||
@@ -256,11 +171,23 @@ class OverseasProcurementServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception {
|
void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception {
|
||||||
for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId",
|
for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId",
|
||||||
"purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier",
|
"purchaseWarehouseName", "estimatedArrivalAt", "remark")) {
|
||||||
"overseasTrackingNo", "estimatedArrivalAt", "remark")) {
|
|
||||||
TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class);
|
TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class);
|
||||||
assertNotNull(annotation, field);
|
assertNotNull(annotation, field);
|
||||||
assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+257
@@ -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)));}
|
||||||
|
}
|
||||||
+129
@@ -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)));}
|
||||||
|
}
|
||||||
+204
@@ -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)); }
|
||||||
|
}
|
||||||
+188
@@ -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")));}
|
||||||
|
}
|
||||||
+155
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
@@ -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)));}
|
||||||
|
}
|
||||||
+35
@@ -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("暂不可用");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -102,7 +102,7 @@ class FbaWarehouseValidatorTest {
|
|||||||
private DomesticWarehouseDirectoryClient.Detail detail(String type, String status, String code, String name) {
|
private DomesticWarehouseDirectoryClient.Detail detail(String type, String status, String code, String name) {
|
||||||
return new DomesticWarehouseDirectoryClient.Detail(
|
return new DomesticWarehouseDirectoryClient.Detail(
|
||||||
new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name,
|
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",
|
List.of(new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles",
|
||||||
null, "90001", "123 Logistics Ave", null)));
|
null, "90001", "123 Logistics Ave", null)));
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -83,7 +83,7 @@ class WarehouseTransferReceiptCoordinatorTest {
|
|||||||
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
|
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
|
||||||
new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
|
new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
|
||||||
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
|
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 -> {
|
when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> {
|
||||||
assertFalse(TransactionSynchronizationManager.isActualTransactionActive(), "Remote reserve must not hold local transaction locks");
|
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);
|
reserveCalls.incrementAndGet(); long pallet = call.getArgument(2), wh = call.getArgument(1), location = call.getArgument(3);
|
||||||
@@ -253,10 +253,10 @@ class WarehouseTransferReceiptCoordinatorTest {
|
|||||||
}
|
}
|
||||||
@Test void inactiveOrForeignTypeFbaMasterStopsBeforeResourceOccupation() {
|
@Test void inactiveOrForeignTypeFbaMasterStopsBeforeResourceOccupation() {
|
||||||
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
|
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());
|
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(
|
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"));
|
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
|
||||||
}
|
}
|
||||||
@Test void inactiveReceivingWarehouseOrForeignPalletCannotCreateReceiptCommand() {
|
@Test void inactiveReceivingWarehouseOrForeignPalletCannotCreateReceiptCommand() {
|
||||||
@@ -271,7 +271,7 @@ class WarehouseTransferReceiptCoordinatorTest {
|
|||||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
|
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
|
||||||
when(warehouse.resolvePallet(20L)).thenReturn(pallet(20, "IDLE"));
|
when(warehouse.resolvePallet(20L)).thenReturn(pallet(20, "IDLE"));
|
||||||
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
|
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"));
|
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
|
||||||
}
|
}
|
||||||
@Test void sourceWithoutShippedOwnershipIsRejectedBeforeAnyRemoteCall() {
|
@Test void sourceWithoutShippedOwnershipIsRejectedBeforeAnyRemoteCall() {
|
||||||
|
|||||||
+51
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# 后端脚本管理与全量 HTTP 验收索引
|
# 后端脚本管理与全量 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/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、库存或履约状态。执行前核对目标数据库,执行后核验查询应为空,异常关系保留供核实。已同步海外业务统一增量脚本。
|
`db/return-tail-order-number-incremental-20261009.sql` 将已验证关联的退货尾程单号及退货单尾程号快照统一为退货单号。显式 `USE baiyue_lastleg`,可重复执行,只更新同客户、关联订单 ID 和来源退货号均一致的 `RETURN_RELABEL` 单据;不改变业务 ID、库存或履约状态。执行前核对目标数据库,执行后核验查询应为空,异常关系保留供核实。已同步海外业务统一增量脚本。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Admin 测试环境 HTTP 模块
|
# 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。
|
`phase5-root-fulfillment` 可由总入口显式 `--prepare-domestic-pda-h5 true` 仅准备本轮国内H5待拣任务。调用 `phase3-order-splitting.loadOneFragment` 到正式计划确认、待拣任务及箱唛就绪即返回,不领取、不拣货、不继续海外;结果固定 `PREPARED_NOT_VERIFIED`,不算全链验收。默认完整链不变,范围及授权见根脚本README。
|
||||||
|
|
||||||
`fleet-capacity-pool-schema-contract.test.mjs` 是离线数据库合同:验证发布与持久命令唯一约束、唯一商业履约来源、可空实际数量、服务与统一基线一致、非破坏性结构补齐及独立销售/调度权限;不访问环境、不证明真实履约已通过。
|
`fleet-capacity-pool-schema-contract.test.mjs` 是离线数据库合同:验证发布与持久命令唯一约束、唯一商业履约来源、可空实际数量、服务与统一基线一致、非破坏性结构补齐及独立销售/调度权限;不访问环境、不证明真实履约已通过。
|
||||||
@@ -269,7 +271,7 @@ export async function run(context) {
|
|||||||
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
| 消息中心管理 | `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 真实前置、对账、结算与下载中心 | `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`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
|
| 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` |
|
| 海外仓 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 柜号收货与库内操作 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` 初态 |
|
| 海外 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` |
|
| 国内离仓至海外 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` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
|
| 国内出仓聚合、门禁、待装柜与仓库权限 | `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`,并继续验证装柜完成、报关和订舱状态组合 |
|
| 人工状态矩阵 | `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` |
|
| 海外主数据 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`;不再由船节点脚本直接伪造提柜和还柜 |
|
| 目的港码头拖车与船节点终态 | `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` |
|
| 海外排车、出库、派送、异常与回访 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,不改表或伪造新事件。
|
`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` 校验失败不继续下载及文件内容验证,不访问环境。
|
下载中心导出共用内部助手:`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' }
|
const credentials = options.credentials === false ? {} : { username: 'merchant', password: 'secret' }
|
||||||
|
|
||||||
function purchaseDetail() {
|
function purchaseDetail() {
|
||||||
return { summary: state.purchase, productUrl: state.purchase?.productUrl ?? null,
|
return { summary: state.purchase, remark: state.purchase?.remark ?? null, histories: state.purchaseHistories }
|
||||||
domesticCarrier: state.purchase?.domesticCarrier ?? null, domesticTrackingNo: state.purchase?.domesticTrackingNo ?? null,
|
|
||||||
estimatedArrivalAt: state.purchase?.estimatedArrivalAt ?? null, 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 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 } }
|
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') {
|
if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') {
|
||||||
assert.equal(token, 'merchant-overseas-token')
|
assert.equal(token, 'merchant-overseas-token')
|
||||||
const hash = JSON.stringify(body)
|
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.purchaseHash = hash
|
||||||
state.purchase = { id: '30001', orderNo: 'OP1', customerId: '10001', customerName: body.customerName,
|
state.purchase = { id: '30001', orderNo: 'OP1', customerId: '10001', customerName: body.customerName,
|
||||||
purchaseWarehouseId: null, purchaseWarehouseName: null, purchaseCategory: body.purchaseCategory,
|
purchaseWarehouseId: null, purchaseWarehouseName: null, purchaseCategory: body.purchaseCategory,
|
||||||
productName: body.productName, productSpecification: body.productSpecification, productUrl: body.productUrl,
|
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 }
|
status: 'PENDING_REVIEW', version: 0, remark: body.remark }
|
||||||
state.purchaseHistories.push({ operationType: 'CREATE', fromStatus: null, toStatus: 'PENDING_REVIEW', remark: marker })
|
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
|
if (requestPath.includes('/customer-options')) return response(state.purchase
|
||||||
? [{ customerId: '10001', customerName: '客户甲' }] : [])
|
? [{ customerId: '10001', customerName: '客户甲' }] : [])
|
||||||
@@ -71,24 +70,19 @@ function fixture(options = {}) {
|
|||||||
if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409')
|
if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409')
|
||||||
if (state.purchase.status !== 'PENDING_REVIEW') return response(null, 'A0409')
|
if (state.purchase.status !== 'PENDING_REVIEW') return response(null, 'A0409')
|
||||||
if (Number(body.version) !== state.purchase.version) 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.purchase = { ...state.purchase, status: next, version: state.purchase.version + 1 }
|
||||||
state.purchaseHistories.push({ operationType: 'REVIEW', fromStatus: 'PENDING_REVIEW', toStatus: next, remark: body.remark })
|
state.purchaseHistories.push({ operationType: 'REVIEW', fromStatus: 'PENDING_REVIEW', toStatus: next, remark: body.remark })
|
||||||
const result = structuredClone(purchaseDetail())
|
const result = structuredClone(purchaseDetail())
|
||||||
state.purchaseReviews.set(body.idempotencyKey, { hash, result })
|
state.purchaseReviews.set(body.idempotencyKey, { hash, result })
|
||||||
return response(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 (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') {
|
||||||
if (Number(body.version) !== state.purchase.version) return response(null, 'A0409')
|
if (Number(body.version) !== state.purchase.version) return response(null, 'A0409')
|
||||||
const patch = { ...body }; delete patch.version
|
if (Object.keys(body).some(key => !['version', 'remark'].includes(key))) return response(null, 'A0400')
|
||||||
for (const key of ['productSpecification', 'productUrl', 'domesticCarrier', 'domesticTrackingNo', 'remark']) {
|
if (!Object.hasOwn(body, 'remark') || blankToNull(body.remark) === state.purchase.remark) return response(purchaseDetail())
|
||||||
if (Object.hasOwn(patch, key)) patch[key] = blankToNull(patch[key])
|
state.purchase = { ...state.purchase, remark: blankToNull(body.remark), version: state.purchase.version + 1 }
|
||||||
}
|
|
||||||
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 }
|
|
||||||
state.purchaseHistories.push({ operationType: 'UPDATE', fromStatus: state.purchase.status,
|
state.purchaseHistories.push({ operationType: 'UPDATE', fromStatus: state.purchase.status,
|
||||||
toStatus: state.purchase.status, changeDetail: JSON.stringify({ marker }) })
|
toStatus: state.purchase.status, changeDetail: JSON.stringify({ marker }) })
|
||||||
return response(purchaseDetail())
|
return response(purchaseDetail())
|
||||||
@@ -180,7 +174,8 @@ function fixture(options = {}) {
|
|||||||
} : async function(method,requestPath,body) {
|
} : async function(method,requestPath,body) {
|
||||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||||
if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers))
|
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 === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) {
|
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) {
|
||||||
state.warehouseStatus = 'INACTIVE'; return response(null)
|
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'/)
|
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()
|
const context = fixture()
|
||||||
await run(context)
|
await run(context)
|
||||||
assert.equal(context.steps.every((step) => step.passed), true,
|
assert.equal(context.steps.every((step) => step.passed), true,
|
||||||
context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
||||||
assert.equal(context.gaps.length, 0)
|
assert.equal(context.gaps.length, 0)
|
||||||
assert.equal(context.blockers.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')
|
const procurementWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET')
|
||||||
assert.deepEqual(procurementWrites.map((call) => call.method),
|
assert.deepEqual(procurementWrites.map((call) => call.method),
|
||||||
['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST', 'POST', 'POST'])
|
['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST', 'POST', 'POST'])
|
||||||
assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 4)
|
assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 5)
|
||||||
assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.productSpecification === null && call.body.productUrl === ''))
|
assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.remark === null))
|
||||||
const procurementReviews = procurementWrites.filter((call) => call.path.includes('/review?'))
|
const procurementReviews = procurementWrites.filter((call) => call.path.includes('/review?'))
|
||||||
assert.equal(procurementReviews.length, 4)
|
assert.equal(procurementReviews.length, 4)
|
||||||
assert.ok(procurementReviews.every((call) => Number.isInteger(Number(call.body.version))
|
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', () => {
|
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',
|
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',
|
'/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',
|
'同幂等键同载荷精确重放', '同幂等键异载荷返回冲突', '旧 version 返回 A0409',
|
||||||
'状态正向推进', '状态逆向回退返回冲突', '流水覆盖', "status: 'CANCELLED'",
|
'状态正向推进', '状态逆向回退返回冲突', '流水覆盖', "status: 'CANCELLED'",
|
||||||
'/review', 'truck-review-approve', 'looseboard-status-dispatched',
|
'/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 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 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 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 conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response).endsWith('409')) }
|
||||||
function versionConflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' }
|
function versionConflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' }
|
||||||
function longId(value) { return /^[1-9]\d*$/.test(id(value)) }
|
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',
|
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
|
||||||
district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null,
|
district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null,
|
||||||
latitude: null, longitude: 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` }],
|
cutoffTime: null, description: `${context.marker} procurement acceptance` }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,13 +81,13 @@ function procurementPayload(context, activeCustomer) {
|
|||||||
customerName: activeCustomer?.tenantName || activeCustomer?.name || `Customer-${id(activeCustomer?.id)}`,
|
customerName: activeCustomer?.tenantName || activeCustomer?.name || `Customer-${id(activeCustomer?.id)}`,
|
||||||
purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 采购商品`,
|
purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 采购商品`,
|
||||||
productSpecification: 'Phase4 acceptance', productUrl: null, purchaseQuantity: 10,
|
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,
|
contactName: '采购验收联系人', contactPhone: '13800000002', remark: context.marker,
|
||||||
idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255),
|
idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginMerchant(context, activeCustomer) {
|
async function loginMerchant(context, activeCustomer, session) {
|
||||||
const fixture = context.context?.merchantQualificationFixture
|
const fixture = context.context?.merchantQualificationFixture
|
||||||
if (!nonEmpty(fixture?.username) || !nonEmpty(fixture?.password)) {
|
if (!nonEmpty(fixture?.username) || !nonEmpty(fixture?.password)) {
|
||||||
return blocker(context, '采购客户登录前置完整', null,
|
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 login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
||||||
const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS)
|
const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS)
|
||||||
const token = auth?.accessToken
|
const token = auth?.accessToken
|
||||||
|
session.accessToken = token; session.refreshToken = auth?.refreshToken
|
||||||
context.assert(MODULE, '采购客户登录返回统一Token', login, nonEmpty(token),
|
context.assert(MODULE, '采购客户登录返回统一Token', login, nonEmpty(token),
|
||||||
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(token)}; ${trace(login)}`, OPTIONS)
|
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(token)}; ${trace(login)}`, OPTIONS)
|
||||||
return nonEmpty(token) ? token : null
|
return nonEmpty(token) ? token : null
|
||||||
@@ -105,22 +105,10 @@ async function loginMerchant(context, activeCustomer) {
|
|||||||
async function createMerchantProcurement(context, activeCustomer, token) {
|
async function createMerchantProcurement(context, activeCustomer, token) {
|
||||||
const path = '/api/merchant/v1/last-leg/overseas-procurements'
|
const path = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||||
const payload = procurementPayload(context, activeCustomer)
|
const payload = procurementPayload(context, activeCustomer)
|
||||||
let activeToken = token
|
const activeToken = token
|
||||||
let response = await context.request('POST', path, payload, activeToken, {})
|
const 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 detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
|
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)
|
const resource = nonEmpty(value?.orderNo) && longId(value?.customerId)
|
||||||
? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId),
|
? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId),
|
||||||
version: Number(value.version), finalStatus: value.status, retained: true }
|
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 replayResponse = await context.request('POST', path, payload, activeToken, {})
|
||||||
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
|
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
|
||||||
const replay = summary(replayDetail)
|
const replay = replayDetail?.order
|
||||||
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
|
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
|
||||||
replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version),
|
replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version),
|
||||||
`orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS)
|
`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 }
|
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')
|
const tenants = await domesticGet(context, '查询 ACTIVE 客户候选', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
|
||||||
assertPage(context, 'ACTIVE 客户候选', tenants)
|
assertPage(context, 'ACTIVE 客户候选', tenants)
|
||||||
const activeCustomers = records(tenants.data).filter((item) => longId(item?.id) && item?.status === 'ACTIVE')
|
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)
|
const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
|
||||||
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
|
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
|
||||||
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
|
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
|
||||||
const token = await loginMerchant(context, activeCustomer)
|
const token = await loginMerchant(context, activeCustomer, session)
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
const partnerWarehouse = await createPartnerWarehouse(context)
|
const partnerWarehouse = await createPartnerWarehouse(context)
|
||||||
if (!partnerWarehouse) return null
|
if (!partnerWarehouse) return null
|
||||||
@@ -173,18 +161,17 @@ async function prerequisites(context) {
|
|||||||
const procurementCustomer = Array.isArray(customerOptions.data)
|
const procurementCustomer = Array.isArray(customerOptions.data)
|
||||||
? customerOptions.data.find((item) => id(item?.customerId) === id(activeCustomer.id)) : null
|
? customerOptions.data.find((item) => id(item?.customerId) === id(activeCustomer.id)) : null
|
||||||
const warehouse = Array.isArray(warehouseOptions.data)
|
const warehouse = Array.isArray(warehouseOptions.data)
|
||||||
? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id)
|
? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id) : null
|
||||||
|| warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null
|
|
||||||
if (!procurementCustomer) return blocker(context, '采购客户候选包含本轮客户', customerOptions.response,
|
if (!procurementCustomer) return blocker(context, '采购客户候选包含本轮客户', customerOptions.response,
|
||||||
`本轮客户 ${id(activeCustomer.id)} 未进入采购客户候选,禁止继续写验收`)
|
`本轮客户 ${id(activeCustomer.id)} 未进入采购客户候选,禁止继续写验收`)
|
||||||
if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response,
|
if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response,
|
||||||
'采购仓候选为空,禁止跳过采购三态清空')
|
'采购仓候选为空,禁止跳过采购备注与审核边界')
|
||||||
return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions,
|
return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions,
|
||||||
partnerWarehouse, purchase, token }
|
partnerWarehouse, purchase, token }
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertProcurementHistory(context, result) {
|
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))
|
const operations = new Set(histories.map((item) => item?.operationType))
|
||||||
context.assert(MODULE, '采购流水覆盖创建编辑与审核', result.response,
|
context.assert(MODULE, '采购流水覆盖创建编辑与审核', result.response,
|
||||||
['CREATE', 'UPDATE', 'REVIEW'].every((type) => operations.has(type))
|
['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 path = query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}`, { customerId })
|
||||||
const detail = await overseas(context, 'GET', '查询本轮采购订单详情', path)
|
const detail = await overseas(context, 'GET', '查询本轮采购订单详情', path)
|
||||||
const before = summary(detail.data)
|
const before = detail.data?.customerView?.order
|
||||||
context.assert(MODULE, '采购详情归属和初始版本正确', detail.response,
|
context.assert(MODULE, '采购详情归属和初始版本正确', detail.response,
|
||||||
before?.orderNo === owned.orderNo && id(before?.customerId) === customerId && Number(before?.version) === 0,
|
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)
|
`orderNo=${id(before?.orderNo) || 'missing'}, version=${before?.version ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||||
|
|
||||||
const setResult = await overseas(context, 'PATCH', '采购可空字段设置非空值', path,
|
const setResult = await overseas(context, 'PATCH', '设置采购内部备注', path,
|
||||||
{ version: Number(before?.version), productSpecification: `${context.marker}-SPEC`,
|
{ version: Number(before?.version), remark: `${context.marker}-edited` })
|
||||||
productUrl: `phase4-product-${context.marker}`, purchaseWarehouseId: warehouseId,
|
const setSummary = setResult.data?.summary
|
||||||
purchaseWarehouseName: prerequisite.warehouse.warehouseName, domesticCarrier: `${context.marker}-CARRIER`,
|
context.assert(MODULE, '采购备注设置并递增版本', setResult.response,
|
||||||
domesticTrackingNo: `${context.marker}-TRACK`, estimatedArrivalAt: '2026-12-31T12:00:00', remark: context.marker })
|
setResult.data?.remark === `${context.marker}-edited` && Number(setSummary?.version) === Number(before?.version) + 1,
|
||||||
const setSummary = summary(setResult.data)
|
trace(setResult.response), OPTIONS)
|
||||||
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 omittedResult = await overseas(context, 'PATCH', '采购可空字段缺失保持原值', path,
|
const omittedResult = await overseas(context, 'PATCH', '采购备注缺失保持原值', path,
|
||||||
{ version: Number(setSummary?.version), domesticCarrier: `${context.marker}-CARRIER-2` })
|
{ version: Number(setSummary?.version) })
|
||||||
const omittedSummary = summary(omittedResult.data)
|
context.assert(MODULE, '缺失备注不修改且版本不漂移', omittedResult.response,
|
||||||
context.assert(MODULE, '采购三态第二态字段缺失不修改', omittedResult.response,
|
omittedResult.data?.remark === `${context.marker}-edited` && Number(omittedResult.data?.summary?.version) === Number(setSummary?.version),
|
||||||
omittedSummary?.productSpecification === setSummary?.productSpecification
|
trace(omittedResult.response), OPTIONS)
|
||||||
&& detailValue(omittedResult.data, 'productUrl') === detailValue(setResult.data, 'productUrl')
|
const clearedResult = await overseas(context, 'PATCH', '采购备注显式清空', path,
|
||||||
&& id(omittedSummary?.purchaseWarehouseId) === warehouseId
|
{ version: Number(setSummary?.version), remark: null })
|
||||||
&& Number(omittedSummary?.version) === Number(setSummary?.version) + 1,
|
const clearedSummary = clearedResult.data?.summary
|
||||||
`version=${omittedSummary?.version ?? 'missing'}, specification=${omittedSummary?.productSpecification || 'missing'}; ${trace(omittedResult.response)}`, OPTIONS)
|
context.assert(MODULE, '采购备注清空只变更本字段', clearedResult.response,
|
||||||
|
clearedResult.data?.remark == null && Number(clearedSummary?.version) === Number(setSummary?.version) + 1,
|
||||||
const clearedResult = await overseas(context, 'PATCH', '采购可空字段显式 null 与空串清空', path,
|
trace(clearedResult.response), OPTIONS)
|
||||||
{ version: Number(omittedSummary?.version), productSpecification: null, productUrl: '', purchaseWarehouseId: null,
|
const forbidden = await context.request('PATCH', path,
|
||||||
purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: '', estimatedArrivalAt: null, remark: null })
|
{ version: Number(clearedSummary?.version), domesticTrackingNo: 'FORBIDDEN' })
|
||||||
const clearedSummary = summary(clearedResult.data)
|
context.assert(MODULE, '通用编辑拒绝旧履约字段', forbidden,
|
||||||
context.assert(MODULE, '采购三态第三态显式清空全部生效', clearedResult.response,
|
forbidden?.http === 400 || (forbidden?.http === 200 && codeOf(forbidden) === 'A0400'), trace(forbidden), OPTIONS)
|
||||||
clearedSummary?.productSpecification == null && detailValue(clearedResult.data, 'productUrl') == null
|
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), remark: 'STALE-DO-NOT-APPLY' })
|
||||||
&& 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' })
|
|
||||||
expectedConflict(context, '采购旧 version 返回 A0409', stale, true)
|
expectedConflict(context, '采购旧 version 返回 A0409', stale, true)
|
||||||
const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path)
|
const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path)
|
||||||
const afterConflictSummary = summary(afterConflict.data)
|
const afterConflictSummary = afterConflict.data?.customerView?.order
|
||||||
context.assert(MODULE, '采购旧 version 冲突未造成部分更新', afterConflict.response,
|
context.assert(MODULE, '采购拒绝请求不改变商品与版本', afterConflict.response,
|
||||||
afterConflictSummary?.productSpecification == null
|
afterConflictSummary?.productSpecification === before?.productSpecification
|
||||||
|
&& afterConflict.data?.internalRemark == null
|
||||||
&& Number(afterConflictSummary?.version) === Number(clearedSummary?.version),
|
&& 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', '驳回本轮采购订单并进入合法终态',
|
const rejected = await overseas(context, 'POST', '驳回本轮采购订单并进入合法终态',
|
||||||
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
|
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' })
|
const reviewHashConflict = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||||
expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict)
|
expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict)
|
||||||
const reverseReview = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { version: Number(rejectedSummary?.version), decision: 'APPROVE',
|
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' })
|
remark: 'REVERSE-DO-NOT-APPLY' })
|
||||||
expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview)
|
expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview)
|
||||||
|
|
||||||
const final = await overseas(context, 'GET', '采购最终状态与流水回查', path)
|
const final = await overseas(context, 'GET', '采购最终状态与流水回查', path)
|
||||||
const finalSummary = summary(final.data)
|
const finalSummary = final.data?.customerView?.order
|
||||||
context.assert(MODULE, '采购 marker 资源最终保持合法终态', final.response,
|
context.assert(MODULE, '采购 marker 资源最终保持合法终态', final.response,
|
||||||
finalSummary?.status === 'REJECTED' && Number(finalSummary?.version) === Number(rejectedSummary?.version),
|
finalSummary?.status === 'REJECTED' && Number(finalSummary?.version) === Number(rejectedSummary?.version),
|
||||||
`status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS)
|
`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)}`,
|
const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}`,
|
||||||
{ customerId: resource.customerId })
|
{ customerId: resource.customerId })
|
||||||
const current = await overseas(context, 'GET', '采购最终处置前回查本轮订单', path)
|
const current = await overseas(context, 'GET', '采购最终处置前回查本轮订单', path)
|
||||||
const currentSummary = summary(current.data)
|
const currentSummary = current.data?.customerView?.order
|
||||||
if (['REJECTED', 'APPROVED'].includes(currentSummary?.status)) {
|
if (['REJECTED', 'CANCELLED'].includes(currentSummary?.status)) {
|
||||||
resource.finalStatus = currentSummary.status; resource.version = Number(currentSummary.version); resource.retained = false
|
resource.finalStatus = currentSummary.status; resource.version = Number(currentSummary.version); resource.retained = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -555,7 +529,7 @@ async function finalizePendingPurchase(context, resource) {
|
|||||||
remark: `${context.marker}-prerequisite-cleanup` })
|
remark: `${context.marker}-prerequisite-cleanup` })
|
||||||
const rejectedSummary = summary(rejected.data)
|
const rejectedSummary = summary(rejected.data)
|
||||||
const verified = await overseas(context, 'GET', '回查未完成采购订单最终状态', path)
|
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'
|
const passed = rejectedSummary?.status === 'REJECTED' && finalSummary?.status === 'REJECTED'
|
||||||
&& Number(finalSummary?.version) === Number(rejectedSummary?.version)
|
&& Number(finalSummary?.version) === Number(rejectedSummary?.version)
|
||||||
context.assert(MODULE, '采购本轮测试数据最终合法驳回', verified.response, passed,
|
context.assert(MODULE, '采购本轮测试数据最终合法驳回', verified.response, passed,
|
||||||
@@ -567,8 +541,9 @@ async function finalizePendingPurchase(context, resource) {
|
|||||||
|
|
||||||
export async function run(context) {
|
export async function run(context) {
|
||||||
let prerequisite = null
|
let prerequisite = null
|
||||||
|
const session = {}
|
||||||
try {
|
try {
|
||||||
prerequisite = await prerequisites(context)
|
prerequisite = await prerequisites(context, session)
|
||||||
if (!prerequisite) return
|
if (!prerequisite) return
|
||||||
await procurementFlow(context, prerequisite)
|
await procurementFlow(context, prerequisite)
|
||||||
const customerId = id(prerequisite.activeCustomer.id)
|
const customerId = id(prerequisite.activeCustomer.id)
|
||||||
@@ -576,7 +551,16 @@ export async function run(context) {
|
|||||||
await looseboardFlow(context, customerId)
|
await looseboardFlow(context, customerId)
|
||||||
} finally {
|
} finally {
|
||||||
const registry = context.createdResources.phase4OverseasBusiness
|
const registry = context.createdResources.phase4OverseasBusiness
|
||||||
await finalizePendingPurchase(context, registry?.purchase)
|
try {
|
||||||
await retirePartnerWarehouse(context, registry?.partnerWarehouse)
|
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,无法确认会话退出')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -55,6 +55,7 @@ function fixture() {
|
|||||||
}
|
}
|
||||||
if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/finance-fee-sources?')) return response([])
|
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 (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 key = headers['Idempotency-Key']
|
||||||
const hash = JSON.stringify(body)
|
const hash = JSON.stringify(body)
|
||||||
const existing = commands.get(`CREATE:${key}`)
|
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)
|
]) 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()
|
const context = fixture()
|
||||||
await run(context)
|
await run(context)
|
||||||
const registry = context.createdResources.phase4OverseasFinanceCrossRegion
|
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([
|
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'
|
const warehouseCreate = context.calls.find((call) => call.method === 'POST'
|
||||||
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources'
|
&& 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'
|
const creates = context.calls.filter((call) => call.method === 'POST'
|
||||||
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources')
|
&& 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.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.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).every((step) => step.passed))
|
||||||
assert.ok(context.steps.find((step) => step.name === '统一Gateway不公开内部事件运维接口')?.passed)
|
assert.ok(context.steps.find((step) => step.name === '统一Gateway不公开内部事件运维接口')?.passed)
|
||||||
|
|
||||||
assert.deepEqual(context.steps.filter((step) => !step.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].deploymentBlocked, false)
|
||||||
assert.equal(context.scenarios[0].success, true)
|
assert.equal(context.scenarios[0].success, true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -222,17 +222,22 @@ export async function run(context) {
|
|||||||
const fixtures = await resolveFixtures(context)
|
const fixtures = await resolveFixtures(context)
|
||||||
const definitions = [
|
const definitions = [
|
||||||
{ label: '海外入仓', businessType: 'OVERSEAS_INBOUND', sourceType: 'OVERSEAS_INBOUND_FEE', fixture: fixtures.inbound },
|
{ 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: 'US_TRUCK', sourceType: 'OVERSEAS_TRUCK_FEE', fixture: fixtures.truck },
|
||||||
{ label: '本土散板', businessType: 'LOCAL_LOOSEBOARD', sourceType: 'OVERSEAS_LOOSEBOARD_FEE', fixture: fixtures.looseboard },
|
{ label: '本土散板', businessType: 'LOCAL_LOOSEBOARD', sourceType: 'OVERSEAS_LOOSEBOARD_FEE', fixture: fixtures.looseboard },
|
||||||
{ label: '海外仓服务', businessType: 'WAREHOUSE_SERVICE', sourceType: 'OVERSEAS_WAREHOUSE_FEE', fixture: fixtures.warehouse },
|
{ 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 = []
|
const sources = []
|
||||||
for (const [index, definition] of definitions.entries()) {
|
for (const [index, definition] of definitions.entries()) {
|
||||||
const source = await createApprove(context, definition, definition.fixture, index)
|
const source = await createApprove(context, definition, definition.fixture, index)
|
||||||
if (source) sources.push(source)
|
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)
|
`created=${sources.length}, expected=${definitions.length}`, OPTIONS)
|
||||||
const warehouseSource = sources.find((item) => item.definition.businessType === 'WAREHOUSE_SERVICE')
|
const warehouseSource = sources.find((item) => item.definition.businessType === 'WAREHOUSE_SERVICE')
|
||||||
context.assert(MODULE, '仓储服务费用仅关联客户和仓库', null,
|
context.assert(MODULE, '仓储服务费用仅关联客户和仓库', null,
|
||||||
|
|||||||
+7
-5
@@ -21,10 +21,7 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
|
|||||||
'creditExpiresOn',
|
'creditExpiresOn',
|
||||||
'/accounts/${encodeURIComponent(customerId)}/adjustments',
|
'/accounts/${encodeURIComponent(customerId)}/adjustments',
|
||||||
"const CLIENT_FINANCE = '/api/client/finance/v1'",
|
"const CLIENT_FINANCE = '/api/client/finance/v1'",
|
||||||
"['VOUCHER', 'RECEIPT']",
|
|
||||||
"walletStatus === 'FROZEN'",
|
"walletStatus === 'FROZEN'",
|
||||||
"walletStatus === 'SETTLED'",
|
|
||||||
"['FREEZE_PENDING', 'FROZEN']",
|
|
||||||
"['FREEZE', 'CONSUMPTION', 'UNFREEZE']",
|
"['FREEZE', 'CONSUMPTION', 'UNFREEZE']",
|
||||||
'item.sourceNo === businessNo',
|
'item.sourceNo === businessNo',
|
||||||
"'RELEASED'",
|
"'RELEASED'",
|
||||||
@@ -44,8 +41,13 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
|
|||||||
"deliveryMethod: 'TRUCK'",
|
"deliveryMethod: 'TRUCK'",
|
||||||
]) assert.ok(source.includes(token), token)
|
]) 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, /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.match(source, /verifyProcurementSample\(procurementContext/)
|
||||||
assert.doesNotMatch(source, /voucherFileId:\s*Number\(/)
|
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, /customerOrderNo:[^\n]+overseasWarehouseId:/)
|
||||||
assert.doesNotMatch(source, /internal\/finance|jdbc|mysql|process\.env|https?:\/\//i)
|
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 MODULE = 'phase4-overseas-wallet-and-pallet-cost'
|
||||||
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
|
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
|
||||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||||
@@ -47,9 +50,9 @@ function assert(context, name, response, passed, evidence = '') {
|
|||||||
async function merchantLogin(context, fixture) {
|
async function merchantLogin(context, fixture) {
|
||||||
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
||||||
const auth = success(response) ? response.body.data : null
|
const auth = success(response) ? response.body.data : null
|
||||||
assert(context, '客户账号同时取得国内和海外短时Token', response,
|
assert(context, '客户账号取得统一会话与退出凭据', response,
|
||||||
Boolean(auth?.accessToken) && Boolean(auth?.accessToken),
|
Boolean(auth?.accessToken) && Boolean(auth?.refreshToken),
|
||||||
`customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.accessToken)}`)
|
`customerId=${fixture.tenantId}, session=${Boolean(auth?.accessToken)}, refresh=${Boolean(auth?.refreshToken)}`)
|
||||||
return auth
|
return auth
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,79 +99,46 @@ async function prepareWallet(context, customerId) {
|
|||||||
return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null
|
return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function procurementFlow(context, fixture, token) {
|
async function procurementFlow(context, fixture, token, warehouseId) {
|
||||||
const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements'
|
if (!/^[A-Za-z0-9_-]{1,80}$/.test(context.marker)) throw new Error('采购验收批次不适合作为独立证据标识')
|
||||||
const payload = {
|
const directory = new URL('../../reports/procurement-wallet-checkpoints/', import.meta.url)
|
||||||
customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`,
|
await mkdir(directory, { recursive: true })
|
||||||
productSpecification: 'full acceptance wallet', productUrl: null, purchaseQuantity: 2,
|
const output = new URL(`${context.marker}.json`, directory)
|
||||||
budgetUnitPrice: 1, currency: 'USD', receivingAddress: 'Los Angeles',
|
const procurementContext = Object.create(context)
|
||||||
contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker,
|
let initialized = false
|
||||||
idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255),
|
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, {})
|
await procurementContext.checkpoint()
|
||||||
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'}`)
|
|
||||||
|
|
||||||
const customerId = id(fixture.tenantId)
|
const customerId = id(fixture.tenantId)
|
||||||
const approved = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), {
|
const upload = async (orderNo, category) => {
|
||||||
version: Number(detail.summary.version), decision: 'APPROVE',
|
const proof = context.createdResources.procurementSample
|
||||||
idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker,
|
const entry = { category, orderNo, status: 'SENDING' }
|
||||||
})
|
proof.uploads ??= []; proof.uploads.push(entry); await procurementContext.checkpoint()
|
||||||
assert(context, '平台审核通过钱包采购单', approved,
|
let response
|
||||||
success(approved) && approved.body.data?.summary?.status === 'APPROVED',
|
try {
|
||||||
`status=${approved.body?.data?.summary?.status || 'missing'}`)
|
response = await context.requestMultipart('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
|
||||||
if (!success(approved)) return null
|
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
|
||||||
|
} catch (error) { entry.status = 'UNKNOWN_RESULT'; await procurementContext.checkpoint(); throw error }
|
||||||
const files = []
|
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 })
|
||||||
for (const category of ['VOUCHER', 'RECEIPT']) {
|
await procurementContext.checkpoint()
|
||||||
const uploaded = await context.requestMultipart('POST',
|
assert(context, `采购受控上传${category}`, response, success(response) && Boolean(response.body.data?.fileId))
|
||||||
query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
|
if (!success(response) || !response.body.data?.fileId) throw new Error('采购凭证上传未确认成功,不自动重试')
|
||||||
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
|
return response.body.data
|
||||||
assert(context, `采购单上传${category}文件`, uploaded,
|
}
|
||||||
success(uploaded) && id(uploaded.body.data?.fileId) !== '' && uploaded.body.data?.category === category,
|
try {
|
||||||
`fileId=${id(uploaded.body?.data?.fileId) || 'missing'}`)
|
const proof = await verifyProcurementSample(procurementContext, { marker: context.marker, customerId,
|
||||||
if (success(uploaded)) files.push(uploaded.body.data)
|
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) {
|
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) {
|
async function verifyClientWallet(context, token, fixture, wallet, procurement, relabels) {
|
||||||
const account = await context.request('GET', `${CLIENT_FINANCE}/account`, undefined, token)
|
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,
|
assert(context, '客户工作台余额与冻结结算结果一致', account,
|
||||||
success(account) && money(account.body.data?.actualBalance) === expectedActual
|
success(account) && money(account.body.data?.actualBalance) === expectedActual
|
||||||
&& money(account.body.data?.frozenBalance) === money(wallet.before.frozenBalance)
|
&& 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'}`)
|
`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 ledger = await context.request('GET', `${CLIENT_FINANCE}/account/ledger?page=1&size=100`, undefined, token)
|
||||||
const entries = rows(ledger?.body?.data)
|
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,
|
assert(context, '客户账本包含充值、冻结、结算和释放明细', ledger,
|
||||||
success(ledger) && entries.some((item) => item.changeType === 'RECHARGE')
|
success(ledger) && entries.some((item) => item.changeType === 'RECHARGE')
|
||||||
&& businessNos.every((businessNo) => entries.some((item) => item.sourceNo === businessNo))
|
&& 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
|
if (!fixture?.tenantId || !warehouseId || completedFacts.length === 0) return
|
||||||
|
|
||||||
const auth = await merchantLogin(context, fixture)
|
const auth = await merchantLogin(context, fixture)
|
||||||
|
if (!auth?.accessToken || !auth?.refreshToken) return
|
||||||
|
try {
|
||||||
const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null
|
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 settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
|
||||||
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
|
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
|
||||||
const relabels = [settledRelabel, releasedRelabel].filter(Boolean)
|
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),
|
context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed),
|
||||||
deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed),
|
deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed),
|
||||||
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
|
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
Reference in New Issue
Block a user