diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java index 012d99fc3..e16c34162 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java @@ -56,6 +56,7 @@ public record BusinessFeeSourceChangedV1( String reviewStatus, String publicationStatus, String sourceStatus, - String sourceHash + String sourceHash, + WalletSettlementReference walletSettlement ) { } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java index f786e58c1..914384f8e 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java @@ -15,7 +15,9 @@ public record WalletCommandCompletedV1( BigDecimal amount, String errorCode, String errorMessage, - Instant completedAt + Instant completedAt, + String walletHoldId, + String walletLedgerId ) { public static final int VERSION = 1; } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java index 8c3514ec4..7fd36de91 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java @@ -3,7 +3,13 @@ package com.baiyue.common.event; import java.math.BigDecimal; import java.time.Instant; -/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */ +/** + * 海外业务向 Finance 发起的客户资金指令。 + * FREEZE 的 amount 为预留金额;SETTLE 全额结算;RELEASE 释放原冻结。 + * SETTLE_ACTUAL 的 amount 为最终正额 CNY 实扣,原子补差或释放余款,原冻结金额保留。 + * 实际零元使用 RELEASE,不发送零元结算。结算和释放按原业务冻结身份关联。 + * REFUND 使用独立业务退款号并携带 originalLedgerId,关联原成功消费,不创建充值。 + */ public record WalletCommandRequestedV1( String operation, long customerId, @@ -12,7 +18,8 @@ public record WalletCommandRequestedV1( BigDecimal amount, String remark, String idempotencyKey, - Instant requestedAt + Instant requestedAt, + String originalLedgerId ) { public static final int VERSION = 1; } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java new file mode 100644 index 000000000..71fe1fd86 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java @@ -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) { } diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 881d90f8e..5eec1d2a4 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,19 @@ # 单测试环境数据库空库初始化 +`migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。 + +`migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。 + +`migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql` 保存费用链修正目标、原汇率及确认前净实扣,并关联逐笔正差额/退款费用。同步进入 Lastleg 源基线与海外统一空库基线,不新增资金账本,不重算或回填旧消费;只有匹配版本能够发送本次费用链请求。本文件存在不代表已执行。 + +采购实物与运输结构分别见 `migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql` 和 `V18__procurement_transport_facts.sql`:V17 保存分次实收、差异、装箱及两段整批交寄七表;V18 保存面单版本、逐箱贴单、预估核对和一对一换包装四表,并补齐采购附件的 LABEL/PURCHASE/BUDGET/EXCEPTION 分类约束。源基线及海外统一空库基线同步,节点权限统一维护于采购 V5 权限脚本。File 同时发布对应文件类型约束;不补写历史收货、装箱或发运事实,不代表已执行或已验收。 + +采购购买节点结构使用 `migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql`,新增单批履约事实、节点幂等审计及预算授权历史三表。独立购买/取消权限使用 `migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql`,源种子为 IAM `procurement-permissions.sql`,同步进入国内统一空库基线;只给现有平台管理员角色默认授权,不新增岗位。上述结构与权限必须随采购节点匹配版本发布,不补造旧订单购买事实,不代表已执行或已验收。 + +`migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql` 为采购费用增加用途、确认版本、原币汇率、原冻结及实际流水引用,并建立 `ll_procurement_wallet_attempt` 保存每次资金请求及最终结果。零元费用可以落核定事实但不生成资金命令;字段和约束与源基线、海外统一空库基线同步。执行前核对新增列、索引和表均未存在,先发布准备完整匹配版本;不回填旧费用、不释放或重扣测试存量。该脚本仅完成费用结构,后续履约表按相应变更单独核对。 + +`migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql` 为采购已实扣费用来源新增 `wallet_settlement` JSON 引用,保存 Finance 原资金命令、冻结及消费流水身份。源基线与海外统一空库基线同步维护;指定测试环境执行前核对列不存在,不回填历史费用,不重新收费。需与 Finance 资金回执、费用关联及 Lastleg 采购生产端的匹配版本一起发布;本文件存在不表示已经执行。 + `reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接并取得对这两张表的具体重置授权;不得作为自动启动脚本或用于生产。结构必须与匹配的 Lastleg 新版一同发布,失败立即停止,不对旧数据回填。权限从 IAM 种子核对;本文件存在不表示已执行。 `migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_unicode_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。 @@ -15,7 +29,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 159 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 181 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -37,7 +51,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/157`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/181`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql b/deploy/sql/migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql new file mode 100644 index 000000000..43e496c1b --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql @@ -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; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql new file mode 100644 index 000000000..544590859 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql @@ -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; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql new file mode 100644 index 000000000..1fcc56763 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql @@ -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='采购费用资金命令尝试与不可变结果'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql new file mode 100644 index 000000000..61e7bf813 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql @@ -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='采购货款预算授权核验历史'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql new file mode 100644 index 000000000..084f7b810 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql @@ -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='采购整批交寄逐箱事实'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V18__procurement_transport_facts.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V18__procurement_transport_facts.sql new file mode 100644 index 000000000..cef0f7d88 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V18__procurement_transport_facts.sql @@ -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')); diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql new file mode 100644 index 000000000..a90a59396 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql @@ -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='采购费用目标更正与逐笔资金事实关联'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql new file mode 100644 index 000000000..1014956d7 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql @@ -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='异常关闭逐来源剩余货物最终去向,不产生新发运'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql new file mode 100644 index 000000000..1183a6083 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql @@ -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')); diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 65f30529d..860c8170b 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -466,6 +466,8 @@ CREATE TABLE sys_audit_outbox ( -- 服务 schema:baiyue_order CREATE DATABASE IF NOT EXISTS baiyue_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + + USE baiyue_order; CREATE TABLE ord_customer_address ( @@ -8284,4 +8286,33 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; +USE baiyue_iam; +-- 采购节点动作独立授权;不新建岗位,不以通用编辑权限代替购买与取消。 +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5117103,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:buy','采购购买及货款核定',NULL,'POST',803,'ACTIVE',1,0), + (5117104,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:cancel','购买前取消采购订单',NULL,'POST',804,'ACTIVE',1,0), + (5117105,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-overseas','采购海外收货',NULL,'POST',805,'ACTIVE',1,0), + (5117106,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-domestic','采购国内收货',NULL,'POST',806,'ACTIVE',1,0), + (5117107,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:pack','采购装箱与确认',NULL,'POST',807,'ACTIVE',1,0), + (5117108,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:manage','采购差异处置与事实更正',NULL,'POST',808,'ACTIVE',1,0), + (5117109,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:label','采购面单维护',NULL,'POST',809,'ACTIVE',1,0), + (5117110,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:charge','采购国内核费',NULL,'POST',810,'ACTIVE',1,0), + (5117111,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:finance-adjust','采购财务调整',NULL,'POST',811,'ACTIVE',1,0), + (5117112,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:estimate','采购预估运费核对',NULL,'POST',812,'ACTIVE',1,0), + (5117113,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:apply-label','采购逐箱贴单',NULL,'POST',813,'ACTIVE',1,0), + (5117114,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-overseas','采购海外整批交寄',NULL,'POST',814,'ACTIVE',1,0), + (5117115,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-customer','采购国内寄客户',NULL,'POST',815,'ACTIVE',1,0); +INSERT INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN + ('platform:overseas-business:purchase:buy','platform:overseas-business:purchase:cancel', + 'platform:overseas-business:purchase:receive-overseas','platform:overseas-business:purchase:receive-domestic', + 'platform:overseas-business:purchase:pack','platform:overseas-business:purchase:manage', + 'platform:overseas-business:purchase:label','platform:overseas-business:purchase:charge','platform:overseas-business:purchase:finance-adjust', + 'platform:overseas-business:purchase:estimate','platform:overseas-business:purchase:apply-label', + 'platform:overseas-business:purchase:ship-overseas','platform:overseas-business:purchase:ship-customer') +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND r.status='ACTIVE' AND r.deleted=0; + SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4d87ae203..744b33f45 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -805,6 +805,7 @@ CREATE TABLE ll_finance_fee_source ( paid_at datetime(3) DEFAULT NULL COMMENT '直接已付时间', payment_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付方式', payment_reference_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付流水号', + wallet_settlement json DEFAULT NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用', payment_voucher_file_ids json DEFAULT NULL COMMENT '直接已付凭证文件ID快照', business_date date NOT NULL COMMENT '费用业务日期', currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY', @@ -1740,6 +1741,24 @@ CREATE TABLE ll_outbound_pda_task ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务'; CREATE TABLE ll_overseas_procurement_fee ( + purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定', + confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作', + fee_version INT NULL COMMENT '同用途费用确认版本', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本', + determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用', + original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD', + original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额', + exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照', + exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份', + business_date DATE NULL COMMENT '实际费用业务日期', + wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录', + wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用', + settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', + source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', + parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + request_hash CHAR(64) NULL COMMENT '确认请求摘要', id bigint NOT NULL COMMENT '主键ID', order_id bigint NOT NULL COMMENT '采购订单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', @@ -1748,8 +1767,8 @@ CREATE TABLE ll_overseas_procurement_fee ( fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称', amount decimal(20,2) NOT NULL COMMENT 'CNY金额', currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', - voucher_file_id bigint NOT NULL COMMENT '凭证文件ID', - voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '凭证文件名', + voucher_file_id bigint NULL COMMENT '凭证文件ID', + voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NULL COMMENT '凭证文件名', remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明', wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态', wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', @@ -1758,10 +1777,12 @@ CREATE TABLE ll_overseas_procurement_fee ( created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), + UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), PRIMARY KEY (id), UNIQUE KEY uk_ll_procurement_fee_no (fee_no), KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at), - CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount > 0)), + CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount >= 0)), CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期'; @@ -1769,7 +1790,7 @@ CREATE TABLE ll_overseas_procurement_file ( id bigint NOT NULL COMMENT '主键ID', order_id bigint NOT NULL COMMENT '采购订单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', - category varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', + category varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id bigint NOT NULL COMMENT 'File服务文件ID', file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名快照', content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '媒体类型', @@ -1779,7 +1800,7 @@ CREATE TABLE ll_overseas_procurement_file ( PRIMARY KEY (id), UNIQUE KEY uk_ll_procurement_file (order_id,file_id), KEY idx_ll_procurement_file_order (customer_id,order_id,category,created_at), - CONSTRAINT chk_ll_procurement_file_category CHECK ((category in (_utf8mb4'RECEIPT',_utf8mb4'VOUCHER'))) + CONSTRAINT chk_ll_procurement_file_category CHECK (category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证'; CREATE TABLE ll_overseas_procurement_order ( @@ -1799,12 +1820,7 @@ CREATE TABLE ll_overseas_procurement_order ( contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话', purchase_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库名称', - outbound_details json DEFAULT NULL COMMENT '出库明细', inbound_quantity bigint NOT NULL DEFAULT '0' COMMENT '采购单据累计入库数量', - domestic_carrier varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '境内承运商', - domestic_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '境内物流单号', - overseas_carrier varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外承运商', - overseas_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外物流单号', estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', @@ -3625,4 +3641,244 @@ CREATE TABLE ll_return_carton_count_session ( UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 161 AS initialized_tables; +CREATE TABLE ll_procurement_wallet_attempt ( + id BIGINT NOT NULL COMMENT '请求尝试ID', + order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID', + attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号', + operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', + business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号', + amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空', + command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用', + status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', + result_hash CHAR(64) NULL COMMENT '原业务回执摘要', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用', + error_code VARCHAR(64) NULL COMMENT '业务失败码', + error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', + completed_at DATETIME(3) NULL COMMENT '最终结果时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id), + UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), + KEY idx_procurement_attempt_order(customer_id,order_id,status), + CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; + +-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。 +CREATE TABLE ll_procurement_flow ( + order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + purchased_at DATETIME(3) NULL COMMENT '真实购买时间', + purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品', + purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格', + purchased_quantity BIGINT NULL COMMENT '实际购买件数', + purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额', + purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD', + purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用', + purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明', + purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功', + actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID', + actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照', + actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照', + overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间', + packing_version INT NULL COMMENT '当前确认装箱清单版本', + overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间', + domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间', + charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间', + customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间', + domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人', + domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话', + domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址', + cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间', + closed_at DATETIME(3) NULL COMMENT '异常关闭时间', + control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), + CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0), + CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; + +CREATE TABLE ll_procurement_command ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作', + idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', + operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据', + detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), + KEY idx_procurement_command_customer(customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; + +CREATE TABLE ll_procurement_budget_authorization ( + id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种', + authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种', + voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据', + reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', + PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), + CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0), + CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; + +-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。 +CREATE TABLE ll_procurement_receipt ( + id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓', + good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量', + received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实', + supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), + CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0), + CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; + +CREATE TABLE ll_procurement_disposition ( + id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源', + receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', + quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实', + voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind), + CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; + +CREATE TABLE ll_procurement_manifest ( + id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), + CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; + +CREATE TABLE ll_procurement_carton ( + id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', + carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米', + width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米', + gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id), + CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; + +CREATE TABLE ll_procurement_carton_source ( + carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', + PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; + +CREATE TABLE ll_procurement_shipment ( + id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', + manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间', + address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), + CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; + +CREATE TABLE ll_procurement_shipment_item ( + shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数', + carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照', + label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', + PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; + +USE baiyue_lastleg; +-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。 +CREATE TABLE ll_procurement_label_set ( + id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本', + manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要', + sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照', + created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version), + CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; + +CREATE TABLE ll_procurement_label_item ( + label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', + file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始', + carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', + applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人', + PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; + +CREATE TABLE ll_procurement_estimate_review ( + id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本', + fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员', + reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; + +CREATE TABLE ll_procurement_repack ( + id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', + length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米', + height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克', + voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据', + created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id), + CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; + +USE baiyue_lastleg; +-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 +CREATE TABLE ll_procurement_fee_correction ( + id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根', + idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要', + target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种', + exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份', + target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', + difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', + voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据', + created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), + KEY idx_procurement_correction_root(order_id,root_fee_id,id), + CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; + +CREATE TABLE ll_procurement_fee_correction_item ( + correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', + PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; + +USE baiyue_lastleg; +-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 +CREATE TABLE ll_procurement_control_note ( + id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', + target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约', + voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明', + client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', + original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间', + resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; + +CREATE TABLE ll_procurement_closure ( + id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证', + financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', + reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; + +CREATE TABLE ll_procurement_closure_item ( + id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', + source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空', + quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', + destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明', + PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id), + CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 181 AS initialized_tables; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 7bc39d2e8..d88862760 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -22,6 +22,10 @@ enum BusinessFileCategory { INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_LABEL(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_PURCHASE(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_BUDGET(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_EXCEPTION(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/test/java/com/baiyue/file/service/ProcurementFilePolicyTest.java b/file/src/test/java/com/baiyue/file/service/ProcurementFilePolicyTest.java new file mode 100644 index 000000000..04c7b8864 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ProcurementFilePolicyTest.java @@ -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","fake".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(); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 9049e00da..1312e6dc0 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -130,6 +130,14 @@ public class BusinessFeeSourceEventService { } positive(value.sourceFeeId(), "sourceFeeId"); positive(value.customerId(), "customerId"); + if (value.walletSettlement() != null) { + if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用"); + positive(value.walletSettlement().holdId(), "walletSettlement.holdId"); + positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId"); + if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用"); + } else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) { + throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池"); + } if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) { if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType())) throw error("FIN-EVT-012", "海外柜费来源不一致"); @@ -160,7 +168,7 @@ public class BusinessFeeSourceEventService { value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(), value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(), value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(), - value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null); + value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null, value.walletSettlement()); String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); } @@ -189,7 +197,40 @@ public class BusinessFeeSourceEventService { private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) { if (!"RECEIVABLE".equals(source.direction())) return; - jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND h.amount=f.amount", + if (source.walletSettlement() != null) { + var reference = source.walletSettlement(); + long customerId = positive(source.customerId(), "customerId"); + long holdId = positive(reference.holdId(), "holdId"); + long ledgerId = positive(reference.ledgerId(), "ledgerId"); + List> 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 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); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java index 6a1d3475e..970f1c44c 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java @@ -300,6 +300,113 @@ public class FinanceWalletService { return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false); } + /** Settles one estimate without releasing its protection before checking any shortfall. */ + @Transactional + public WalletHold settleActual(long customerId, String rawBusinessType, String rawBusinessNo, + String idempotencyKey, BigDecimal actualAmount, String remark) { + requirePositive(customerId, "客户"); + BigDecimal amount = money(actualAmount); + String businessType = businessType(rawBusinessType); + String businessNo = businessNo(rawBusinessNo); + String operation = "WALLET_HOLD_SETTLE_ACTUAL"; + String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType, + "businessNo", businessNo, "amount", amount, "remark", String.valueOf(remark))); + Long replay = reserve(customerId, 0, operation, idempotencyKey, requestHash); + if (replay != null) return requiredHold(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + WalletHold hold = findHold(customerId, businessType, businessNo, true); + if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在"); + if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理"); + WalletAccount after = settleActualAccount(before, hold, amount); + updateAccount(customerId, after, before.version(), 0); + WalletLedgerEntry ledger = insertLedger(customerId, "CONSUMPTION", amount.negate(), after, + businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", remark); + // The hold retains the original estimate; its action ledger records the actual charge. + int updated = jdbc.update("UPDATE fin_wallet_hold SET status='SETTLED',action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'", + ledger.ledgerId(), hold.holdId()); + if (updated != 1) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理"); + audit(customerId, "WALLET_HOLD", hold.holdId(), "SETTLE_ACTUAL", remark, before, after, 0, "业务系统"); + complete(customerId, 0, operation, idempotencyKey, hold.holdId()); + return requiredHold(hold.holdId(), customerId); + } + + /** Refunds a successful consumption; recharge allocation rules apply without creating a recharge. */ + @Transactional + public WalletLedgerEntry refundConsumption(long customerId, long originalLedgerId, String refundNo, + String idempotencyKey, BigDecimal refundAmount, + long operatorId, String operatorName, String remark) { + requirePositive(customerId, "客户"); + requirePositive(originalLedgerId, "原扣款流水"); + String reference = businessNo(refundNo); + BigDecimal amount = money(refundAmount); + if (!StringUtils.hasText(remark)) throw bad("退款原因不能为空"); + String requestHash = hash(Map.of("customerId", customerId, "originalLedgerId", originalLedgerId, + "refundNo", reference, "amount", amount, "remark", remark)); + Long replay = reserve(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, requestHash); + if (replay != null) return requiredLedger(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + List 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 existing = jdbc.query( + "SELECT * FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND source_no=? FOR UPDATE", + (rs, n) -> ledgerRow(rs), customerId, reference); + if (!existing.isEmpty()) { + WalletLedgerEntry refunded = existing.get(0); + if (refunded.amount().compareTo(amount) != 0 || !original.businessNo().equals(refunded.referenceNo())) { + throw error("FIN-IDEMPOTENCY-CONFLICT", "同一退款单对应的原扣款或金额不一致"); + } + complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, refunded.ledgerId()); + return refunded; + } + // Locking reads see refunds committed while this transaction waited for the account lock, + // even under MySQL REPEATABLE READ after the idempotency lookup established a snapshot. + BigDecimal refunded = jdbc.queryForList( + "SELECT amount FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND reference_no=? FOR UPDATE", + BigDecimal.class, customerId, original.businessNo()).stream().reduce(BigDecimal.ZERO, BigDecimal::add); + if (refunded.add(amount).compareTo(original.amount().negate()) > 0) { + throw error("FIN-WALLET-REFUND-EXCEEDED", "退款金额超过原扣款剩余可退金额"); + } + WalletAccount after = rechargeAccount(before, amount); + updateAccount(customerId, after, before.version(), operatorId); + WalletLedgerEntry result = insertLedger(customerId, "REFUND", amount, after, + "BUSINESS_REFUND", reference, LocalDate.now(), null, original.businessNo(), + operatorId, operatorName, remark); + audit(customerId, "WALLET_LEDGER", originalLedgerId, "REFUND", remark, before, after, operatorId, operatorName); + complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, result.ledgerId()); + return result; + } + + WalletAccount settleActualAccount(WalletAccount before, WalletHold hold, BigDecimal actualAmount) { + BigDecimal amount = money(actualAmount); + BigDecimal extra = amount.subtract(hold.amount()).max(BigDecimal.ZERO); + if (extra.signum() > 0 && before.availableBalance().compareTo(extra) < 0) { + throw error("FIN-WALLET-INSUFFICIENT", "实际费用补差所需的可用余额和授信额度不足"); + } + BigDecimal actual = before.actualBalance(); + BigDecimal creditUsed = before.creditUsed(); + if (before.creditActive()) { + BigDecimal reservedCharge = amount.min(hold.amount()); + BigDecimal cashCharge = reservedCharge.min(hold.actualFrozenAmount()); + BigDecimal freeCash = actual.subtract(before.frozenBalance().subtract(before.creditFrozen())) + .max(BigDecimal.ZERO); + cashCharge = cashCharge.add(extra.min(freeCash)); + actual = actual.subtract(cashCharge); + creditUsed = creditUsed.add(amount.subtract(cashCharge)); + } else { + // Previously reserved credit still settles after expiry, without granting new credit. + actual = actual.subtract(amount); + } + return accountState(actual, before.frozenBalance().subtract(hold.amount()), before.creditLimit(), + creditUsed, before.creditFrozen().subtract(hold.creditFrozenAmount()), + before.creditExpiresOn(), before.version()); + } + private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo, String idempotencyKey, WalletHoldActionRequest request, boolean settle) { requirePositive(customerId, "客户"); diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java index e42137930..64a930dc5 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -29,9 +29,10 @@ import java.util.Set; @Service public class WalletCommandEventService { - private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE"); + private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "SETTLE_ACTUAL", "RELEASE", "REFUND"); private static final Set FINAL_REJECTIONS = Set.of( - "FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400"); + "FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400", + "FIN-WALLET-REFUND-EXCEEDED", "FIN-WALLET-404"); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceWalletService walletService; @@ -56,10 +57,9 @@ public class WalletCommandEventService { if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); // Wallet methods join this transaction: money, the Inbox outcome and // the result Outbox commit together, or all roll back. - WalletHold hold = execute(command); - if (hold == null) throw new IllegalStateException("资金操作未返回处理结果"); + ExecutionResult execution = execute(command); finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED", - hold.status(), hold.amount(), null, null)); + execution.status(), execution.amount(), null, null, execution.holdId(), execution.ledgerId())); return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); }); } catch (FinanceEventException exception) { @@ -70,7 +70,7 @@ public class WalletCommandEventService { requireLockedIdentity(envelope); if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED", - null, command.amount(), exception.code(), exception.getMessage())); + null, command.amount(), exception.code(), exception.getMessage(), null, null)); return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); }); } @@ -86,21 +86,33 @@ public class WalletCommandEventService { } } - private WalletHold execute(WalletCommandRequestedV1 command) { + private record ExecutionResult(String status, BigDecimal amount, String holdId, String ledgerId) { } + + private ExecutionResult execute(WalletCommandRequestedV1 command) { + if ("REFUND".equals(command.operation())) { + var refund = walletService.refundConsumption(command.customerId(), Long.parseLong(command.originalLedgerId()), + command.businessNo(), command.idempotencyKey(), command.amount(), 0, "业务系统", command.remark()); + return new ExecutionResult("REFUNDED", refund.amount(), null, String.valueOf(refund.ledgerId())); + } WalletHold hold = switch (command.operation()) { case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(), new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark())); case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(), command.idempotencyKey(), new WalletHoldActionRequest(command.remark())); + case "SETTLE_ACTUAL" -> walletService.settleActual(command.customerId(), command.businessType(), command.businessNo(), + command.idempotencyKey(), command.amount(), command.remark()); case "RELEASE" -> walletService.release(command.customerId(), command.businessType(), command.businessNo(), command.idempotencyKey(), new WalletHoldActionRequest(command.remark())); default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效"); }; - if ("SETTLE".equals(command.operation())) { - jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=h.amount", + if (hold == null) throw new IllegalStateException("资金操作未返回处理结果"); + if (Set.of("SETTLE", "SETTLE_ACTUAL").contains(command.operation())) { + jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id AND l.change_type='CONSUMPTION' SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=-l.amount", hold.holdId()); } - return hold; + Long ledgerId = "FREEZE".equals(command.operation()) ? hold.freezeLedgerId() : hold.actionLedgerId(); + return new ExecutionResult(hold.status(), "SETTLE_ACTUAL".equals(command.operation()) ? command.amount() : hold.amount(), + String.valueOf(hold.holdId()), ledgerId == null ? null : String.valueOf(ledgerId)); } private WalletCommandRequestedV1 validate(EventEnvelope envelope) { @@ -118,14 +130,22 @@ public class WalletCommandEventService { String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT); if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType()) || blank(value.businessNo()) || blank(value.idempotencyKey()) - || ("FREEZE".equals(operation) && (value.amount() == null || value.amount().signum() <= 0))) { + || (Set.of("FREEZE", "SETTLE_ACTUAL", "REFUND").contains(operation) + && (value.amount() == null || value.amount().signum() <= 0 || value.amount().scale() > 2))) { throw new FinanceEventException("FIN-EVT-001", "资金指令字段无效"); } + if ("REFUND".equals(operation)) { + if (blank(value.originalLedgerId()) || Long.parseLong(value.originalLedgerId()) <= 0 || blank(value.remark())) { + throw new FinanceEventException("FIN-EVT-001", "退款必须关联原扣款并填写原因"); + } + } else if (value.originalLedgerId() != null) { + throw new FinanceEventException("FIN-EVT-001", "非退款指令不能指定原扣款"); + } if (!String.valueOf(value.customerId()).equals(envelope.tenantId())) { throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致"); } return new WalletCommandRequestedV1(operation, value.customerId(), value.businessType(), value.businessNo(), - value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt()); + value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt(), value.originalLedgerId()); } catch (FinanceEventException exception) { throw exception; } catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); } } @@ -159,9 +179,10 @@ public class WalletCommandEventService { return count != null && count > 0; } private WalletCommandCompletedV1 completed(String eventId, WalletCommandRequestedV1 command, String result, - String holdStatus, BigDecimal amount, String errorCode, String errorMessage) { + String holdStatus, BigDecimal amount, String errorCode, String errorMessage, + String holdId, String ledgerId) { return new WalletCommandCompletedV1(eventId, command.operation(), command.customerId(), command.businessType(), - command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now()); + command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now(), holdId, ledgerId); } private String json(Object value) { try { return objectMapper.writeValueAsString(value); } diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index 7e6fd2a2e..3d69bc90a 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -45,11 +45,11 @@ class BusinessFeeSourceEventServiceTest { when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"), eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"), - eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of()); + eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(List.of()); when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"), - eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0); + eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L))).thenReturn(0); when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), - eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L); + eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L); assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); @@ -65,21 +65,21 @@ class BusinessFeeSourceEventServiceTest { private EventEnvelope eventWithoutInbound() { BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG", - "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", - "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + "OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null, null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE, - "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null); + "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null, null); String sourceHash = EventPayloadHash.sha256( EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned))); BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG", - "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", - "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + "OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null, null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE, - "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash); + "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash, null); JsonNode node = json.valueToTree(payload); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701", @@ -237,4 +237,53 @@ class BusinessFeeSourceEventServiceTest { verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class)); } + + @Test + void procurementWithoutConsumptionReferenceNeverEntersReceivablePool() { + EventEnvelope event = procurementEvent(false); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("必须引用成功实扣"); + noFinancialWrites(); + } + + @Test + void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() { + EventEnvelope event = procurementEvent(true); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L); + when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L), + eq(10001L), eq("PROCUREMENT_FEE"), eq("PO-US-001"))).thenReturn(List.of(Map.of( + "amount", new BigDecimal("-120.00"), "result_json", """ + {"result":"SUCCEEDED","operation":"SETTLE_ACTUAL","commandEventId":"command-1", + "walletHoldId":"9","walletLedgerId":"72","customerId":10001, + "businessType":"PROCUREMENT_FEE","businessNo":"PO-US-001","amount":120.00} + """))); + service.consume(event); + verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L)); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), any(Object[].class)); + } + + @Test + void incorrectOrCrossCustomerConsumptionIsRejectedInsteadOfBecomingAvailable() { + EventEnvelope event = procurementEvent(true); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("不匹配"); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class)); + } + + private EventEnvelope procurementEvent(boolean reference) { + EventEnvelope base = eventWithoutInbound(); + ObjectNode payload = (ObjectNode) base.payload().deepCopy(); + payload.put("sourceType", "OVERSEAS_PROCUREMENT_FEE"); + payload.put("businessType", "PROCUREMENT_FEE"); + if (reference) { + payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9").put("ledgerId", "72"); + } + payload.putNull("action"); payload.putNull("sourceHash"); + payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + payload.put("action", "PUBLISHED"); + return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(), + base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } } diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java index c0425eeae..b7711d577 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java @@ -101,6 +101,38 @@ class WalletCommandEventServiceTest { verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); } + @Test + void actualSettlementReportsActualChargeInsteadOfOriginalEstimate() throws Exception { + EventEnvelope event = envelope("SETTLE_ACTUAL", 31L, new BigDecimal("60.00")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId()))) + .thenReturn(List.of(event.payloadHash())); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + when(wallet.settleActual(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"), + eq("freeze-procurement-1"), argThat((BigDecimal amount) -> amount.compareTo(new BigDecimal("60.00")) == 0), any())).thenReturn( + new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("100.00"), + new BigDecimal("100.00"), BigDecimal.ZERO, "SETTLED", 71L, 72L, null, null, null)); + service.consume(event); + var resultJson = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"), + resultJson.capture(), eq(event.eventId())); + JsonNode result = mapper.readTree(resultJson.getValue()); + assertThat(result.path("amount").decimalValue()).isEqualByComparingTo("60.00"); + assertThat(result.path("holdStatus").asText()).isEqualTo("SETTLED"); + assertThat(result.path("walletHoldId").asText()).isEqualTo("9"); + assertThat(result.path("walletLedgerId").asText()).isEqualTo("72"); + verify(jdbc).update(contains("f.amount=-l.amount"), eq(9L)); + verify(wallet, never()).release(anyLong(), anyString(), anyString(), anyString(), any()); + } + + @Test + void actualSettlementRejectsZeroAndMissingAmountsBeforeOpeningTransaction() { + for (BigDecimal amount : new BigDecimal[]{null, BigDecimal.ZERO, new BigDecimal("0.001")}) { + assertThatThrownBy(() -> service.consume(envelope("SETTLE_ACTUAL", 31L, amount))) + .isInstanceOf(FinanceEventException.class); + } + verifyNoInteractions(jdbc, wallet, transactions); + } + @Test void losingInboxInsertMustRecheckThePersistedHash() { EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00")); @@ -133,9 +165,44 @@ class WalletCommandEventServiceTest { } private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) { + return envelope(operation, customerId, amount, null); + } + + @Test + void refundRequiresOriginalConsumptionBeforeAnyWalletCall() { + assertThatThrownBy(() -> service.consume(envelope("REFUND", 31L, BigDecimal.TEN))) + .isInstanceOf(FinanceEventException.class); + verifyNoInteractions(jdbc, wallet); + } + + @Test + void refundHasItsOwnLedgerReferenceAndNeverPretendsToSettleAHold() throws Exception { + EventEnvelope event = envelope("REFUND", 31L, BigDecimal.TEN, "72"); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId()))) + .thenReturn(List.of(event.payloadHash())); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + var refunded = new com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry(73L, "AC-TEST", "REFUND", + BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, null, "BUSINESS_REFUND", "PO-1", java.time.LocalDate.now(), null, + "AC-ORIGINAL", "业务系统", "采购快递费", null); + when(wallet.refundConsumption(eq(31L), eq(72L), eq("PO-1"), eq("freeze-procurement-1"), + any(), eq(0L), anyString(), anyString())).thenReturn(refunded); + service.consume(event); + var capture = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"), + capture.capture(), eq(event.eventId())); + JsonNode result = mapper.readTree(capture.getValue()); + assertThat(result.path("walletLedgerId").asText()).isEqualTo("73"); + assertThat(result.path("walletHoldId").isNull()).isTrue(); + assertThat(result.path("holdStatus").asText()).isEqualTo("REFUNDED"); + verify(wallet, never()).settle(anyLong(), anyString(), anyString(), anyString(), any()); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class)); + } + + private EventEnvelope envelope(String operation, long customerId, BigDecimal amount, String originalLedgerId) { WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId, "OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费", - "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z")); + "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"), originalLedgerId); JsonNode payload = mapper.valueToTree(command); return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java index 9d27000cc..402447670 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java @@ -190,7 +190,7 @@ class WalletCommandTransactionTest { private EventEnvelope event(String id, String amount) { var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", - new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z")); + new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z"), null); var payload = mapper.valueToTree(command); return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, Instant.now(), "31", "WALLET_HOLD", "PO-1", 1, diff --git a/finance/src/test/java/com/baiyue/finance/service/ProcurementActualSettlementTest.java b/finance/src/test/java/com/baiyue/finance/service/ProcurementActualSettlementTest.java new file mode 100644 index 000000000..ebe8d3817 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/ProcurementActualSettlementTest.java @@ -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); } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/ProcurementSettlementTransactionTest.java b/finance/src/test/java/com/baiyue/finance/service/ProcurementSettlementTransactionTest.java new file mode 100644 index 000000000..d70870e39 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/ProcurementSettlementTransactionTest.java @@ -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 one = executor.submit(() -> attempt(start, "one")); + Future 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 one = executor.submit(() -> refundAttempt(start, original.actionLedgerId(), "RF-A")); + Future 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(); + } +} diff --git a/iam/src/main/resources/db/procurement-permissions.sql b/iam/src/main/resources/db/procurement-permissions.sql new file mode 100644 index 000000000..43e496c1b --- /dev/null +++ b/iam/src/main/resources/db/procurement-permissions.sql @@ -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; diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 332e16471..96083fde7 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -146,7 +146,7 @@ - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 -- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购编辑可维护入库数量和采购出库明细,出库量为明细数量汇总,审核通过且有入库量后按 `PENDING_OUTBOUND`、`PARTIALLY_OUTBOUND`、`OUTBOUNDED` 派生展示状态。采购出库只记录采购单据流转,不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 +- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java index 048206462..2c77fb221 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java @@ -12,6 +12,15 @@ import java.util.List; @FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient", url = "${baiyue.clients.domestic-warehouse.url:}") public interface DomesticWarehouseDirectoryClient { + @GetMapping("/internal/warehouses/shared-overseas") + ApiResponse> receivingWarehouses(@RequestHeader("X-Internal-Service-Token") String internalToken); + + @GetMapping("/internal/warehouses/shared-overseas/{warehouseId}") + ApiResponse receivingWarehouse(@PathVariable("warehouseId") long warehouseId, + @RequestHeader("X-Internal-Service-Token") String internalToken); + + record ReceivingWarehouse(Long warehouseId, String warehouseCode, String warehouseName, String countryCode, String status) { } + @GetMapping("/internal/warehouses/0/{warehouseId}/active") ApiResponse activeWarehouse( @PathVariable("warehouseId") long warehouseId, @@ -20,7 +29,7 @@ public interface DomesticWarehouseDirectoryClient { record Detail(Warehouse warehouse, List
addresses) { } record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, - String warehouseType, String countryCode, String status) { } + String warehouseType, String countryCode, String status, String contactName, String contactPhone) { } record Address(String addressType, String countryCode, String stateProvince, String city, String district, String postalCode, String addressLine1, String addressLine2) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java index da1af7cf5..ebae08ff7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java @@ -43,6 +43,8 @@ public class OverseasFeeSource { private String paymentMethod; private String paymentReferenceNo; @TableField(typeHandler = JacksonTypeHandler.class) private List paymentVoucherFileIds; + @TableField(typeHandler = JacksonTypeHandler.class) + private com.baiyue.common.event.WalletSettlementReference walletSettlement; private LocalDate businessDate; private String currency; private BigDecimal amount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java index db82e9b59..f107f08ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java @@ -9,5 +9,6 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface OverseasFeeSourceMapper extends BaseMapper { @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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index 98296a317..286079b6a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -69,7 +69,7 @@ public class OverseasFeeOutboxService { value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(), value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(), value.getReviewStatus(), - "PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash); + "PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash, value.getWalletSettlement()); } private static String text(Long value) { return value == null ? null : String.valueOf(value); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java index b6a4ab9ff..7f3df7aee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -79,14 +79,20 @@ public class OverseasFeeSourceService { if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送"); } + private static void requireNonProcurement(String sourceType,String businessType) { + if("OVERSEAS_PROCUREMENT_FEE".equals(sourceType) || "OVERSEAS_PROCUREMENT".equals(businessType) || "PROCUREMENT_FEE".equals(businessType)) + throw conflict("采购费用必须通过采购履约或财务节点确认,实扣成功后自动关联来源"); + } + @Transactional public OverseasFeeSource create(String operatorId, String key, SaveRequest request) { + requireNonProcurement(request.sourceType(),request.businessType()); OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request); if (replay != null) return current(replay.getSourceFeeId()); validate(request); businessGuard.requireOwned(request); long sourceFeeId = IdWorker.getId(); - OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request); + OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, null); mapper.insert(value); command(operatorId, "CREATE", key, request, sourceFeeId, 1); return value; @@ -108,11 +114,45 @@ public class OverseasFeeSourceService { return value; } + private record SettledRequest(SaveRequest fee, OverseasFeeCurrencyConverter.Conversion conversion, + com.baiyue.common.event.WalletSettlementReference settlement) { } + + /** Internal business entry: money has already succeeded and its confirmed FX must not be recalculated. */ + @Transactional + public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request, + OverseasFeeCurrencyConverter.Conversion conversion, + com.baiyue.common.event.WalletSettlementReference settlement) { + if (!"RECEIVABLE".equals(request.direction()) || conversion == null || settlement == null + || text(settlement.commandEventId()) == null || text(settlement.holdId()) == null || text(settlement.ledgerId()) == null + || !request.currency().equals(conversion.originalCurrency()) + || request.amount().compareTo(conversion.originalAmount()) != 0 + || conversion.settlementAmount().signum() <= 0 || conversion.exchangeRate().signum() <= 0 + || !request.businessDate().toString().substring(0, 7).equals(conversion.exchangeRateMonth())) { + throw bad("已扣费用的资金引用或确认汇率快照不完整"); + } + var confirmed = new SettledRequest(request, conversion, settlement); + OverseasFeeCommand replay = replay(operatorId, "PUBLISH_SETTLED", key, confirmed); + if (replay != null) return current(replay.getSourceFeeId()); + validate(request); + businessGuard.requireOwned(request); + long sourceFeeId = IdWorker.getId(); + OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion); + value.setWalletSettlement(settlement); + value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED"); + value.setReviewerId(operatorId); value.setReviewedAt(now()); + value.setReviewReason("已核定业务费用扣款成功后发布"); + mapper.insert(value); + outbox.publish(value, "PUBLISHED"); + command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1); + return value; + } + @Transactional public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) { OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource value = lock(sourceFeeId); + requireNonProcurement(value.getSourceType(),value.getBusinessType()); if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus())) throw conflict("只有草稿或已驳回费用可以提交"); value.setReviewStatus("PENDING"); value.setReviewReason(null); @@ -126,6 +166,7 @@ public class OverseasFeeSourceService { OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource value = lock(sourceFeeId); + requireNonProcurement(value.getSourceType(),value.getBusinessType()); if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())) throw conflict("只有待审核的有效费用可以审核"); boolean approved = "APPROVE".equals(request.decision()); @@ -143,6 +184,8 @@ public class OverseasFeeSourceService { OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource old = lock(sourceFeeId); + if (old.getWalletSettlement() != null) throw conflict("已扣费用只能通过关联的财务差额或退款调整"); + requireNonProcurement(old.getSourceType(),old.getBusinessType()); if (cancellation.requested(old)) throw conflict("该费用已申请作废,请先处理财务结果"); if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus()) || old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整"); @@ -157,7 +200,7 @@ public class OverseasFeeSourceService { || !Objects.equals(old.getContainerNo(), text(request.fee().containerNo()))) throw conflict("调整不得改变费用来源或业务归属"); old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old); - OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee()); + OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee(), null); next.setReviewStatus("PENDING"); mapper.insert(next); command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion()); @@ -169,6 +212,8 @@ public class OverseasFeeSourceService { OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource value = lock(sourceFeeId); + requireNonProcurement(value.getSourceType(),value.getBusinessType()); + if (value.getWalletSettlement() != null) throw conflict("已扣费用不能作废,请使用关联退款"); if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()) || value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废"); if (cancellation.requested(value)) return cancellation.describe(List.of(value)).get(0); @@ -183,7 +228,8 @@ public class OverseasFeeSourceService { return value; } - private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request) { + private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request, + OverseasFeeCurrencyConverter.Conversion confirmedConversion) { OverseasFeeSource value = new OverseasFeeSource(); value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version); value.setCustomerId(request.customerId().trim()); value.setCustomerName(text(request.customerName())); @@ -196,7 +242,8 @@ public class OverseasFeeSourceService { value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo())); value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); - OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate()); + OverseasFeeCurrencyConverter.Conversion conversion = confirmedConversion != null ? confirmedConversion + : currencyConverter.convert(request.currency(), request.amount(), request.businessDate()); value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount()); value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount()); value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java index e674063fd..2ab349d27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -24,13 +24,15 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") public class MerchantOverseasProcurementController { @Resource private OverseasProcurementService service; + @Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')") - public ApiResponse create( + public ApiResponse create( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody OverseasProcurementRequests.Create request) { - return ApiResponse.success(service.create(customer(user), operator(user), request)); + var created=service.create(customer(user),operator(user),request); + return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo())); } @GetMapping @@ -42,9 +44,9 @@ public class MerchantOverseasProcurementController { @GetMapping("/{orderNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')") - public ApiResponse detail( + public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { - return ApiResponse.success(service.detail(customer(user), orderNo)); + return ApiResponse.success(queryService.customer(customer(user),orderNo)); } private String customer(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java index a444577ea..80af7c406 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -32,6 +32,7 @@ import java.util.List; @RequestMapping("/api/platform/v1/last-leg/overseas-procurements") public class PlatformOverseasProcurementController { @Resource private OverseasProcurementService service; + @Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") @@ -67,11 +68,11 @@ public class PlatformOverseasProcurementController { @GetMapping("/{orderNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") @Operation(summary = "Get an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST) - public ApiResponse detail( + public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo) { requirePlatform(user); - return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + return ApiResponse.success(queryService.platform(requiredCustomer(customerId), orderNo,user.permissions())); } @PostMapping("/{orderNo}/review") @@ -94,25 +95,6 @@ public class PlatformOverseasProcurementController { return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); } - @PostMapping(value = "/{orderNo}/files/{category}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')") - public ApiResponse 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 createFee( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, - @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.CreateFee request) { - requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); - return ApiResponse.success(service.createFee(requiredCustomer(customerId), orderNo, operator(user), request)); - } - private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java new file mode 100644 index 000000000..1234d76b9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java @@ -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 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> 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 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 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 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 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 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 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 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 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)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java new file mode 100644 index 000000000..fb203bbe2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java @@ -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 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 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 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 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 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)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java new file mode 100644 index 000000000..bd18fac9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java @@ -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 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 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 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()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java new file mode 100644 index 000000000..b7ec2fd45 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java @@ -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 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 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 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 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 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 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()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java new file mode 100644 index 000000000..355e94602 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java @@ -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 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 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 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 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 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 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 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 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 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 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 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)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java new file mode 100644 index 000000000..0110c7f85 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java @@ -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 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 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 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 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 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 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 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,"运输段与操作入口不一致");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index 453e9a0bd..0b9b3f9d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -22,11 +22,11 @@ public final class OverseasProcurementRequests { @Size(max = 500) String productSpecification, @Size(max = 2000) String productUrl, @NotNull @Positive Long purchaseQuantity, - @DecimalMin(value = "0.00") BigDecimal budgetUnitPrice, - @Size(min = 3, max = 3) String currency, - @Size(max = 1000) String receivingAddress, + @NotNull @DecimalMin(value = "0.00") @jakarta.validation.constraints.Digits(integer=16,fraction=2) BigDecimal budgetUnitPrice, + @NotBlank @jakarta.validation.constraints.Pattern(regexp="CNY|USD") String currency, + @NotBlank @Size(max = 1000) String receivingAddress, @NotBlank @Size(max = 128) String contactName, - @NotBlank @Size(max = 64) String contactPhone, + @NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone, @Size(max = 1000) String remark, @NotBlank @Size(max = 255) String idempotencyKey) {} @@ -51,38 +51,15 @@ public final class OverseasProcurementRequests { @NotNull @Min(0) Long version, @NotBlank String decision, @NotBlank @Size(max = 255) String idempotencyKey, - @Size(max = 1000) String remark) {} - - public record CreateFee( - @NotBlank @Size(max = 32) String feeItemCode, - @NotBlank @Size(max = 128) String feeItemName, - @NotNull @DecimalMin(value = "0.01") BigDecimal amount, - @NotNull @Positive Long voucherFileId, - @Size(max = 500) String remark) {} + @Size(max = 1000) String remark, + @Positive Long purchaseWarehouseId) {} public record Update( @NotNull @Min(0) Long version, - @Size(max = 128) String purchaseCategory, - @Size(max = 255) String productName, - JsonNode productSpecification, - JsonNode productUrl, - @Min(0) Long inboundQuantity, - JsonNode purchaseWarehouseId, - JsonNode purchaseWarehouseName, - JsonNode domesticCarrier, - JsonNode domesticTrackingNo, - JsonNode overseasCarrier, - JsonNode overseasTrackingNo, - JsonNode estimatedArrivalAt, - JsonNode remark, - JsonNode outboundDetails) { - public Update(Long version, String purchaseCategory, String productName, JsonNode productSpecification, - JsonNode productUrl, Long inboundQuantity, JsonNode purchaseWarehouseId, JsonNode purchaseWarehouseName, - JsonNode domesticCarrier, JsonNode domesticTrackingNo, JsonNode overseasCarrier, JsonNode overseasTrackingNo, - JsonNode estimatedArrivalAt, JsonNode remark) { - this(version, purchaseCategory, productName, productSpecification, productUrl, inboundQuantity, - purchaseWarehouseId, purchaseWarehouseName, domesticCarrier, domesticTrackingNo, overseasCarrier, - overseasTrackingNo, estimatedArrivalAt, remark, null); + JsonNode remark) { + @com.fasterxml.jackson.annotation.JsonAnySetter + public void rejectUnknownField(String name, JsonNode value) { + throw new IllegalArgumentException("采购通用编辑仅支持备注,业务字段请使用对应履约操作"); } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java index 9a48f9b4a..685c764d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -7,14 +7,11 @@ import java.util.List; public final class OverseasProcurementResponses { private OverseasProcurementResponses() {} - public record OutboundDetail(String id, long quantity, String carrier, String trackingNo, LocalDateTime shippedAt, String remark) {} - public record Summary( Long id, String orderNo, String customerId, String customerName, String purchaseCategory, String productName, String productSpecification, long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, String purchaseWarehouseId, String purchaseWarehouseName, - long inboundQuantity, long outboundQuantity, long inventoryQuantity, String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} public record CustomerOption(String customerId, String customerName) {} @@ -38,10 +35,9 @@ public final class OverseasProcurementResponses { public record Detail( Summary summary, String productUrl, String receivingAddress, String contactName, String contactPhone, - String domesticCarrier, String domesticTrackingNo, - String overseasCarrier, String overseasTrackingNo, LocalDateTime estimatedArrivalAt, String remark, String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt, - List histories, List outboundDetails, + List histories, List files, List fees) {} + } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java new file mode 100644 index 000000000..ca91847fa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java new file mode 100644 index 000000000..1c7a9dd4b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java new file mode 100644 index 000000000..87dc28d75 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java new file mode 100644 index 000000000..4f84eb561 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java new file mode 100644 index 000000000..6ca574939 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java index 89a289562..ac50d6b2f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; @Data @TableName("ll_overseas_procurement_fee") @@ -14,6 +15,24 @@ public class OverseasProcurementFee { private Long orderId; private String customerId; private String feeNo; + private String purpose; + private String confirmationOrigin; + private Integer feeVersion; + private Boolean active; + private String determination; + private String originalCurrency; + private BigDecimal originalAmount; + private BigDecimal exchangeRate; + private String exchangeRateMonth; + private LocalDate businessDate; + private Long walletBusinessId; + private String walletBusinessNo; + private String walletHoldId; + private String walletLedgerId; + private String settledCommandEventId; + private Long sourceFeeId; + private Long parentFeeId; + private String requestHash; private String feeItemCode; private String feeItemName; private BigDecimal amount; @@ -22,7 +41,9 @@ public class OverseasProcurementFee { private String voucherFileName; private String remark; private String walletStatus; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private String walletErrorCode; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private String walletErrorMessage; private String walletCommandEventId; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index df14cd381..4cf2bce87 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -30,11 +30,6 @@ public class OverseasProcurementOrder { @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName; private Long inboundQuantity; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String outboundDetails; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo; @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; private String status; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java index b054a93f7..53953819c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java @@ -37,7 +37,7 @@ public class HttpProcurementFileGateway implements ProcurementFileGateway { @Override public String getFilename() { return file.getOriginalFilename(); } }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(file.getContentType())); } catch (Exception exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购文件无法读取", exception); } - String businessType = "RECEIPT".equals(category) ? "OVERSEAS_PROCUREMENT_RECEIPT" : "OVERSEAS_PROCUREMENT_VOUCHER"; + String businessType = ProcurementFileCategory.parse(category).businessType(); Envelope response = client.post().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", Long.parseLong(customerId), businessType, orderId) .header("X-Internal-Service-Token", token).header("X-Operator-Id", String.valueOf(operatorId)) @@ -51,5 +51,16 @@ public class HttpProcurementFileGateway implements ProcurementFileGateway { return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); } private record Envelope(String code, String message, Metadata data) { } + @Override + public Download download(String customerId,long orderId,String category,long fileId) { + var response=client.get().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", + Long.parseLong(customerId),ProcurementFileCategory.parse(category).businessType(),orderId,fileId) + .header("X-Internal-Service-Token",token).retrieve() + .onStatus(HttpStatusCode::isError,(request,result)->{throw new ResponseStatusException( + result.getStatusCode().is4xxClientError()?HttpStatus.NOT_FOUND:HttpStatus.SERVICE_UNAVAILABLE,"采购文件暂不可读取");}) + .toEntity(byte[].class); + if(response.getBody()==null)throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"文件服务没有返回文件内容"); + return new Download(response.getBody(),response.getHeaders().getContentType()==null?MediaType.APPLICATION_OCTET_STREAM_VALUE:response.getHeaders().getContentType().toString()); + } private record Metadata(Long fileId, String fileName, String contentType, Long sizeBytes) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileCategory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileCategory.java new file mode 100644 index 000000000..9aa3f4000 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileCategory.java @@ -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,"采购文件用途不受支持");} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java index 07eddfe5e..2f4a51014 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java @@ -2,5 +2,7 @@ package com.baiyue.lastleg.procurement.gateway; import org.springframework.web.multipart.MultipartFile; public interface ProcurementFileGateway { UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file); + Download download(String customerId,long orderId,String category,long fileId); + record Download(byte[] content,String contentType) { } record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java index 57db8b5fe..bc2e8f7ad 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java @@ -2,4 +2,9 @@ package com.baiyue.lastleg.procurement.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { } +@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index dc93177b3..5e13eca43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; @@ -14,11 +13,6 @@ import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway; -import com.baiyue.lastleg.wallet.WalletCommandOutboxService; -import com.baiyue.lastleg.wallet.WalletResultHandler; -import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; -import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; -import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; import com.baiyue.lastleg.security.OverseasPermissions; @@ -39,7 +33,6 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -49,24 +42,24 @@ import java.util.Set; import org.springframework.web.multipart.MultipartFile; @Service -public class OverseasProcurementService implements WalletResultHandler { +public class OverseasProcurementService { @Resource private ProcurementOrderNumberGenerator orderNoGenerator; public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; - public static final String APPROVED = "APPROVED"; + public static final String WAIT_PURCHASE = "WAIT_PURCHASE"; public static final String REJECTED = "REJECTED"; - private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED); + private static final Set STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "WAIT_OVERSEAS_RECEIPT", + "OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "INTERNATIONAL_TRANSIT", + "DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED"); @Resource private OverseasProcurementOrderMapper orders; @Resource private OverseasProcurementStatusHistoryMapper histories; - @Resource private PartnerWarehouseDirectory warehouseDirectory; + @Resource private ProcurementWarehouseDirectory warehouseDirectory; @Resource private ObjectMapper objectMapper; @Resource private OverseasProcurementFileMapper procurementFiles; @Resource private OverseasProcurementFeeMapper procurementFees; @Resource private ProcurementFileGateway fileGateway; - @Resource private WalletCommandOutboxService walletCommands; - @Resource private OverseasFeeSourceService feeSources; @Transactional public OverseasProcurementResponses.Detail create(String customerId, String operatorId, @@ -117,13 +110,13 @@ public class OverseasProcurementService implements WalletResultHandler { if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200"); return orders.customerOptions(trim(keyword), limit).stream() .map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(), - Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId()))) + Objects.requireNonNullElse(trim(value.getCustomerName()), "—"))) .toList(); } public List warehouseOptions() { return warehouseDirectory.options().stream() - .map(value -> new OverseasProcurementResponses.WarehouseOption(value.warehouseId(), + .map(value -> new OverseasProcurementResponses.WarehouseOption(String.valueOf(value.warehouseId()), value.warehouseCode(), value.warehouseName(), value.countryCode())) .toList(); } @@ -159,21 +152,20 @@ public class OverseasProcurementService implements WalletResultHandler { if (order == null) throw notFound("Overseas procurement order not found"); return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(), order.getReceivingAddress(), order.getContactName(), order.getContactPhone(), - order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(), - order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(), + order.getEstimatedArrivalAt(), order.getRemark(), order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(), histories.listOrder(customerId, order.getId()).stream().map(value -> new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getChangeDetail(), value.getRemark(), - value.getCreatedAt())).toList(), outboundDetails(order), files(order), fees(order)); + value.getCreatedAt())).toList(), files(order), fees(order)); } @Transactional public OverseasProcurementResponses.FileInfo uploadFile(String customerId, String orderNo, String operatorId, String category, MultipartFile file) { OverseasProcurementOrder order = lock(customerId, orderNo); - if (!Set.of("RECEIPT", "VOUCHER").contains(category)) throw badRequest("File category must be RECEIPT or VOUCHER"); + category = com.baiyue.lastleg.procurement.gateway.ProcurementFileCategory.parse(category).name(); ProcurementFileGateway.UploadedFile uploaded = fileGateway.upload(customerId, order.getId(), Long.parseLong(operatorId), category, file); OverseasProcurementFile row = new OverseasProcurementFile(); row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); row.setCategory(category); @@ -183,54 +175,15 @@ public class OverseasProcurementService implements WalletResultHandler { return file(row); } - @Transactional - public OverseasProcurementResponses.Fee createFee(String customerId, String orderNo, String operatorId, - OverseasProcurementRequests.CreateFee request) { - OverseasProcurementOrder order = lock(customerId, orderNo); - if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can add fees"); - OverseasProcurementFile voucher = procurementFiles.selectOne(new LambdaQueryWrapper() - .eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, customerId) - .eq(OverseasProcurementFile::getCategory, "VOUCHER").eq(OverseasProcurementFile::getFileId, request.voucherFileId())); - if (voucher == null) throw badRequest("Fee voucher does not belong to this procurement order"); - LocalDateTime now = LocalDateTime.now(); - OverseasProcurementFee row = new OverseasProcurementFee(); - row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); - row.setFeeNo("PF" + row.getId()); row.setFeeItemCode(request.feeItemCode().trim().toUpperCase()); - row.setFeeItemName(request.feeItemName().trim()); row.setAmount(request.amount()); row.setCurrency("CNY"); - row.setVoucherFileId(voucher.getFileId()); row.setVoucherFileName(voucher.getFileName()); row.setRemark(trim(request.remark())); - row.setWalletStatus("FREEZE_PENDING"); row.setCreatedBy(operatorId); row.setCreatedAt(now); row.setUpdatedAt(now); - procurementFees.insert(row); - String eventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "PROCUREMENT_FEE", row.getFeeNo(), - row.getAmount(), "海外采购费用冻结:" + orderNo, 1L); - row.setWalletCommandEventId(eventId); procurementFees.updateById(row); - return fee(row); - } - - @Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); } - - @Override - @Transactional - public void handle(WalletCommandCompletedV1 result) { - OverseasProcurementFee row = procurementFees.selectOne(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getCustomerId, String.valueOf(result.customerId())) - .eq(OverseasProcurementFee::getFeeNo, result.businessNo())); - if (row == null) throw notFound("Procurement fee not found for wallet result"); - if ("SUCCEEDED".equals(result.result())) { - row.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); }); - row.setWalletErrorCode(null); row.setWalletErrorMessage(null); - } else { - row.setWalletStatus(result.operation() + "_FAILED"); row.setWalletErrorCode(result.errorCode()); row.setWalletErrorMessage(result.errorMessage()); - } - row.setUpdatedAt(LocalDateTime.now()); procurementFees.updateById(row); - if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) { - OverseasProcurementOrder order = orders.selectById(row.getOrderId()); - if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) { - publishFeeSource(order, row); - walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null, - "海外采购完成入库,结算费用", 2L); - row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row); - } - } + public record DownloadedFile(String fileName,String contentType,byte[] content) { } + public DownloadedFile downloadFile(String customerId,String orderNo,long fileId) { + var order=orders.findNo(customerId,orderNo); + if(order==null)throw notFound("采购订单不存在或不属于当前客户"); + var file=procurementFiles.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFile::getCustomerId,customerId).eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getFileId,fileId)); + if(file==null)throw notFound("文件未关联本采购订单"); + var content=fileGateway.download(customerId,order.getId(),file.getCategory(),fileId); + return new DownloadedFile(file.getFileName(),content.contentType(),content.content()); } private List files(OverseasProcurementOrder order) { @@ -257,8 +210,8 @@ public class OverseasProcurementService implements WalletResultHandler { public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId, OverseasProcurementRequests.Review request) { String next = switch (request.decision().trim().toUpperCase()) { - case "APPROVE", APPROVED -> APPROVED; - case "REJECT", REJECTED -> REJECTED; + case "APPROVE" -> WAIT_PURCHASE; + case "REJECT" -> REJECTED; default -> throw badRequest("Review decision must be APPROVE or REJECT"); }; OverseasProcurementOrder order = lock(customerId, orderNo); @@ -272,6 +225,12 @@ public class OverseasProcurementService implements WalletResultHandler { } if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); + if (WAIT_PURCHASE.equals(next)) { + if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择有效海外收货仓"); + var warehouse = warehouseDirectory.required(request.purchaseWarehouseId()); + order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId())); + order.setPurchaseWarehouseName(warehouse.warehouseName()); + } LocalDateTime now = LocalDateTime.now(); order.setStatus(next); order.setReviewedBy(operatorId); @@ -292,23 +251,7 @@ public class OverseasProcurementService implements WalletResultHandler { OverseasProcurementOrder order = lock(customerId, orderNo); if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); Map before = editableSnapshot(order); - if (request.inboundQuantity() != null) { - if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock"); - if (request.inboundQuantity() > order.getPurchaseQuantity()) throw badRequest("Inbound quantity cannot exceed purchase quantity"); - order.setInboundQuantity(request.inboundQuantity()); - } - setIfText(request.purchaseCategory(), order::setPurchaseCategory); - setIfText(request.productName(), order::setProductName); - patchText(request.productSpecification(), 500, "productSpecification", order::setProductSpecification); - patchText(request.productUrl(), 2000, "productUrl", order::setProductUrl); - patchWarehouse(order, request.purchaseWarehouseId(), request.purchaseWarehouseName()); - patchText(request.domesticCarrier(), 128, "domesticCarrier", order::setDomesticCarrier); - patchText(request.domesticTrackingNo(), 128, "domesticTrackingNo", order::setDomesticTrackingNo); - patchText(request.overseasCarrier(), 128, "overseasCarrier", order::setOverseasCarrier); - patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo); - patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt); patchText(request.remark(), 1000, "remark", order::setRemark); - if (request.outboundDetails() != null) order.setOutboundDetails(normalizeOutbound(request.outboundDetails(), order.getInboundQuantity())); Map after = editableSnapshot(order); if (before.equals(after)) return detail(customerId, orderNo); LocalDateTime now = LocalDateTime.now(); @@ -316,28 +259,9 @@ public class OverseasProcurementService implements WalletResultHandler { orders.updateById(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, changeDetail(before, after), null, now); - if (order.getInboundQuantity() >= order.getPurchaseQuantity()) { - for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) { - publishFeeSource(order, fee); - walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null, - "海外采购完成入库,结算费用", 2L); - fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee); - } - } return detail(customerId, orderNo); } - private void publishFeeSource(OverseasProcurementOrder order, OverseasProcurementFee fee) { - feeSources.publishApproved(StringUtils.hasText(fee.getCreatedBy()) ? fee.getCreatedBy() : "SYSTEM", "PROCUREMENT:" + fee.getFeeNo(), - new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(), - "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getId(), fee.getFeeNo(), - order.getId(), null, null, null, null, null, parseLong(order.getPurchaseWarehouseId()), - fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", null, null, null, null, - null, null, null, List.of(), LocalDate.now(), fee.getCurrency(), fee.getAmount(), - BigDecimal.ONE, "ITEM", fee.getAmount(), null, null)); - } - private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) { if (!hash.equals(order.getRequestHash())) { throw conflict("Idempotency key was reused with different procurement data"); @@ -352,16 +276,11 @@ public class OverseasProcurementService implements WalletResultHandler { } private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) { - long inbound = value(order.getInboundQuantity()); - long outbound = outboundDetails(order).stream().mapToLong(OverseasProcurementResponses.OutboundDetail::quantity).sum(); String displayStatus = order.getStatus(); - if (APPROVED.equals(order.getStatus()) && inbound > 0) { - displayStatus = outbound == 0 ? "PENDING_OUTBOUND" : outbound < inbound ? "PARTIALLY_OUTBOUND" : "OUTBOUNDED"; - } return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), - order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound, Math.max(0, inbound - outbound), + order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); } @@ -400,31 +319,6 @@ public class OverseasProcurementService implements WalletResultHandler { } } - private void patchWarehouse(OverseasProcurementOrder order, JsonNode warehouseId, JsonNode warehouseName) { - if (warehouseId == null) { - if (warehouseName != null) throw badRequest("purchaseWarehouseName cannot be changed without purchaseWarehouseId"); - return; - } - String id = nullableText(warehouseId, 128, "purchaseWarehouseId"); - if (id == null) { - order.setPurchaseWarehouseId(null); - order.setPurchaseWarehouseName(null); - return; - } - long numericId; - try { - numericId = Long.parseLong(id); - } catch (NumberFormatException exception) { - throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID"); - } - // Keep the warehouse snapshot already frozen on a historical order. A later master-data - // deactivation must not block unrelated edits such as recording outbound details. - if (Long.toString(numericId).equals(order.getPurchaseWarehouseId())) return; - var option = warehouseDirectory.required(numericId); - order.setPurchaseWarehouseId(option.warehouseId()); - order.setPurchaseWarehouseName(option.warehouseName()); - } - private static void patchText(JsonNode node, int maxLength, String field, java.util.function.Consumer setter) { if (node != null) setter.accept(nullableText(node, maxLength, field)); @@ -438,55 +332,12 @@ public class OverseasProcurementService implements WalletResultHandler { return value; } - private static void patchDateTime(JsonNode node, java.util.function.Consumer 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 editableSnapshot(OverseasProcurementOrder order) { - Map result = new LinkedHashMap<>(); - result.put("purchaseCategory", order.getPurchaseCategory()); - result.put("productName", order.getProductName()); - result.put("productSpecification", order.getProductSpecification()); - result.put("productUrl", order.getProductUrl()); - result.put("inboundQuantity", value(order.getInboundQuantity())); - result.put("purchaseWarehouseId", order.getPurchaseWarehouseId()); - result.put("purchaseWarehouseName", order.getPurchaseWarehouseName()); - result.put("domesticCarrier", order.getDomesticCarrier()); - result.put("domesticTrackingNo", order.getDomesticTrackingNo()); - result.put("overseasCarrier", order.getOverseasCarrier()); - result.put("overseasTrackingNo", order.getOverseasTrackingNo()); - result.put("estimatedArrivalAt", order.getEstimatedArrivalAt()); - result.put("remark", order.getRemark()); - result.put("outboundDetails", order.getOutboundDetails()); + Map result=new LinkedHashMap<>(); + result.put("remark",order.getRemark()); return result; } - private List 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 before, Map after) { Map changes = new LinkedHashMap<>(); before.forEach((field, oldValue) -> { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java new file mode 100644 index 000000000..a8a5df697 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java @@ -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 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 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 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(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java new file mode 100644 index 000000000..9e105c487 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java @@ -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 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 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 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()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java new file mode 100644 index 000000000..c5452b234 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java @@ -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> notes, + List remaining,List> closures,List> 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();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 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(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(); + 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 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 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 noteView(Map 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();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java new file mode 100644 index 000000000..71ad9f5f0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java @@ -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; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java new file mode 100644 index 000000000..0aeafce03 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java @@ -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() + .eq(OverseasProcurementFee::getOrderId, orderId).eq(OverseasProcurementFee::getPurpose, purpose.name()) + .eq(OverseasProcurementFee::getFeeItemCode, code).eq(OverseasProcurementFee::getActive, true)); + } + + public List all(long orderId) { + return fees.selectList(new LambdaQueryWrapper().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().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 refunds = fees.selectList(new LambdaQueryWrapper() + .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 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 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 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(); + 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> 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 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> 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 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().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() + .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() + .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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java new file mode 100644 index 000000000..a9fed57ba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java @@ -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 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 categories) { + var file=files.selectOne(new LambdaQueryWrapper().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);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java new file mode 100644 index 000000000..629272bb2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java @@ -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 sources) { } + public record Snapshot(String orderNo,long version,String status,List receipts, + List> dispositions,Integer packingVersion,String packingStatus,List 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 available=new HashMap<>(),used=new HashMap<>(); + for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) available.put(receipt.id(),receipt.goodQuantity()); + Set 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 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 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> 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 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 received(long orderId,Leg leg,Long source){return received(orderId,leg,source,false);} + private List 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 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();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java new file mode 100644 index 000000000..cc722df4b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java @@ -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,List boxes,List shipments,List fees,List exceptionSummaries) { } + public record Action(String key,String label,String permission,boolean allowed,String reason) { } + public record PlatformView(CustomerView customerView,Map purchaseFacts,ProcurementPhysicalService.Snapshot physical, + ProcurementTransportService.Snapshot transport,List fees, + List feeCorrections,ProcurementControlService.Snapshot controls, + List files,List reviewHistory, + List> operations,List 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 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=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.>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 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 actions(OverseasProcurementOrder order,Map flow,ProcurementPhysicalService.Snapshot physicalView,ProcurementTransportService.Snapshot transportView,Set 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 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 result,Set 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();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java new file mode 100644 index 000000000..afe3bdaa8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java @@ -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 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 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 shipments,List overseasCargo,List 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 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(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 result=new ArrayList<>(); + for(var row:rows) { + long id=((Number)row.get("id")).longValue(); + List 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,List ids) { + Set 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 deserialize(String value,Class 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);}} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java new file mode 100644 index 000000000..36040d990 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java @@ -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 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.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,"海外收货仓目录暂不可用,请稍后重试"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 35769be0a..9da6b4fdf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -73,6 +73,19 @@ public final class OverseasPermissions { public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; + public static final String PURCHASE_BUY = "platform:overseas-business:purchase:buy"; + public static final String PURCHASE_CANCEL = "platform:overseas-business:purchase:cancel"; + public static final String PURCHASE_RECEIVE_OVERSEAS = "platform:overseas-business:purchase:receive-overseas"; + public static final String PURCHASE_RECEIVE_DOMESTIC = "platform:overseas-business:purchase:receive-domestic"; + public static final String PURCHASE_PACK = "platform:overseas-business:purchase:pack"; + public static final String PURCHASE_ESTIMATE = "platform:overseas-business:purchase:estimate"; + public static final String PURCHASE_APPLY_LABEL = "platform:overseas-business:purchase:apply-label"; + public static final String PURCHASE_SHIP_OVERSEAS = "platform:overseas-business:purchase:ship-overseas"; + public static final String PURCHASE_SHIP_CUSTOMER = "platform:overseas-business:purchase:ship-customer"; + public static final String PURCHASE_MANAGE = "platform:overseas-business:purchase:manage"; + public static final String PURCHASE_LABEL = "platform:overseas-business:purchase:label"; + public static final String PURCHASE_CHARGE = "platform:overseas-business:purchase:charge"; + public static final String PURCHASE_FINANCE_ADJUST = "platform:overseas-business:purchase:finance-adjust"; public static final String TRUCK_LIST = "platform:overseas-business:truck:list"; public static final String TRUCK_CREATE = "platform:overseas-business:truck:create"; public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java index bf5320cdb..df578a8ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java @@ -27,12 +27,41 @@ public class WalletCommandOutboxService { public String publish(String operation, long customerId, String businessType, String businessNo, BigDecimal amount, String remark, long aggregateVersion) { - String eventKey = "WALLET_COMMAND:" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; + if ("REFUND".equals(operation)) throw new IllegalArgumentException("退款必须关联原扣款流水"); + return publishCommand(operation, customerId, businessType, businessNo, amount, remark, aggregateVersion, null); + } + + public String publishRefund(long customerId, String businessType, String refundNo, BigDecimal amount, + String originalLedgerId, String remark, long aggregateVersion) { + if (originalLedgerId == null || !originalLedgerId.matches("[1-9][0-9]*")) { + throw new IllegalArgumentException("退款原扣款流水无效"); + } + return publishCommand("REFUND", customerId, businessType, refundNo, amount, remark, aggregateVersion, originalLedgerId); + } + + private String publishCommand(String operation, long customerId, String businessType, String businessNo, + BigDecimal amount, String remark, long aggregateVersion, String originalLedgerId) { + String eventKey = "WALLET_COMMAND:" + customerId + ':' + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; LastlegEventOutbox existing = mapper.findByEventKey(eventKey); - if (existing != null) return eventId(existing.getPayloadJson()); - String idempotencyKey = businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; + if (existing != null) { + try { + EventEnvelope saved = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); + WalletCommandRequestedV1 command = objectMapper.treeToValue(saved.payload(), WalletCommandRequestedV1.class); + if (command.customerId() != customerId || !operation.equals(command.operation()) + || !businessType.equals(command.businessType()) || !businessNo.equals(command.businessNo()) + || !java.util.Objects.equals(remark, command.remark()) + || !java.util.Objects.equals(originalLedgerId, command.originalLedgerId()) + || (amount == null ? command.amount() != null : command.amount() == null || amount.compareTo(command.amount()) != 0)) { + throw new IllegalStateException("同一资金请求对应不同费用事实"); + } + return saved.eventId(); + } catch (java.io.IOException exception) { + throw new IllegalStateException("资金指令事件快照损坏", exception); + } + } + String idempotencyKey = customerId + ":" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; WalletCommandRequestedV1 value = new WalletCommandRequestedV1(operation, customerId, businessType, - businessNo, amount, remark, idempotencyKey, Instant.now()); + businessNo, amount, remark, idempotencyKey, Instant.now(), originalLedgerId); JsonNode payload = objectMapper.valueToTree(value); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, Instant.now(), @@ -49,10 +78,6 @@ public class WalletCommandOutboxService { return envelope.eventId(); } - private String eventId(String json) { - try { return objectMapper.readValue(json, EventEnvelope.class).eventId(); } - catch (Exception exception) { throw new IllegalStateException("资金指令事件快照损坏", exception); } - } private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception exception) { throw new IllegalStateException("资金指令事件序列化失败", exception); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 855d8538d..c21744461 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -885,6 +885,7 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', + wallet_settlement JSON NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用', business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY', amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD', original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1', @@ -2631,10 +2632,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', - purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', - domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', - overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', @@ -2880,23 +2879,43 @@ CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence ( CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', - category VARCHAR(16) NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', + category VARCHAR(16) NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', file_name VARCHAR(255) NOT NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '媒体类型', size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小', created_by VARCHAR(128) NULL COMMENT '上传人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_file(order_id,file_id), KEY idx_ll_procurement_file_order(customer_id,order_id,category,created_at), - CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER')) + CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION')) ) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证'; CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( + purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定', + confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作', + fee_version INT NULL COMMENT '同用途费用确认版本', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本', + determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用', + original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD', + original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额', + exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照', + exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份', + business_date DATE NULL COMMENT '实际费用业务日期', + wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录', + wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用', + settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', + source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', + parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + request_hash CHAR(64) NULL COMMENT '确认请求摘要', id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', - amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NOT NULL COMMENT '凭证文件ID', - voucher_file_name VARCHAR(255) NOT NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', + amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NULL COMMENT '凭证文件ID', + voucher_file_name VARCHAR(255) NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) NULL COMMENT '登记人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), + UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), - CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') + CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') ) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; @@ -3218,3 +3237,240 @@ CREATE TABLE IF NOT EXISTS ll_return_carton_count_session ( PRIMARY KEY(operator_id), UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; + +CREATE TABLE IF NOT EXISTS ll_procurement_wallet_attempt ( + id BIGINT NOT NULL COMMENT '请求尝试ID', + order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID', + attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号', + operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', + business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号', + amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空', + command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用', + status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', + result_hash CHAR(64) NULL COMMENT '原业务回执摘要', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用', + error_code VARCHAR(64) NULL COMMENT '业务失败码', + error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', + completed_at DATETIME(3) NULL COMMENT '最终结果时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id), + UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), + KEY idx_procurement_attempt_order(customer_id,order_id,status), + CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; + +-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。 +CREATE TABLE IF NOT EXISTS ll_procurement_flow ( + order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + purchased_at DATETIME(3) NULL COMMENT '真实购买时间', + purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品', + purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格', + purchased_quantity BIGINT NULL COMMENT '实际购买件数', + purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额', + purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD', + purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用', + purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明', + purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功', + actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID', + actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照', + actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照', + overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间', + packing_version INT NULL COMMENT '当前确认装箱清单版本', + overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间', + domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间', + charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间', + customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间', + domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人', + domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话', + domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址', + cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间', + closed_at DATETIME(3) NULL COMMENT '异常关闭时间', + control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), + CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0), + CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; + +CREATE TABLE IF NOT EXISTS ll_procurement_command ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作', + idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', + operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据', + detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), + KEY idx_procurement_command_customer(customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; + +CREATE TABLE IF NOT EXISTS ll_procurement_budget_authorization ( + id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种', + authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种', + voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据', + reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', + PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), + CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0), + CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; + +-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。 +CREATE TABLE IF NOT EXISTS ll_procurement_receipt ( + id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓', + good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量', + received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实', + supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), + CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0), + CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; + +CREATE TABLE IF NOT EXISTS ll_procurement_disposition ( + id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源', + receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', + quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实', + voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind), + CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; + +CREATE TABLE IF NOT EXISTS ll_procurement_manifest ( + id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), + CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; + +CREATE TABLE IF NOT EXISTS ll_procurement_carton ( + id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', + carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米', + width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米', + gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id), + CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; + +CREATE TABLE IF NOT EXISTS ll_procurement_carton_source ( + carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', + PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; + +CREATE TABLE IF NOT EXISTS ll_procurement_shipment ( + id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', + manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间', + address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), + CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; + +CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item ( + shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数', + carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照', + label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', + PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; + +-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。 +CREATE TABLE IF NOT EXISTS ll_procurement_label_set ( + id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本', + manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要', + sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照', + created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version), + CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; + +CREATE TABLE IF NOT EXISTS ll_procurement_label_item ( + label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', + file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始', + carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', + applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人', + PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; + +CREATE TABLE IF NOT EXISTS ll_procurement_estimate_review ( + id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本', + fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员', + reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; + +CREATE TABLE IF NOT EXISTS ll_procurement_repack ( + id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', + length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米', + height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克', + voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据', + created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id), + CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; + +-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 +CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction ( + id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根', + idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要', + target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种', + exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份', + target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', + difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', + voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据', + created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), + KEY idx_procurement_correction_root(order_id,root_fee_id,id), + CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; + +CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction_item ( + correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', + PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; + +-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 +CREATE TABLE IF NOT EXISTS ll_procurement_control_note ( + id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', + target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约', + voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明', + client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', + original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间', + resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; + +CREATE TABLE IF NOT EXISTS ll_procurement_closure ( + id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证', + financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', + reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; + +CREATE TABLE IF NOT EXISTS ll_procurement_closure_item ( + id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', + source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空', + quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', + destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明', + PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id), + CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 415c8879f..74a9e31fe 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1012,8 +1012,8 @@ paths: content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } } /api/platform/v1/last-leg/overseas-procurements/warehouse-options: get: - summary: 查询启用的海外合作采购仓候选 - description: 权限 platform:overseas-business:purchase:list;复用服务端缓存目录,浏览器不访问国内内部 API。 + summary: 查询有效公共海外收货仓候选 + description: 权限 platform:overseas-business:purchase:list;固定国内权威公共 OVERSEAS、ACTIVE、RECEIVING 仓,浏览器不访问内部 API。 security: [{ bearerAuth: [] }] responses: '200': @@ -1027,10 +1027,10 @@ paths: summary: 查询海外采购订单详情及操作流水 description: 权限 platform:overseas-business:purchase:list。 security: [{ bearerAuth: [] }] - responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + responses: { '200': { description: 后台采购聚合详情及服务端操作资格, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementPlatformResponse' } } } } } patch: summary: 编辑海外采购订单 - description: 权限 platform:overseas-business:purchase:edit;可选字段缺失表示不修改,显式 null 或空串表示清空。采购仓名称由 warehouse ID 对应候选快照决定。采购出库不属于本 API。 + description: 权限 platform:overseas-business:purchase:edit;仅编辑内部备注,字段缺失表示不修改,显式 null 或空串表示清空。商品、仓库、数量和运输字段由各履约节点维护,提交这些旧字段会被拒绝。 security: [{ bearerAuth: [] }] requestBody: required: true @@ -1246,7 +1246,7 @@ paths: description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } - responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } } + responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse' } } } }, '409': { description: 幂等冲突 } } get: summary: 客户分页查询自己的海外采购订单 description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。 @@ -1264,11 +1264,11 @@ paths: responses: { '200': { description: 当前客户采购订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementPageResponse' } } } } } /api/merchant/v1/last-leg/overseas-procurements/{orderNo}: get: - summary: 客户查询自己的海外采购订单详情及流水 + summary: 客户查询自己的采购履约、箱明细、运单及费用 description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。 security: [{ bearerAuth: [] }] parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } + responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse' } } } }, '404': { description: 当前客户下订单不存在 } } /api/merchant/v1/last-leg/us-truck-orders: get: summary: 客户分页查询自己的美国本土卡订单 @@ -3220,11 +3220,6 @@ components: - { $ref: '#/components/schemas/MerchantApiResponse' } - type: object properties: { data: { $ref: '#/components/schemas/ProcurementPage' } } - ProcurementDetailResponse: - allOf: - - { $ref: '#/components/schemas/MerchantApiResponse' } - - type: object - properties: { data: { $ref: '#/components/schemas/ProcurementDetail' } } TruckPageResponse: allOf: - { $ref: '#/components/schemas/MerchantApiResponse' } @@ -3327,7 +3322,7 @@ components: properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } } ProcurementSummary: type: object - required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, inboundQuantity, outboundQuantity, inventoryQuantity, status, version, createdAt, updatedAt] + required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, status, version, createdAt, updatedAt] properties: id: { type: string, pattern: '^[0-9]+$' } orderNo: { type: string } @@ -3341,43 +3336,10 @@ components: currency: { type: string, nullable: true } purchaseWarehouseId: { type: string, nullable: true } purchaseWarehouseName: { type: string, nullable: true } - inboundQuantity: { type: integer, format: int64 } - outboundQuantity: { type: integer, format: int64 } - inventoryQuantity: { type: integer, format: int64 } - status: { type: string, description: PENDING_REVIEW/APPROVED/REJECTED 或按审核后出库数量派生的 PENDING_OUTBOUND/PARTIALLY_OUTBOUND/OUTBOUNDED } + status: { type: string, description: 以 ProcurementStatus 中的真实履约阶段返回,不按通用出库数量推断 } version: { type: integer, format: int64 } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } - ProcurementDetail: - type: object - required: [summary, histories, outboundDetails] - properties: - summary: { $ref: '#/components/schemas/ProcurementSummary' } - productUrl: { type: string, nullable: true } - receivingAddress: { type: string, nullable: true } - contactName: { type: string, nullable: true } - contactPhone: { type: string, nullable: true } - domesticCarrier: { type: string, nullable: true } - domesticTrackingNo: { type: string, nullable: true } - overseasCarrier: { type: string, nullable: true } - overseasTrackingNo: { type: string, nullable: true } - estimatedArrivalAt: { type: string, format: date-time, nullable: true } - remark: { type: string, nullable: true } - createdBy: { type: string, nullable: true } - reviewedBy: { type: string, nullable: true } - reviewRemark: { type: string, nullable: true } - reviewedAt: { type: string, format: date-time, nullable: true } - histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } - outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } } - ProcurementOutboundDetail: - type: object - properties: - id: { type: string } - quantity: { type: integer, format: int64 } - carrier: { type: string, nullable: true } - trackingNo: { type: string, nullable: true } - shippedAt: { type: string, format: date-time, nullable: true } - remark: { type: string, nullable: true } TruckPage: allOf: - { $ref: '#/components/schemas/PageMetadata' } @@ -3720,18 +3682,20 @@ components: actualVolumeCbm: { type: number, nullable: true } status: { type: string } version: { type: integer } - ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + ProcurementStatus: { type: string, enum: [PENDING_REVIEW, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, SHIPPED_TO_CUSTOMER, REJECTED, CANCELLED, EXCEPTION_CLOSED] } ProcurementReview: type: object required: [version, decision, idempotencyKey] + description: 审核通过必须选择有效公共海外收货仓;拒绝不要求仓库。 properties: version: { type: integer, format: int64, minimum: 0 } decision: { type: string, enum: [APPROVE, REJECT] } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } remark: { type: string, nullable: true, maxLength: 1000 } + purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true } ProcurementCreate: type: object - required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey] + required: [purchaseCategory, productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey] properties: customerName: { type: string, nullable: true, maxLength: 255 } purchaseCategory: { type: string, maxLength: 128 } @@ -3739,9 +3703,9 @@ components: productSpecification: { type: string, nullable: true, maxLength: 500 } productUrl: { type: string, nullable: true, maxLength: 2000 } purchaseQuantity: { type: integer, format: int64, minimum: 1 } - budgetUnitPrice: { type: number, nullable: true, minimum: 0 } - currency: { type: string, minLength: 3, maxLength: 3, default: USD } - receivingAddress: { type: string, nullable: true, maxLength: 1000 } + budgetUnitPrice: { type: number, minimum: 0, multipleOf: 0.01, description: 客户授权的货款预算单价;零表示仅授权免费样品 } + currency: { type: string, enum: [CNY, USD] } + receivingAddress: { type: string, minLength: 1, maxLength: 1000, description: 最终客户国内收件完整地址 } contactName: { type: string, maxLength: 128 } contactPhone: { type: string, maxLength: 64 } remark: { type: string, nullable: true, maxLength: 1000 } @@ -3749,35 +3713,12 @@ components: NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 } ProcurementUpdate: type: object + additionalProperties: false + description: 只维护内部备注;商品、数量、仓库、实收和运输必须通过独立节点操作。 required: [version] properties: version: { type: integer, format: int64, minimum: 0 } - purchaseCategory: { type: string, maxLength: 128 } - productName: { type: string, maxLength: 255 } - productSpecification: { $ref: '#/components/schemas/NullableTextPatch' } - productUrl: { $ref: '#/components/schemas/NullableTextPatch' } - inboundQuantity: { type: integer, format: int64, minimum: 0 } - purchaseWarehouseId: { $ref: '#/components/schemas/NullableTextPatch' } - purchaseWarehouseName: { $ref: '#/components/schemas/NullableTextPatch' } - domesticCarrier: { $ref: '#/components/schemas/NullableTextPatch' } - domesticTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } - overseasCarrier: { $ref: '#/components/schemas/NullableTextPatch' } - overseasTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } - estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 缺失不修改;null 或空串清空 } remark: { $ref: '#/components/schemas/NullableTextPatch' } - outboundDetails: - type: array - description: 出库明细,可一次提交多条;总出库量不得超过入库量 - items: - type: object - required: [quantity] - properties: - id: { type: string } - quantity: { type: integer, format: int64, minimum: 0 } - carrier: { type: string, nullable: true, maxLength: 128 } - trackingNo: { type: string, nullable: true, maxLength: 128 } - shippedAt: { type: string, format: date-time, nullable: true } - remark: { type: string, nullable: true, maxLength: 500 } CustomerOptionResponse: type: object properties: diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml index 617b106ca..1257dc662 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -13,7 +13,7 @@ paths: responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } } post: summary: 创建海外业务费用来源草稿 - description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 + description: 采购费用仅由采购履约或财务节点实扣后自动发布,本入口拒绝采购来源或采购业务类型。客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 security: [{ bearerAuth: [] }] parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } } @@ -34,7 +34,7 @@ paths: /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: post: summary: 提交、审核、调整或作废海外业务费用来源 - description: 审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。 + description: 采购来源不接受通用提交、审核、调整或作废,须回到采购财务节点处理。审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。 security: [{ bearerAuth: [] }] parameters: - { in: path, name: sourceFeeId, required: true, schema: { type: string } } @@ -70,8 +70,8 @@ components: required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount] properties: customerId: { type: string } - sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] } - businessType: { $ref: '#/components/schemas/BusinessType' } + sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] } + businessType: { type: string, enum: [OVERSEAS_INBOUND, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] } businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } businessNo: { type: string, maxLength: 128 } containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } diff --git a/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml b/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml new file mode 100644 index 000000000..159d70577 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml @@ -0,0 +1,3505 @@ +openapi: 3.0.3 +info: + title: 采购样品履约节点 API + version: 1.0.0 + description: 客户范围、节点资格、版本与幂等由服务端校验;购买事实与资金结果分开保存。 +security: +- bearerAuth: [] +paths: + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fulfillment: + get: + summary: 读取采购履约与货款结果 + description: 权限 platform:overseas-business:purchase:list;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/purchase: + post: + summary: 登记真实购买并核定样品货款 + description: 权限 platform:overseas-business:purchase:buy;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Purchase' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/purchase/confirm: + post: + summary: 补齐授权或汇率后核定原购买 + description: 权限 platform:overseas-business:purchase:buy;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/budget-authorizations: + post: + summary: 核验客户预算上限授权 + description: 权限 platform:overseas-business:purchase:review;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Budget' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/planned-warehouse: + post: + summary: 首次实收前选择计划采购仓 + description: 权限 platform:overseas-business:purchase:review;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Warehouse' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/cancel: + post: + summary: 购买前取消采购订单 + description: 权限 platform:overseas-business:purchase:cancel;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/physical: + get: + summary: 查询采购实收与当前装箱版本 + description: 权限 platform:overseas-business:purchase:list;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-receipts: + post: + summary: 登记海外分次实收 + description: 权限 platform:overseas-business:purchase:receive-overseas;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-receipts: + post: + summary: 按原交寄箱登记国内实收 + description: 权限 platform:overseas-business:purchase:receive-domestic;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-receipts/complete: + post: + summary: 确认海外收货完成 + description: 权限 platform:overseas-business:purchase:receive-overseas;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FinishReceipt' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-receipts/complete: + post: + summary: 确认国内收货完成 + description: 权限 platform:overseas-business:purchase:receive-domestic;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FinishReceipt' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/dispositions: + post: + summary: 确认单内差异处置 + description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Disposition' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing: + post: + summary: 保存新的装箱清单版本 + description: 权限 platform:overseas-business:purchase:pack;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Packing' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing/confirm: + post: + summary: 确认整批装箱 + description: 权限 platform:overseas-business:purchase:pack;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmPacking' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing/withdraw: + post: + summary: 撤回尚未交寄的确认清单 + description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/receipts/{receiptId}/correct: + post: + summary: 保留原记录并受控更正实收 + description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: receiptId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Correction' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/dispositions/{dispositionId}/revoke: + post: + summary: 凭证支持的差异处置更正 + description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: dispositionId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RevokeDisposition' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/transport: + get: + summary: 读取两段面单及实际交寄事实 + description: 权限 platform:overseas-business:purchase:list。平台内部资料,不直接对客输出。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/labels: + post: + summary: 保存本段全批面单新版本 + description: 权限 platform:overseas-business:purchase:label。清单、文件、页码与全部可寄原箱关联;国际寄件取实际仓主档。国内改最终收件信息须有本单EXCEPTION客户确认凭证。新版本不沿用旧贴单。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Labels' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/international-estimate: + post: + summary: 核对国际预估并申请冻结或释放 + description: 权限 platform:overseas-business:purchase:estimate。仅CNY。同金额可显式沿用原FROZEN/ZERO;变价先申请释放,等待成功后以新请求确认新金额,未决不得交寄。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Estimate' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/labels/apply: + post: + summary: 逐箱登记真实贴单 + description: 权限 platform:overseas-business:purchase:apply-label。仅海外段;可以分次贴单,不能重复记录相同版本相同箱。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApplyLabels' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-repack: + post: + summary: 国内原箱一对一换包装 + description: 权限 platform:overseas-business:purchase:receive-domestic。不改变来源及件数,不拆箱合箱;使旧国内面单失效,并要求重新核对国内费用。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Repack' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-shipment: + post: + summary: 确认整批海外实际交寄 + description: 权限 platform:overseas-business:purchase:ship-overseas。leg固定OVERSEAS;全部箱、当前面单、逐箱贴单、样品实扣关联、唯一有效预估均须通过。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Ship' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/customer-shipment: + post: + summary: 确认整批国内寄客户 + description: 权限 platform:overseas-business:purchase:ship-customer。leg固定DOMESTIC;核费及全部资金完成才可交寄。每单每段最多一次,重复不同请求不能再发货。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Ship' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/files/{category}: + post: + summary: 上传采购内部附件 + description: 按类别独立授权:PURCHASE=buy;BUDGET=review;LABEL=label;EXCEPTION=manage;RECEIPT=receive-overseas或receive-domestic;VOUCHER=charge或finance-adjust。新增四类只允许PDF/JPG/PNG,最大10MiB。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: category + in: path + required: true + schema: + type: string + enum: + - RECEIPT + - VOUCHER + - LABEL + - PURCHASE + - BUDGET + - EXCEPTION + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + responses: + '200': + description: 本单附件元数据业务响应 + '403': + description: 主体或类别权限不足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/files/{fileId}/content: + get: + summary: 读取本采购单内部文件 + description: 权限 platform:overseas-business:purchase:list;校验客户、订单、分类与文件归属。附件下载,Cache-Control no-store。客户入口不开放这些内部文件。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: fileId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 文件内容 + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: 文件不属于本单 + '403': + description: 无平台采购只读权限 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/charges: + get: + summary: 读取核费与资金处理结果 + description: 权限 platform:overseas-business:purchase:list。仅平台使用,内部费用及诊断字段不得直接透传客户。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fee-corrections: + get: + summary: 读取费用链更正历史 + description: 权限 platform:overseas-business:purchase:list。不可变目标、原汇率、确认时净额及产生的逐笔差额或退款业务号。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeCorrectionsResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/charges/confirm: + post: + summary: 确认国内整单费用 + description: 权限 platform:overseas-business:purchase:charge。须国内实收完成。三项固定费用明确核定,其他不适用不产生虚构收费行;重核引用原feeId不再次扣款,不可省略已有有效实际费用。确认不等于资金成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmCharges' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/retry: + post: + summary: 重试已明确失败的费用请求 + description: 样品货款要求buy,预估要求estimate,实际国际运费/关税/国内运费/其他要求charge,调整/退款要求finance-adjust;未知结果拒绝新请求,已冻结仅重试结算。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/terminate: + post: + summary: 终止明确失败的费用版本 + description: 权限 platform:overseas-business:purchase:finance-adjust。终止旧版本重试资格;有效冻结必须释放成功后才可替代,重新核对整单费用。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/correct-target: + post: + summary: 按正确原币总额更正费用链 + description: 权限 platform:overseas-business:purchase:finance-adjust。选择原实际费用根。沿用原汇率计算正确CNY总额,以成功原扣+成功差额-成功退款求净额;正差额新扣、负差额逐笔关联退款,未决旧调整阻断。CNY差额单自身汇率为1,目标原币汇率保留在更正快照,不因分差反推原币。已寄客户不回退履约。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeCorrectionResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CorrectFeeTarget' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/additional: + post: + summary: 登记独立新发生费用 + description: 权限 platform:overseas-business:purchase:finance-adjust。仅已发生购买订单;按新费用真实业务日冻结汇率,以独立费用号发起扣款,核定入口为 FINANCIAL。保留原费用;普通核费首次确认后只引用已有版本。未交寄订单需重新核费,已交寄事实不回退。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeVersionResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AdditionalFee' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/replace-failed: + post: + summary: 重新核定已终止且未实扣的实际费用 + description: 权限 platform:overseas-business:purchase:finance-adjust。仅允许最新原实际费用已明确失败、终止重试且有效冻结已释放后替代;拒绝已实扣、未决或已有后续版本。沿用原币、业务日和汇率快照,使用新资金业务身份;货款仍核验客户预算。以更正记录关联旧版本与新实际费用,零元不发起资金请求。原购买与履约事实不回退。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeCorrectionResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CorrectFeeTarget' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/refund: + post: + summary: 关联原实扣退回客户钱包 + description: 权限 platform:overseas-business:purchase:finance-adjust。amount为正额CNY。仅本单已结算消费可退;整链有未完成调整/退款时不接受新请求,重放原请求仍返回原退款。累计金额由Finance原子封顶,不走外部支付。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeVersionResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefundFee' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/controls: + get: + summary: 读取异常、更正与剩余货物来源 + description: 权限 platform:overseas-business:purchase:list。内部记录,剩余来源用于逐项填写最终去向。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/control-notes: + post: + summary: 追加事实更正说明或阻断异常 + description: 权限 platform:overseas-business:purchase:manage。校验本单事实和EXCEPTION凭证;已交寄允许追加说明,不改原货物、运单或状态。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlNote' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/control-notes/{noteId}/resolve: + post: + summary: 有凭证解除采购阻断异常 + description: 权限 platform:overseas-business:purchase:manage。不覆盖原说明,记录处理人和证据;不改变资金结果。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: noteId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveControl' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/actual-warehouse/correct: + post: + summary: 受控更正实际收货仓 + description: 权限 platform:overseas-business:purchase:manage。未有后续箱明细时更新有效实际仓并留原快照及原实收;已有后续实物或已结束时仅追加阻断说明,不重归属货物。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CorrectWarehouse' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/exception-close: + post: + summary: 实物资金收口后异常关闭 + description: 权限 platform:overseas-business:purchase:manage。购买后逐来源核平剩余货物最终去向,并提供客户停止履约证据、财务结论及对客摘要。全部已知资金与费用来源处理结束且无阻断异常才可关闭;不退款、不生成退货或零数量发运。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExceptionClose' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/release-estimate: + post: + summary: 释放不再使用的预估冻结 + description: 权限 platform:overseas-business:purchase:finance-adjust。只作用于本单有效预估;未决或已实际结算不允许释放。供异常处理使用,不能将实际消费当冻结释放。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + Command: + type: object + required: + - version + - idempotencyKey + - reason + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + Purchase: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - productName + - quantity + - amount + - currency + - purchasedAt + - estimatedArrivalAt + - voucherFileId + properties: + productName: + type: string + minLength: 1 + maxLength: 255 + specification: + type: string + nullable: true + maxLength: 500 + quantity: + type: integer + format: int64 + minimum: 1 + amount: + type: number + minimum: 0 + multipleOf: 0.01 + currency: + type: string + enum: + - CNY + - USD + purchasedAt: + type: string + format: date-time + description: 真实购买时间,不得在未来 + estimatedArrivalAt: + type: string + format: date-time + voucherFileId: + type: string + description: 必须属于本订单的购买或费用凭证 + Budget: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - amount + - currency + - voucherFileId + properties: + amount: + type: number + minimum: 0 + multipleOf: 0.01 + description: 新授权货款总上限 + currency: + type: string + enum: + - CNY + - USD + voucherFileId: + type: string + description: 本订单客户书面或聊天确认凭证 + Warehouse: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - warehouseId + properties: + warehouseId: + type: string + description: 国内权威目录的有效公共海外收货仓引用 + Response: + type: object + properties: + code: + type: string + message: + type: string + data: + type: object + properties: + orderNo: + type: string + version: + type: integer + format: int64 + status: + type: string + purchase: + type: object + properties: + recorded: + type: boolean + confirmed: + type: boolean + description: 货款已核定,不代表已扣款 + issue: + type: string + nullable: true + feeNo: + type: string + nullable: true + walletStatus: + type: string + nullable: true + sourceLinked: + type: boolean + description: 已实扣费用成功入池关联或明确免费 + Receipt: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - leg + - receivedAt + properties: + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + warehouseId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + description: 海外段必填的实际公共收货仓,国内为空 + sourceCartonId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + description: 国内段必填的本单原国际交寄箱,海外为空 + goodQuantity: + type: integer + format: int64 + minimum: 0 + damagedQuantity: + type: integer + format: int64 + minimum: 0 + extraQuantity: + type: integer + format: int64 + minimum: 0 + receivedAt: + type: string + format: date-time + FinishReceipt: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - leg + properties: + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + Disposition: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - leg + - kind + - quantity + - decision + - voucherFileId + properties: + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + sourceCartonId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + receiptId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + kind: + type: string + enum: + - SHORTAGE + - DAMAGED + - EXTRA + quantity: + type: integer + format: int64 + minimum: 1 + decision: + type: string + enum: + - ACCEPT_SHORTAGE + - DISPOSE_DAMAGED + - RETURN_EXTRA + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + Source: + type: object + required: + - receiptId + - quantity + properties: + receiptId: + type: string + pattern: ^[1-9][0-9]*$ + quantity: + type: integer + format: int64 + minimum: 1 + Carton: + type: object + required: + - cartonNo + - lengthCm + - widthCm + - heightCm + - grossWeightKg + - sources + properties: + cartonNo: + type: string + minLength: 1 + maxLength: 64 + lengthCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + widthCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + heightCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + grossWeightKg: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.001 + sources: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Source' + Packing: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - cartons + properties: + cartons: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Carton' + ConfirmPacking: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - packingVersion + properties: + packingVersion: + type: integer + minimum: 1 + Correction: + type: object + required: + - receipt + - voucherFileId + properties: + receipt: + $ref: '#/components/schemas/Receipt' + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + RevokeDisposition: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - voucherFileId + properties: + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + PhysicalResponse: + type: object + properties: + code: + type: string + message: + type: string + data: + type: object + properties: + orderNo: + type: string + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + packingVersion: + type: integer + nullable: true + packingStatus: + type: string + nullable: true + enum: + - DRAFT + - CONFIRMED + - SUPERSEDED + receipts: + type: array + items: + type: object + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + receiptNo: + type: string + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + sourceCartonId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + goodQuantity: + type: integer + format: int64 + minimum: 0 + damagedQuantity: + type: integer + format: int64 + minimum: 0 + extraQuantity: + type: integer + format: int64 + minimum: 0 + receivedAt: + type: string + format: date-time + active: + type: boolean + dispositions: + type: array + description: 平台处置历史,active=0为已撤销记录;不直接对客输出内部依据 + items: + type: object + cartons: + type: array + items: + allOf: + - $ref: '#/components/schemas/Carton' + - type: object + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + quantity: + type: integer + format: int64 + minimum: 1 + PostalAddress: + type: object + required: + - name + - phone + - address + properties: + name: + type: string + minLength: 1 + maxLength: 128 + phone: + type: string + pattern: ^[+0-9 ()-]{5,64}$ + address: + type: string + minLength: 1 + maxLength: 1000 + Label: + type: object + required: + - cartonId + - fileId + - page + - carrier + - trackingNo + properties: + cartonId: + type: string + pattern: ^[1-9][0-9]*$ + fileId: + type: string + pattern: ^[1-9][0-9]*$ + page: + type: integer + minimum: 1 + carrier: + type: string + minLength: 1 + maxLength: 128 + trackingNo: + type: string + minLength: 1 + maxLength: 128 + Labels: + type: object + required: + - version + - idempotencyKey + - reason + - leg + - packingVersion + - receiver + - labels + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + packingVersion: + type: integer + minimum: 1 + receiver: + $ref: '#/components/schemas/PostalAddress' + addressVoucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + labels: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Label' + Estimate: + type: object + required: + - version + - idempotencyKey + - reason + - labelVersion + - amount + - businessDate + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + labelVersion: + type: integer + minimum: 1 + amount: + type: number + minimum: 0 + multipleOf: 0.01 + businessDate: + type: string + format: date + ApplyLabels: + type: object + required: + - version + - idempotencyKey + - reason + - labelVersion + - cartonIds + - appliedAt + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + labelVersion: + type: integer + minimum: 1 + cartonIds: + type: array + minItems: 1 + items: + type: string + pattern: ^[1-9][0-9]*$ + uniqueItems: true + appliedAt: + type: string + format: date-time + Ship: + type: object + required: + - version + - idempotencyKey + - reason + - leg + - labelVersion + - cartonIds + - shippedAt + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + labelVersion: + type: integer + minimum: 1 + cartonIds: + type: array + minItems: 1 + items: + type: string + pattern: ^[1-9][0-9]*$ + uniqueItems: true + shippedAt: + type: string + format: date-time + Repack: + type: object + required: + - version + - idempotencyKey + - reason + - cartonId + - lengthCm + - widthCm + - heightCm + - grossWeightKg + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + cartonId: + type: string + pattern: ^[1-9][0-9]*$ + lengthCm: + type: number + exclusiveMinimum: true + minimum: 0 + multipleOf: 0.01 + widthCm: + type: number + exclusiveMinimum: true + minimum: 0 + multipleOf: 0.01 + heightCm: + type: number + exclusiveMinimum: true + minimum: 0 + multipleOf: 0.01 + grossWeightKg: + type: number + exclusiveMinimum: true + minimum: 0 + multipleOf: 0.001 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + LabelRow: + allOf: + - $ref: '#/components/schemas/Label' + - type: object + required: + - appliedAt + properties: + appliedAt: + type: string + format: date-time + nullable: true + LabelSet: + type: object + required: + - id + - version + - manifestId + - cargoHash + - sender + - receiver + - labels + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + version: + type: integer + manifestId: + type: string + pattern: ^[1-9][0-9]*$ + cargoHash: + type: string + sender: + $ref: '#/components/schemas/PostalAddress' + receiver: + $ref: '#/components/schemas/PostalAddress' + labels: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/LabelRow' + TransportCargo: + type: object + required: [cartonId, cartonNo, quantity, lengthCm, widthCm, heightCm, grossWeightKg, packagingRecordId, sources] + properties: + cartonId: + type: string + pattern: '^[1-9][0-9]*$' + cartonNo: + type: string + quantity: + type: integer + format: int64 + minimum: 1 + lengthCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + widthCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + heightCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + grossWeightKg: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.001 + packagingRecordId: + type: string + nullable: true + sources: + type: array + items: + $ref: '#/components/schemas/Source' + TransportResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + type: object + required: + - orderNo + - version + - status + - overseasLabels + - domesticLabels + - overseasCargo + - domesticCargo + - shipments + properties: + orderNo: + type: string + minLength: 1 + maxLength: 64 + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + minLength: 1 + maxLength: 128 + overseasCargo: + type: array + items: + $ref: '#/components/schemas/TransportCargo' + domesticCargo: + type: array + description: 国内有效合格实收及最新一对一换包装箱规,不包含已处置的破损或多货 + items: + $ref: '#/components/schemas/TransportCargo' + overseasLabels: + allOf: + - $ref: '#/components/schemas/LabelSet' + nullable: true + domesticLabels: + allOf: + - $ref: '#/components/schemas/LabelSet' + nullable: true + shipments: + type: array + items: + type: object + required: + - id + - shipmentNo + - leg + - shippedAt + properties: + id: + type: string + pattern: '^[1-9][0-9]*$' + shipmentNo: + type: string + minLength: 1 + maxLength: 64 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + shippedAt: + type: string + format: date-time + ChargeAmount: + type: object + required: + - determination + - amount + - currency + - businessDate + - reason + properties: + determination: + type: string + enum: + - POSITIVE + - ZERO + - NOT_APPLICABLE + amount: + type: number + minimum: 0 + multipleOf: 0.01 + currency: + type: string + enum: + - CNY + - USD + businessDate: + type: string + format: date + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + reason: + type: string + minLength: 1 + maxLength: 500 + ChargeSelection: + type: object + required: [] + properties: + feeId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + value: + allOf: + - $ref: '#/components/schemas/ChargeAmount' + nullable: true + description: feeId与value恰有一个。引用本单同用途有效费用用于重新核对,不产生新扣款;新核定用value,未知不能当零元。 + oneOf: + - required: + - feeId + properties: + value: + type: object + nullable: true + enum: + - null + feeId: + type: string + pattern: ^[1-9][0-9]*$ + - required: + - value + properties: + feeId: + type: string + nullable: true + enum: + - null + value: + $ref: '#/components/schemas/ChargeAmount' + OtherCharge: + type: object + required: + - code + - name + - selection + properties: + code: + type: string + pattern: ^[A-Z][A-Z0-9_]{0,31}$ + name: + type: string + minLength: 1 + maxLength: 128 + selection: + $ref: '#/components/schemas/ChargeSelection' + ConfirmCharges: + type: object + required: + - version + - idempotencyKey + - reason + - internationalFreight + - duty + - domesticFreight + - otherDetermination + - otherFees + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + internationalFreight: + $ref: '#/components/schemas/ChargeSelection' + duty: + $ref: '#/components/schemas/ChargeSelection' + domesticFreight: + $ref: '#/components/schemas/ChargeSelection' + otherDetermination: + type: string + enum: + - NOT_APPLICABLE + - ITEMIZED + otherFees: + type: array + items: + $ref: '#/components/schemas/OtherCharge' + AdditionalFee: + type: object + additionalProperties: false + required: [version, idempotencyKey, name, amount, currency, businessDate, voucherFileId, reason] + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + name: + type: string + minLength: 1 + maxLength: 128 + amount: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + description: 正额原币金额,原币及折算 CNY 均须在费用来源容量内(最多14位整数)。 + currency: + type: string + enum: [CNY, USD] + businessDate: + type: string + format: date + description: 已发生费用的实际业务日期,不允许未来日期。 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + reason: + type: string + minLength: 1 + maxLength: 500 + CorrectFeeTarget: + type: object + required: + - version + - idempotencyKey + - reason + - targetOriginalAmount + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 500 + targetOriginalAmount: + type: number + minimum: 0 + multipleOf: 0.01 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + RefundFee: + type: object + required: + - version + - idempotencyKey + - reason + - amount + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 500 + amount: + type: number + minimum: 0 + multipleOf: 0.01 + exclusiveMinimum: true + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + ProcurementFeeVersion: + type: object + required: + - id + - feeNo + - purpose + - amount + - walletStatus + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + feeNo: + type: string + minLength: 1 + maxLength: 64 + purpose: + type: string + enum: + - SAMPLE_PAYMENT + - INTERNATIONAL_ESTIMATE + - INTERNATIONAL_FREIGHT + - DUTY + - DOMESTIC_FREIGHT + - OTHER + - ADJUSTMENT + - REFUND + confirmationOrigin: + type: string + enum: [BUSINESS, FINANCIAL] + description: 核定入口决定重试权限;客户响应不包含此字段。 + feeVersion: + type: integer + active: + type: boolean + determination: + type: string + feeItemCode: + type: string + minLength: 1 + maxLength: 64 + feeItemName: + type: string + minLength: 1 + maxLength: 128 + amount: + type: number + minimum: 0 + multipleOf: 0.01 + currency: + type: string + enum: + - CNY + originalCurrency: + type: string + enum: + - CNY + - USD + originalAmount: + type: number + minimum: 0 + multipleOf: 0.01 + exchangeRate: + type: number + exchangeRateMonth: + type: string + businessDate: + type: string + format: date + parentFeeId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + walletStatus: + type: string + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + remark: + type: string + nullable: true + ChargesSnapshot: + type: object + required: + - orderNo + - version + - status + - confirmedAt + - fees + properties: + orderNo: + type: string + minLength: 1 + maxLength: 64 + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + minLength: 1 + maxLength: 128 + confirmedAt: + type: string + format: date-time + nullable: true + fees: + type: array + items: + type: object + required: + - detail + - sourceLinked + properties: + detail: + $ref: '#/components/schemas/ProcurementFeeVersion' + sourceLinked: + type: boolean + FeeCorrectionResult: + type: object + required: + - correctionNo + - rootFeeNo + - targetOriginalAmount + - originalCurrency + - exchangeRate + - exchangeRateMonth + - targetAmount + - previousNetAmount + - differenceAmount + - feeNos + properties: + correctionNo: + type: string + minLength: 1 + maxLength: 64 + rootFeeNo: + type: string + minLength: 1 + maxLength: 64 + targetOriginalAmount: + type: number + minimum: 0 + multipleOf: 0.01 + originalCurrency: + type: string + exchangeRate: + type: number + exchangeRateMonth: + type: string + targetAmount: + type: number + minimum: 0 + multipleOf: 0.01 + previousNetAmount: + type: number + minimum: 0 + multipleOf: 0.01 + differenceAmount: + type: number + multipleOf: 0.01 + feeNos: + type: array + items: + type: string + minLength: 1 + maxLength: 64 + ChargesResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ChargesSnapshot' + FeeCorrectionResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/FeeCorrectionResult' + FeeCorrectionsResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + type: array + items: + $ref: '#/components/schemas/FeeCorrectionResult' + FeeVersionResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ProcurementFeeVersion' + ControlNote: + type: object + required: + - version + - idempotencyKey + - reason + - targetType + - blocking + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + targetType: + type: string + enum: + - PURCHASE + - WAREHOUSE + - RECEIPT + - SHIPMENT + targetId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + blocking: + type: boolean + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + clientSummary: + type: string + minLength: 1 + maxLength: 500 + nullable: true + ResolveControl: + type: object + required: + - version + - idempotencyKey + - reason + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + CorrectWarehouse: + type: object + required: + - version + - idempotencyKey + - reason + - warehouseId + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + warehouseId: + type: string + pattern: ^[1-9][0-9]*$ + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + CloseResolution: + type: object + required: + - kind + - quantity + - decision + - destination + - voucherFileId + properties: + kind: + type: string + enum: + - PURCHASE_REMAINDER + - TRANSIT_REMAINDER + - GOOD + - DAMAGED + - EXTRA + sourceId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + quantity: + type: integer + format: int64 + minimum: 1 + decision: + type: string + enum: + - SUPPLIER_RETAINED + - RETURNED_TO_SUPPLIER + - RETURNED_TO_SENDER + - LOSS_CONFIRMED + - CUSTOMER_AUTHORIZED_RETENTION + - DISPOSED + destination: + type: string + minLength: 1 + maxLength: 500 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + ExceptionClose: + type: object + required: + - version + - idempotencyKey + - reason + - resolutions + - customerVoucherFileId + - financialConclusion + - clientSummary + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + resolutions: + type: array + items: + $ref: '#/components/schemas/CloseResolution' + customerVoucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + financialConclusion: + type: string + minLength: 1 + maxLength: 1000 + clientSummary: + type: string + minLength: 1 + maxLength: 500 + ControlRemaining: + type: object + required: + - kind + - sourceId + - businessNo + - quantity + properties: + kind: + type: string + enum: + - PURCHASE_REMAINDER + - TRANSIT_REMAINDER + - GOOD + - DAMAGED + - EXTRA + sourceId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + businessNo: + type: string + minLength: 1 + maxLength: 64 + quantity: + type: integer + format: int64 + minimum: 1 + ControlSnapshot: + type: object + required: + - orderNo + - version + - status + - notes + - remaining + - closures + - closureItems + properties: + orderNo: + type: string + minLength: 1 + maxLength: 64 + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + minLength: 1 + maxLength: 128 + notes: + type: array + items: + type: object + description: 平台内部更正事实,含原始凭证和处理说明;字段为表结构snake_case,不对客透传 + remaining: + type: array + items: + $ref: '#/components/schemas/ControlRemaining' + closures: + type: array + items: + type: object + closureItems: + type: array + items: + type: object + ControlResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ControlSnapshot' + ProcurementHeader: + type: object + required: + - id + - orderNo + - productName + - purchaseQuantity + - status + - version + properties: + orderNo: + type: string + nullable: true + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + purchaseCategory: + type: string + nullable: true + productName: + type: string + nullable: true + productSpecification: + type: string + nullable: true + productUrl: + type: string + nullable: true + currency: + type: string + nullable: true + receivingAddress: + type: string + nullable: true + contactName: + type: string + nullable: true + contactPhone: + type: string + nullable: true + plannedWarehouseName: + type: string + nullable: true + actualWarehouseCode: + type: string + nullable: true + actualWarehouseName: + type: string + nullable: true + status: + type: string + nullable: true + id: + type: string + pattern: ^[1-9][0-9]*$ + purchaseQuantity: + type: integer + format: int64 + budgetUnitPrice: + type: number + nullable: true + version: + type: integer + format: int64 + minimum: 0 + estimatedArrivalAt: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + ProcurementBudget: + type: object + required: + - totalAmount + - currency + - authorizedAt + properties: + totalAmount: + type: number + nullable: true + currency: + type: string + authorizedAt: + type: string + format: date-time + nullable: true + ProcurementProgress: + type: object + required: + - key + - title + - completedAt + properties: + key: + type: string + minLength: 1 + maxLength: 128 + title: + type: string + minLength: 1 + maxLength: 128 + completedAt: + type: string + format: date-time + nullable: true + CustomerBox: + type: object + required: + - cartonNo + - quantity + - lengthCm + - widthCm + - heightCm + - grossWeightKg + properties: + cartonNo: + type: string + minLength: 1 + maxLength: 64 + quantity: + type: integer + format: int64 + lengthCm: + type: number + widthCm: + type: number + heightCm: + type: number + grossWeightKg: + type: number + CustomerShipment: + type: object + required: + - shipmentNo + - leg + - shippedAt + - cartonNo + - quantity + - carrier + - trackingNo + properties: + shipmentNo: + type: string + minLength: 1 + maxLength: 64 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + shippedAt: + type: string + format: date-time + cartonNo: + type: string + minLength: 1 + maxLength: 64 + quantity: + type: integer + format: int64 + carrier: + type: string + minLength: 1 + maxLength: 128 + trackingNo: + type: string + minLength: 1 + maxLength: 128 + CustomerFee: + type: object + required: + - feeNo + - purpose + - name + - determination + - currency + - originalCurrency + - exchangeRateMonth + - parentFeeNo + - walletStatus + - version + - active + - amount + - originalAmount + - exchangeRate + - businessDate + - sourceLinked + properties: + feeNo: + type: string + nullable: true + purpose: + type: string + nullable: true + name: + type: string + nullable: true + determination: + type: string + nullable: true + currency: + type: string + nullable: true + originalCurrency: + type: string + nullable: true + exchangeRateMonth: + type: string + nullable: true + parentFeeNo: + type: string + nullable: true + walletStatus: + type: string + nullable: true + version: + type: integer + nullable: true + active: + type: boolean + amount: + type: number + originalAmount: + type: number + nullable: true + exchangeRate: + type: number + nullable: true + businessDate: + type: string + format: date + nullable: true + sourceLinked: + type: boolean + additionalProperties: false + description: 客户费用白名单:不含凭证、备注、资金命令/冻结/流水引用和原始诊断 + ProcurementCustomerView: + type: object + required: + - order + - budget + - purchased + - purchaseConfirmed + - purchaseNeedsAttention + - progress + - boxes + - shipments + - fees + - exceptionSummaries + properties: + order: + $ref: '#/components/schemas/ProcurementHeader' + budget: + $ref: '#/components/schemas/ProcurementBudget' + purchased: + type: boolean + purchaseConfirmed: + type: boolean + purchaseNeedsAttention: + type: boolean + progress: + type: array + items: + $ref: '#/components/schemas/ProcurementProgress' + boxes: + type: array + items: + $ref: '#/components/schemas/CustomerBox' + shipments: + type: array + items: + $ref: '#/components/schemas/CustomerShipment' + fees: + type: array + items: + $ref: '#/components/schemas/CustomerFee' + exceptionSummaries: + type: array + items: + type: string + additionalProperties: false + description: 客户仅获取此独立投影;boxes只包含已确认海外清单。内部采购异常只提供需要处理标记,异常摘要来自人工明确填写的对客字段。 + ProcurementAction: + type: object + required: + - key + - label + - permission + - allowed + - reason + properties: + key: + type: string + minLength: 1 + maxLength: 128 + label: + type: string + minLength: 1 + maxLength: 128 + permission: + type: string + minLength: 1 + maxLength: 128 + allowed: + type: boolean + reason: + type: string + nullable: true + ProcurementPlatformView: + type: object + required: + - customerView + - purchaseFacts + - physical + - transport + - fees + - feeCorrections + - controls + - files + - reviewHistory + - operations + - actions + - internalRemark + properties: + customerView: + $ref: '#/components/schemas/ProcurementCustomerView' + purchaseFacts: + type: object + description: 平台内部履约事实,字段snake_case + physical: + type: object + properties: + orderNo: + type: string + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + packingVersion: + type: integer + nullable: true + packingStatus: + type: string + nullable: true + enum: + - DRAFT + - CONFIRMED + - SUPERSEDED + receipts: + type: array + items: + type: object + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + receiptNo: + type: string + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + sourceCartonId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + goodQuantity: + type: integer + format: int64 + minimum: 0 + damagedQuantity: + type: integer + format: int64 + minimum: 0 + extraQuantity: + type: integer + format: int64 + minimum: 0 + receivedAt: + type: string + format: date-time + active: + type: boolean + dispositions: + type: array + description: 平台处置历史,active=0为已撤销记录;不直接对客输出内部依据 + items: + type: object + cartons: + type: array + items: + allOf: + - $ref: '#/components/schemas/Carton' + - type: object + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + quantity: + type: integer + format: int64 + minimum: 1 + transport: + type: object + required: + - orderNo + - version + - status + - overseasLabels + - domesticLabels + - overseasCargo + - domesticCargo + - shipments + properties: + orderNo: + type: string + minLength: 1 + maxLength: 64 + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + minLength: 1 + maxLength: 128 + overseasCargo: + type: array + items: + $ref: '#/components/schemas/TransportCargo' + domesticCargo: + type: array + description: 国内有效合格实收及最新一对一换包装箱规,不包含已处置的破损或多货 + items: + $ref: '#/components/schemas/TransportCargo' + overseasLabels: + allOf: + - $ref: '#/components/schemas/LabelSet' + nullable: true + domesticLabels: + allOf: + - $ref: '#/components/schemas/LabelSet' + nullable: true + shipments: + type: array + items: + type: object + required: + - id + - shipmentNo + - leg + - shippedAt + properties: + id: + type: string + pattern: '^[1-9][0-9]*$' + shipmentNo: + type: string + minLength: 1 + maxLength: 64 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + shippedAt: + type: string + format: date-time + fees: + type: array + items: + $ref: '#/components/schemas/ProcurementFeeVersion' + feeCorrections: + type: array + items: + $ref: '#/components/schemas/FeeCorrectionResult' + controls: + $ref: '#/components/schemas/ControlSnapshot' + files: + type: array + items: + type: object + reviewHistory: + type: array + items: + type: object + operations: + type: array + items: + type: object + actions: + type: array + items: + $ref: '#/components/schemas/ProcurementAction' + internalRemark: + type: string + nullable: true + ProcurementCustomerResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ProcurementCustomerView' + ProcurementPlatformResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ProcurementPlatformView' diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java index f9573422f..9a502a018 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java @@ -30,7 +30,7 @@ class DomesticWarehouseDirectoryClientContractTest { assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents()) .map(value -> value.getName() + ":" + value.getType().getSimpleName())) .containsExactly("id:Long", "tenantId:Long", "warehouseCode:String", "warehouseName:String", - "warehouseType:String", "countryCode:String", "status:String"); + "warehouseType:String", "countryCode:String", "status:String", "contactName:String", "contactPhone:String"); assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents()) .map(value -> value.getName() + ":" + value.getType().getSimpleName())) .containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index bda740e5f..2bd6c0966 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -59,7 +59,7 @@ class DispatchBolPrintServiceTest { var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, - "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address)))); @@ -89,7 +89,7 @@ class DispatchBolPrintServiceTest { new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", "NYEXX2609110215", null, null, 1)), null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, - "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of()))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java new file mode 100644 index 000000000..14fd1c29b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java @@ -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("快照不完整"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java index 0d3e23529..bbbbacb1a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java @@ -60,7 +60,7 @@ class MerchantOverseasOpenApiContractTest { .contains("merchant:overseas-warehouse:return-order:create") .contains("merchant:overseas-warehouse:return-order:relabel") .contains("merchant:warehouse:inbound:list") - .contains("#/components/schemas/ProcurementDetailResponse") + .contains("procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse") .contains("#/components/schemas/TruckDetailResponse") .contains("#/components/schemas/LooseboardDetailResponse") .contains("#/components/schemas/InboundPlanDetailResponse") @@ -68,6 +68,10 @@ class MerchantOverseasOpenApiContractTest { .contains("overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }") .contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE") .contains("version 必填").contains("幂等冲突"); + for(String path:new String[]{"/api/merchant/v1/last-leg/overseas-procurements:","/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:"}) { + assertThat(pathBlock(yaml,path)).contains("ProcurementCustomerResponse") + .doesNotContain("ProcurementPlatformResponse", "ProcurementDetailResponse"); + } assertThat(yaml).contains("/api/platform/v1/last-leg/inbound-plans:") .contains("/api/platform/v1/last-leg/inbound-plans/{planNo}:") .contains("overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java index 6288bad5f..99f3e2aaa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java @@ -26,6 +26,8 @@ class OverseasProcurementInventoryBoundaryTest { String responses = Files.readString(Path.of( "src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java"), StandardCharsets.UTF_8); - assertTrue(responses.contains("outboundQuantity")); + assertFalse(responses.contains("outboundQuantity")); + assertFalse(responses.contains("inventoryQuantity")); + assertFalse(responses.contains("OutboundDetail")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index ee27b71af..b541da683 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -22,7 +22,9 @@ class OverseasProcurementSchemaContractTest { assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL")); assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL")); assertTrue(sql.contains("review_request_hash CHAR(64) NULL")); - assertTrue(sql.contains("outbound_details JSON NULL COMMENT '出库明细'")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_details")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item")); assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); assertTrue(sql.contains("change_detail JSON NULL")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); @@ -41,7 +43,8 @@ class OverseasProcurementSchemaContractTest { String order = tableBlock(lower, "ll_overseas_procurement_order"); assertTrue(order.contains("review_idempotency_key varchar(255)")); assertTrue(order.contains("review_request_hash char(64)")); - assertTrue(order.contains("outbound_details json")); + org.junit.jupiter.api.Assertions.assertFalse(order.contains("outbound_details")); + org.junit.jupiter.api.Assertions.assertFalse(order.contains("tracking_no")); assertTrue(tableBlock(lower, "ll_wallet_event_inbox").contains("payload_hash varchar(71)")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_overseas_procurement")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_wallet_event_inbox")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java new file mode 100644 index 000000000..5cf726db8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java @@ -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"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java index d8c84b626..3d90f3932 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java @@ -20,6 +20,7 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class OverseasProcurementControllerTest { @Mock OverseasProcurementService service; + @Mock com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; private MerchantOverseasProcurementController merchant; private PlatformOverseasProcurementController platform; @@ -29,6 +30,8 @@ class OverseasProcurementControllerTest { platform = new PlatformOverseasProcurementController(); ReflectionTestUtils.setField(merchant, "service", service); ReflectionTestUtils.setField(platform, "service", service); + ReflectionTestUtils.setField(merchant,"queryService",queryService); + ReflectionTestUtils.setField(platform,"queryService",queryService); } @Test @@ -37,10 +40,19 @@ class OverseasProcurementControllerTest { merchant.page(user(100L, "MERCHANT", Set.of()), query); verify(service).page("100", query.scoped()); } + @Test void customerDetailUsesOnlyTrustedCustomerAndDedicatedProjection() { + merchant.detail(user(100L,"MERCHANT",Set.of()),"PO-1"); + verify(queryService).customer("100","PO-1"); + } + @Test void platformDetailAggregatesWithCurrentActionPermissions() { + var permissions=Set.of("platform:overseas-business:purchase:list"); + platform.detail(user(0L,"PLATFORM",permissions),"100","PO-1"); + verify(queryService).platform("100","PO-1",permissions); + } @Test void reviewRequiresExplicitPermission() { - var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null, 10L); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request)); assertEquals(403, error.getStatusCode().value()); @@ -56,7 +68,7 @@ class OverseasProcurementControllerTest { @Test void platformReviewUsesExplicitTargetTenant() { - var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok"); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok", 10L); platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)), "100", "HC1", request); verify(service).review("100", "HC1", "7", request); @@ -64,8 +76,7 @@ class OverseasProcurementControllerTest { @Test void platformUpdatePassesAuditedOperator() { - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - null, null, null, null, null, null, null, null, null); + var request = new OverseasProcurementRequests.Update(0L, null); platform.update(user(0L, "PLATFORM", Set.of(OverseasProcurementService.MANAGE_PERMISSION)), "100", "HC1", request); verify(service).update("100", "HC1", "7", request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java new file mode 100644 index 000000000..92e19711a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java @@ -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");} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index dc87b1865..7f11a6952 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -42,7 +42,7 @@ import static org.mockito.Mockito.when; class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementStatusHistoryMapper histories; - @Mock PartnerWarehouseDirectory warehouseDirectory; + @Mock ProcurementWarehouseDirectory warehouseDirectory; @Mock ProcurementOrderNumberGenerator orderNoGenerator; @Mock OverseasProcurementFileMapper procurementFiles; @Mock OverseasProcurementFeeMapper procurementFees; @@ -62,9 +62,6 @@ class OverseasProcurementServiceTest { ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles); ReflectionTestUtils.setField(service, "procurementFees", procurementFees); ReflectionTestUtils.setField(service, "fileGateway", fileGateway); - ReflectionTestUtils.setField(service, "walletCommands", walletCommands); - ReflectionTestUtils.setField(service, "feeSources", feeSources); - lenient().when(feeSources.publishApproved(anyString(), anyString(), any())).thenReturn(new OverseasFeeSource()); lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001"); lenient().when(procurementFiles.selectList(any())).thenReturn(List.of()); lenient().when(procurementFees.selectList(any())).thenReturn(List.of()); @@ -85,117 +82,34 @@ class OverseasProcurementServiceTest { new BigDecimal("26.50"), "USD", "Ningbo", "Buyer", "13800000000", null, "key-1")); assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status()); - assertEquals(0, detail.summary().inboundQuantity()); - assertEquals(0, detail.summary().outboundQuantity()); - assertEquals(0, detail.summary().inventoryQuantity()); } @Test - void outboundQuantityAndDisplayStatusFollowOutboundDetails() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 90, 0); - when(orders.findNo("100", "HC1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - - order.setOutboundDetails("[]"); - assertEquals("PENDING_OUTBOUND", service.detail("100", "HC1").summary().status()); - - order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":1}]"); - var partial = service.detail("100", "HC1").summary(); - assertEquals(1, partial.outboundQuantity()); - assertEquals(89, partial.inventoryQuantity()); - assertEquals("PARTIALLY_OUTBOUND", partial.status()); - - order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":90}]"); - var completed = service.detail("100", "HC1").summary(); - assertEquals(90, completed.outboundQuantity()); - assertEquals(0, completed.inventoryQuantity()); - assertEquals("OUTBOUNDED", completed.status()); - } - - @Test - void pendingOrderCannotReceiveInventory() { - OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0); - when(orders.lockNo("100", "HC1")).thenReturn(order); - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - 100L, null, null, null, null, null, null, null, null); - - ResponseStatusException error = assertThrows(ResponseStatusException.class, - () -> service.update("100", "HC1", "7", request)); - assertEquals(409, error.getStatusCode().value()); - } - - @Test - void explicitNullClearsOptionalFieldsAndWritesOperationHistory() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); - order.setProductUrl("https://example.test/old"); + void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() { + OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0); + order.setRemark("old"); + order.setEstimatedArrivalAt(LocalDateTime.of(2026,10,10,12,0)); when(orders.lockNo("100", "HC1")).thenReturn(order); when(orders.findNo("100", "HC1")).thenReturn(order); when(histories.listOrder("100", 1L)).thenReturn(List.of()); - var request = new OverseasProcurementRequests.Update(0L, null, null, - new ObjectMapper().nullNode(), new ObjectMapper().nullNode(), null, - null, null, null, null, null, null, null, null); - - service.update("100", "HC1", "7", request); - - assertEquals(null, order.getProductUrl()); - verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> - "UPDATE".equals(value.getOperationType()) && value.getChangeDetail().contains("productUrl"))); + service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(0L, new ObjectMapper().nullNode())); + assertEquals(null, order.getRemark()); + assertEquals(LocalDateTime.of(2026,10,10,12,0), order.getEstimatedArrivalAt()); + assertEquals(100L, order.getInboundQuantity()); + assertEquals(List.of("version", "remark"), java.util.Arrays.stream(OverseasProcurementRequests.Update.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList()); } @Test - void omittedOptionalFieldKeepsExistingValue() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); - order.setProductUrl("https://example.test/keep"); + void omittedRemarkKeepsExistingValueAndStaleVersionCannotWrite() { + OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0); + order.setRemark("keep"); when(orders.lockNo("100", "HC1")).thenReturn(order); when(orders.findNo("100", "HC1")).thenReturn(order); when(histories.listOrder("100", 1L)).thenReturn(List.of()); - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - null, null, null, null, null, null, null, null, null); - - service.update("100", "HC1", "7", request); - - assertEquals("https://example.test/keep", order.getProductUrl()); + service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(0L, null)); + assertEquals("keep", order.getRemark()); verify(orders, never()).updateById(org.mockito.ArgumentMatchers.any()); - } - - @Test - void warehouseSelectionFreezesCanonicalCandidateName() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); - when(orders.lockNo("100", "HC1")).thenReturn(order); - when(orders.findNo("100", "HC1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - when(warehouseDirectory.required(10L)).thenReturn( - new OverseasQuoteResponses.PartnerWarehouseOption("10", "LAX-01", "洛杉矶采购仓", "US")); - ObjectMapper mapper = new ObjectMapper(); - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - null, mapper.getNodeFactory().textNode("10"), mapper.getNodeFactory().textNode("伪造名称"), - null, null, null, null, null, null); - - service.update("100", "HC1", "7", request); - - assertEquals("10", order.getPurchaseWarehouseId()); - assertEquals("洛杉矶采购仓", order.getPurchaseWarehouseName()); - } - - @Test - void unchangedHistoricalWarehouseDoesNotBlockUnrelatedEditsAfterDeactivation() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); - order.setPurchaseWarehouseId("10"); - order.setPurchaseWarehouseName("历史采购仓"); - when(orders.lockNo("100", "HC1")).thenReturn(order); - when(orders.findNo("100", "HC1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - ObjectMapper mapper = new ObjectMapper(); - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - null, mapper.getNodeFactory().textNode("10"), null, - null, null, null, null, null, mapper.getNodeFactory().textNode("更新出库信息")); - - service.update("100", "HC1", "7", request); - - assertEquals("10", order.getPurchaseWarehouseId()); - assertEquals("历史采购仓", order.getPurchaseWarehouseName()); - verify(warehouseDirectory, never()).required(10L); - verify(orders).updateById(order); + assertThrows(ResponseStatusException.class, () -> service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(2L, null))); } @Test @@ -218,25 +132,26 @@ class OverseasProcurementServiceTest { when(orders.lockNo("100", "HC1")).thenReturn(order); when(orders.findNo("100", "HC1")).thenReturn(order); when(histories.listOrder("100", 1L)).thenReturn(List.of()); - var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved"); + when(warehouseDirectory.required(10L)).thenReturn(new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(10L,"LAX","洛杉矶","US","ACTIVE")); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved", 10L); var approved = service.review("100", "HC1", "operator-7", request); - assertEquals(OverseasProcurementService.APPROVED, approved.summary().status()); + assertEquals(OverseasProcurementService.WAIT_PURCHASE, approved.summary().status()); assertEquals(1L, approved.summary().version()); assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version()); verify(histories).insert(org.mockito.ArgumentMatchers .argThat(value -> "REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId()) && "approve-1".equals(value.getIdempotencyKey()) - && OverseasProcurementService.APPROVED.equals(value.getToStatus()))); + && OverseasProcurementService.WAIT_PURCHASE.equals(value.getToStatus()))); assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", - new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload"))); + new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload", 10L))); assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", - new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version"))); + new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version", 10L))); assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", - new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse"))); - assertEquals(OverseasProcurementService.APPROVED, order.getStatus()); + new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse", 10L))); + assertEquals(OverseasProcurementService.WAIT_PURCHASE, order.getStatus()); } private OverseasProcurementOrder order(String status, long inbound, long outbound) { @@ -256,11 +171,23 @@ class OverseasProcurementServiceTest { @Test void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception { for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId", - "purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier", - "overseasTrackingNo", "estimatedArrivalAt", "remark")) { + "purchaseWarehouseName", "estimatedArrivalAt", "remark")) { TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class); assertNotNull(annotation, field); assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field); } } + + @Test + void fileDownloadRequiresExactOrderAssociationBeforeCallingFileService() { + var order=order(OverseasProcurementService.WAIT_PURCHASE,0,0); + when(orders.findNo("100","HC1")).thenReturn(order); + assertThrows(ResponseStatusException.class,()->service.downloadFile("100","HC1",999)); + org.mockito.Mockito.verifyNoInteractions(fileGateway); + var file=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFile();file.setCategory("LABEL");file.setFileName("label.pdf"); + when(procurementFiles.selectOne(any())).thenReturn(file); + when(fileGateway.download("100",1,"LABEL",42)).thenReturn(new ProcurementFileGateway.Download(new byte[]{1},"application/pdf")); + assertEquals("label.pdf",service.downloadFile("100","HC1",42).fileName()); + verify(fileGateway).download("100",1,"LABEL",42); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java new file mode 100644 index 000000000..f032b12ea --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java @@ -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>(); + 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>(); + 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)));} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java new file mode 100644 index 000000000..6a7fce90f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java @@ -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 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 lines){return tx.execute(s->service.close("9","PO-1","7",request(lines)));} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java new file mode 100644 index 000000000..b0f89a4de --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java @@ -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)); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java new file mode 100644 index 000000000..c686dfe9a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java @@ -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 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")));} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java new file mode 100644 index 000000000..8526203c2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java @@ -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(); + 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"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java new file mode 100644 index 000000000..df56e9c73 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java @@ -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(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java new file mode 100644 index 000000000..ac1debecc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java @@ -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>(); + 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