This commit is contained in:
2026-08-19 09:42:07 +08:00
parent ff1d330c2d
commit b5864bfcda
56 changed files with 497 additions and 306 deletions
+123 -55
View File
@@ -4633,6 +4633,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码', fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称', fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商',
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与', pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与', pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与', warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与',
@@ -4661,8 +4662,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID', fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空', supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空', supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
business_date DATE NOT NULL COMMENT '业务发生日', business_date DATE NOT NULL COMMENT '业务发生日',
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照', fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照', trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
@@ -7017,6 +7019,8 @@ END$$
DELIMITER ; DELIMITER ;
CALL unified_publish_fee_add_column('fl_fee_item', 'fee_code', CALL unified_publish_fee_add_column('fl_fee_item', 'fee_code',
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope"); "VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
CALL unified_publish_fee_add_column('fl_fee_item', 'supplier_category',
"VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商' AFTER fee_type_name");
ALTER TABLE fl_fee_item ALTER TABLE fl_fee_item
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id',
COMMENT = '平台公共费用项目主数据'; COMMENT = '平台公共费用项目主数据';
@@ -7426,8 +7430,6 @@ CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'published_at',
"DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name"); "DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'inbounded_at', CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'inbounded_at',
"DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at"); "DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'settlement_included',
"TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与结算' AFTER inbounded_at");
DROP PROCEDURE IF EXISTS unified_publish_fee_add_column; DROP PROCEDURE IF EXISTS unified_publish_fee_add_column;
DROP PROCEDURE IF EXISTS unified_publish_fee_add_index; DROP PROCEDURE IF EXISTS unified_publish_fee_add_index;
@@ -10772,6 +10774,10 @@ CREATE TABLE IF NOT EXISTS fin_bill (
remark VARCHAR(500) NULL COMMENT '平台账单内部备注', remark VARCHAR(500) NULL COMMENT '平台账单内部备注',
issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空', issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空',
issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空', issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空',
customer_confirmation_status VARCHAR(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认',
customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID',
customer_confirmed_by_name VARCHAR(128) NULL COMMENT '客户确认人姓名快照',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空', voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空',
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空', voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空',
void_reason VARCHAR(500) NULL COMMENT '账单作废原因', void_reason VARCHAR(500) NULL COMMENT '账单作废原因',
@@ -11015,12 +11021,21 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction), UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status), KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status), KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
KEY idx_fin_settlement_receivable_statistics (direction, currency_code, generated_date, status, tenant_id),
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0), CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')), CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount) CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount)
) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生'; ) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生';
SET @has_receivable_statistics_index = (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_finance' AND table_name='fin_settlement'
AND index_name='idx_fin_settlement_receivable_statistics');
SET @ddl = IF(@has_receivable_statistics_index=0,
'ALTER TABLE baiyue_finance.fin_settlement ADD KEY idx_fin_settlement_receivable_statistics(direction,currency_code,generated_date,status,tenant_id)',
'SELECT 1');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
CREATE TABLE IF NOT EXISTS fin_settlement_line ( CREATE TABLE IF NOT EXISTS fin_settlement_line (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID', id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -11166,12 +11181,12 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照', fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照',
fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空', supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空', supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
business_date DATE NOT NULL COMMENT '费用业务日期', business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
settlement_included TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与财务结算来源池',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回', review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回',
source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废', source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废',
source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本', source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本',
@@ -11186,22 +11201,52 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status), KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status),
KEY idx_container_fee_settlement (tenant_id, review_status, settlement_included, source_status), KEY idx_container_fee_review (tenant_id, review_status, source_status),
CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')), CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'), CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0), CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')), CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED')) CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'; ) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_id', CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction"); id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用快照ID',
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_name', source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID',
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id"); source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本',
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction"); loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID',
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照',
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id"); container_no VARCHAR(64) NULL COMMENT '柜号快照',
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID快照',
fee_item_code VARCHAR(50) NOT NULL COMMENT '费用项目编码快照',
fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
direction VARCHAR(16) NOT NULL COMMENT '费用方向',
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照',
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
review_status VARCHAR(16) NOT NULL COMMENT '审核状态快照',
reviewed_at DATETIME(3) NULL COMMENT '审核时间快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_container_fee_source_version (loading_plan_id, source_fee_id, source_version),
KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id)
) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column; DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema; DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
@@ -11220,13 +11265,13 @@ BEGIN
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail' WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用往来单位快照字段不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail' WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用往来单位快照字段不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
@@ -11270,17 +11315,21 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot', CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash"); "JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
CALL finance_fee_source_add_column('fin_fee_source', 'review_status', CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
"VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot"); "VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot");
CALL finance_fee_source_add_column('fin_fee_source', 'review_reason', CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status"); "VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_included',
"TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_reason',
"VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明' AFTER settlement_included");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version', CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号' AFTER settlement_reason"); "INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at', CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at"); "DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
"VARCHAR(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认' AFTER issued_by");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_by',
"BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID' AFTER customer_confirmation_status");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_by_name',
"VARCHAR(128) NULL COMMENT '客户确认人姓名快照' AFTER customer_confirmed_by");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_at',
"DATETIME(3) NULL COMMENT '客户确认时间' AFTER customer_confirmed_by_name");
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash', CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
"CHAR(64) NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测' AFTER payload"); "CHAR(64) NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测' AFTER payload");
CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id', CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id',
@@ -11298,8 +11347,7 @@ UPDATE fin_fee_source
source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)), source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)),
source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)), source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)),
updated_at = COALESCE(updated_at, created_at), updated_at = COALESCE(updated_at, created_at),
review_status = COALESCE(review_status, 'PENDING'), review_status = COALESCE(review_status, 'DRAFT'),
settlement_included = COALESCE(settlement_included, 0),
operation_version = COALESCE(operation_version, 0); operation_version = COALESCE(operation_version, 0);
UPDATE fin_inbox_event UPDATE fin_inbox_event
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256)); SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
@@ -11324,7 +11372,7 @@ BEGIN
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source' WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code', AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
'source_hash','source_snapshot','review_status','review_reason', 'source_hash','source_snapshot','review_status','review_reason',
'settlement_included','settlement_reason','operation_version','updated_at')) <> 13 THEN 'operation_version','updated_at')) <> 11 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
@@ -11332,6 +11380,12 @@ BEGIN
AND column_name IN ('payload_hash','result_resource_id','error_code')) <> 3 THEN AND column_name IN ('payload_hash','result_resource_id','error_code')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:Inbox幂等字段不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:Inbox幂等字段不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_bill'
AND column_name IN ('customer_confirmation_status','customer_confirmed_by',
'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整';
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL finance_fee_source_assert_schema(); CALL finance_fee_source_assert_schema();
@@ -12314,8 +12368,9 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'created
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'created_by', '创建人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'created_by', '创建人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'currency', '币种,首期固定CNY'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'currency', '币种,首期固定CNY');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'direction', '费用方向:RECEIVABLE应收、PAYABLE应付'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'direction', '费用方向:RECEIVABLE应收、PAYABLE应付');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'partner_id', '应付往来单位ID快照;应收为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'supplier_id', '应付Warehouse供应商ID快照;应收为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'partner_name', '应付往来单位名称快照;应收为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'supplier_name', '应付Warehouse供应商名称快照;应收为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'supplier_category', '费用项目供应商类别约束快照;为空表示任意启用供应商');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_code_snapshot', '费用项目稳定编码快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_code_snapshot', '费用项目稳定编码快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_id', '平台公共费用项目ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_id', '平台公共费用项目ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_name_snapshot', '费用项目名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'fee_item_name_snapshot', '费用项目名称快照');
@@ -12327,7 +12382,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'review_
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewed_at', '审核时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewed_at', '审核时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewer_id', '审核人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewer_id', '审核人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewer_name', '审核人名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'reviewer_name', '审核人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'settlement_included', '是否参与财务结算来源池');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'source_status', '来源状态:ACTIVE有效、VOIDED作废'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'source_status', '来源状态:ACTIVE有效、VOIDED作废');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'source_version', '费用来源版本;调整递增,作废携带当前版本'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'source_version', '费用来源版本;调整递增,作废携带当前版本');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'tenant_id', '客户ID和数据隔离键'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_fee_detail', 'tenant_id', '客户ID和数据隔离键');
@@ -12488,6 +12542,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'deleted', '逻辑
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'description', '说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'description', '说明');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'fee_code', '稳定费用编码;系统费用使用固定编码'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'fee_code', '稳定费用编码;系统费用使用固定编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'fee_type_name', '费用类型名称'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'fee_type_name', '费用类型名称');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'supplier_category', 'PAYABLE供应商类别约束;为空表示任意启用供应商');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'id', '费用项目ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'id', '费用项目ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'pickup_payable', '提柜应付是否参与'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'pickup_payable', '提柜应付是否参与');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'pickup_receivable', '提柜应收是否参与'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'pickup_receivable', '提柜应收是否参与');
@@ -12629,8 +12684,9 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_c
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_id', '入库单ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_id', '入库单ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbounded_at', '入库完成时间快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbounded_at', '入库完成时间快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'invoice_title', '发票抬头快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'invoice_title', '发票抬头快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_id', '应付往来单位ID快照;应收为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id', '应付Warehouse供应商ID快照;应收为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_name', '应付往来单位名称快照;应收为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name', '应付Warehouse供应商名称快照;应收为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category', '费用项目供应商类别约束快照;为空表示任意启用供应商');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'package_count', '件数快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'package_count', '件数快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'publication_status', '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'publication_status', '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'published_at', '发布时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'published_at', '发布时间');
@@ -12642,7 +12698,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'review_st
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewed_at', '审核时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewed_at', '审核时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewer_id', '审核人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewer_id', '审核人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewer_name', '审核人名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'reviewer_name', '审核人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'settlement_included', '是否参与结算');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_id', '来源计费快照ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_id', '来源计费快照ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_no', '来源费用单号'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_no', '来源费用单号');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_version', '来源计费快照版本'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'source_fee_version', '来源计费快照版本');
@@ -14624,9 +14679,7 @@ INSERT INTO baiyue_iam.iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES VALUES
(5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', '财务管理', '/finance', NULL, 60, 'ACTIVE', 0), (5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', '财务管理', '/finance', NULL, 60, 'ACTIVE', 0),
(5105011, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:create', '创建账单', NULL, 'POST', 20, 'ACTIVE', 0),
(5105012, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:update', '修改草稿账单', NULL, 'PUT', 30, 'ACTIVE', 0), (5105012, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:update', '修改草稿账单', NULL, 'PUT', 30, 'ACTIVE', 0),
(5105013, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:issue', '出账', NULL, 'POST', 40, 'ACTIVE', 0),
(5105014, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:adjust', '调整账单', NULL, 'POST', 50, 'ACTIVE', 0), (5105014, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:adjust', '调整账单', NULL, 'POST', 50, 'ACTIVE', 0),
(5105015, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:void', '作废账单', NULL, 'POST', 60, 'ACTIVE', 0), (5105015, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:void', '作废账单', NULL, 'POST', 60, 'ACTIVE', 0),
(5105016, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:receipt:view', '查看收款', NULL, 'GET', 70, 'ACTIVE', 0), (5105016, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:receipt:view', '查看收款', NULL, 'GET', 70, 'ACTIVE', 0),
@@ -14637,9 +14690,18 @@ VALUES
(5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0), (5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0),
(5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0),
(5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0),
(5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0) (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0),
(5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0;
DELETE rp FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue');
DELETE FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM'
AND permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue');
-- Finance G1-G4 新权限只进入可分配权限目录,不默认授予平台管理员。 -- Finance G1-G4 新权限只进入可分配权限目录,不默认授予平台管理员。
DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions; DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions;
DELIMITER $$ DELIMITER $$
@@ -14649,7 +14711,7 @@ BEGIN
SELECT 1 SELECT 1
FROM baiyue_iam.iam_permission FROM baiyue_iam.iam_permission
WHERE (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review') WHERE (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement') OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:edit')
OR (id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create') OR (id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create')
OR (id = 5105027 AND permission_code <> 'platform:finance:reconciliation:review') OR (id = 5105027 AND permission_code <> 'platform:finance:reconciliation:review')
OR (id = 5105028 AND permission_code <> 'platform:finance:reconciliation:issue') OR (id = 5105028 AND permission_code <> 'platform:finance:reconciliation:issue')
@@ -14660,7 +14722,7 @@ BEGIN
OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create') OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create')
OR (id = 5105036 AND permission_code <> 'platform:finance:settlement:void') OR (id = 5105036 AND permission_code <> 'platform:finance:settlement:void')
OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024) OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025) OR (permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025)
OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026) OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026)
OR (permission_code = 'platform:finance:reconciliation:review' AND id <> 5105027) OR (permission_code = 'platform:finance:reconciliation:review' AND id <> 5105027)
OR (permission_code = 'platform:finance:reconciliation:issue' AND id <> 5105028) OR (permission_code = 'platform:finance:reconciliation:issue' AND id <> 5105028)
@@ -14687,8 +14749,8 @@ BEGIN
'查看财务费用来源(已停用)', NULL, 'GET', 140, 'INACTIVE', 1, 1), '查看财务费用来源(已停用)', NULL, 'GET', 140, 'INACTIVE', 1, 1),
(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review', (5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review',
'审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0), '审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0),
(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement', (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit',
'设置费用来源参与结算', NULL, 'PUT', 160, 'ACTIVE', 1, 0), '编辑财务费用来源', NULL, 'PUT', 160, 'ACTIVE', 1, 0),
(5105026, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:create', '生成和提交客户对账单', NULL, 'POST', 170, 'ACTIVE', 1, 0), (5105026, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:create', '生成和提交客户对账单', NULL, 'POST', 170, 'ACTIVE', 1, 0),
(5105027, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:review', '审核客户对账单', NULL, 'POST', 180, 'ACTIVE', 1, 0), (5105027, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:review', '审核客户对账单', NULL, 'POST', 180, 'ACTIVE', 1, 0),
(5105028, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:issue', '出账客户对账单', NULL, 'POST', 190, 'ACTIVE', 1, 0), (5105028, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:issue', '出账客户对账单', NULL, 'POST', 190, 'ACTIVE', 1, 0),
@@ -14715,7 +14777,7 @@ BEGIN
AND deleted = 0 AND deleted = 0
AND permission_code IN ( AND permission_code IN (
'platform:finance:fee-source:review', 'platform:finance:fee-source:review',
'platform:finance:fee-source:settlement', 'platform:finance:fee-source:edit',
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:create',
'platform:finance:reconciliation:review', 'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:issue',
@@ -14745,8 +14807,7 @@ INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
'platform:finance', 'platform:finance',
'platform:finance:bill:create', 'platform:finance:bill:update',
'platform:finance:bill:update', 'platform:finance:bill:issue',
'platform:finance:bill:adjust', 'platform:finance:bill:void', 'platform:finance:bill:adjust', 'platform:finance:bill:void',
'platform:finance:receipt:view', 'platform:finance:receipt:create', 'platform:finance:receipt:view', 'platform:finance:receipt:create',
'platform:finance:cost:view', 'platform:finance:cost:create', 'platform:finance:cost:view', 'platform:finance:cost:create',
@@ -14755,7 +14816,7 @@ ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement', 'platform:finance:fee-source:review', 'platform:finance:fee-source:edit',
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create', 'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
@@ -15210,19 +15271,26 @@ VALUES
(5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0), (5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0),
(5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0), (5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0),
(5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0), (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0),
(5105045,0,'PLATFORM','MENU',5105000,'platform:finance:receivable-statistics:list','应收款统计表','/finance/receivable-statistics','GET',25,'ACTIVE',1,0),
(5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0), (5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0),
(5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0), (5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0),
(5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0), (5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0),
(5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0),
(5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0) (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0),
(5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'); UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete');
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'); WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:receivable-statistics:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list');
UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:settlement',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:edit',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON';
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT'
WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN')
AND p.permission_code='merchant:finance:statement:confirm';
INSERT INTO baiyue_iam.iam_permission INSERT INTO baiyue_iam.iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
@@ -15443,17 +15511,17 @@ BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 6 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 7 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整';
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
+1 -1
View File
@@ -37,7 +37,7 @@
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。 - 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。 - 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
- 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。 - 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。
- 应付费用来源、成本登记和应付结算单引用的供应商必须按费用项目映射到单一供应商类别,并通过 Warehouse 校验 ID、`ACTIVE` 状态和类别;名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。 - 应付费用来源、成本登记和应付结算单引用的供应商必须通过 Warehouse 校验 ID 与 `ACTIVE` 状态;费用项目 `supplierCategory` 为空时允许任意启用供应商,非空时严格校验类别,不得从费用编码或名称猜测。名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或非空类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。
- 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。 - 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。
- 修改 Finance 导出字段、筛选或 `exportType` 时,同步检查 `FinanceExportProviderService`、File `ExportDefinitionRegistry`/`ExportProviderClient`、Admin `modules/finance/` 与 `modules/downloads/`、导出权限和双方契约测试;页面只创建异步下载任务,不绕过 File 导出中心直接拼装业务文件。 - 修改 Finance 导出字段、筛选或 `exportType` 时,同步检查 `FinanceExportProviderService`、File `ExportDefinitionRegistry`/`ExportProviderClient`、Admin `modules/finance/` 与 `modules/downloads/`、导出权限和双方契约测试;页面只创建异步下载任务,不绕过 File 导出中心直接拼装业务文件。
@@ -14,7 +14,7 @@ public interface WarehouseSupplierClient {
ApiResponse<SupplierSnapshot> get(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<SupplierSnapshot> get(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId, @PathVariable long supplierId,
@RequestParam(defaultValue = "true") boolean requireActive, @RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam String category); @RequestParam(required = false) String category);
record SupplierSnapshot(long supplierId, String name, String category, String status) {} record SupplierSnapshot(long supplierId, String name, String category, String status) {}
} }
@@ -131,7 +131,7 @@ public class FinanceFeeSourceEventService {
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整"); if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("PAYABLE".equals(value.direction())) { if ("PAYABLE".equals(value.direction())) {
if (value.supplierId() == null || value.supplierId() <= 0 if (value.supplierId() == null || value.supplierId() <= 0
|| blank(value.supplierName()) || blank(value.supplierCategory())) { || blank(value.supplierName())) {
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整"); throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
} }
var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory()); var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory());
@@ -177,7 +177,7 @@ public class FinanceService {
String supplierName = null; String supplierName = null;
if (request.supplierId() != null) { if (request.supplierId() != null) {
if (request.supplierId() <= 0) throw bad("供应商ID无效"); if (request.supplierId() <= 0) throw bad("供应商ID无效");
throw bad("人工成本缺少显式供应商类别,无法校验供应商"); supplierName = supplierValidator.requireActive(request.supplierId(), null).name();
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) { } else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
throw bad("供应商名称必须来自Warehouse供应商主数据"); throw bad("供应商名称必须来自Warehouse供应商主数据");
} }
@@ -164,13 +164,8 @@ public class FinanceSettlementService {
} }
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0"); if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
if (sources.stream().anyMatch(row -> !StringUtils.hasText(text(row, "supplier_category")))) { String supplierCategory = supplierCategoryConstraint(sources);
throw error("FIN_409", "应付费用来源缺少供应商类别快照"); String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name();
}
Set<String> supplierCategories = sources.stream().map(row -> text(row, "supplier_category"))
.collect(java.util.stream.Collectors.toSet());
if (supplierCategories.size() != 1) throw error("FIN_409", "应付结算费用来源必须属于同一供应商类别");
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategories.iterator().next()).name();
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName")) String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId); .filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
LocalDate generatedDate = LocalDate.now(); LocalDate generatedDate = LocalDate.now();
@@ -384,15 +379,18 @@ public class FinanceSettlementService {
} }
private String payableSupplierCategory(long customerId, long settlementId) { private String payableSupplierCategory(long customerId, long settlementId) {
Set<String> categories = jdbc.queryForList( return supplierCategoryConstraint(jdbc.queryForList(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category FROM fin_settlement_line sl " + "SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category FROM fin_settlement_line sl " +
"JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " + "JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=?", "WHERE sl.tenant_id=? AND sl.settlement_id=?",
customerId, settlementId).stream() customerId, settlementId));
.map(row -> text(row, "supplier_category")) }
.collect(java.util.stream.Collectors.toSet());
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致"); private String supplierCategoryConstraint(List<Map<String, Object>> sources) {
return categories.iterator().next(); Set<String> categories = sources.stream().map(row -> text(row, "supplier_category"))
.filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
if (categories.size() > 1) throw error("FIN_409", "应付费用来源供应商类别约束不一致");
return categories.isEmpty() ? null : categories.iterator().next();
} }
private Long reserve(long customerId, long operatorId, String operation, String key, private Long reserve(long customerId, long operatorId, String operation, String key,
@@ -29,7 +29,8 @@ public class FinanceSupplierValidator {
var supplier = response == null ? null : response.data(); var supplier = response == null ? null : response.data();
if (response == null || !response.successful() || supplier == null if (response == null || !response.successful() || supplier == null
|| supplier.supplierId() != supplierId || !"ACTIVE".equals(supplier.status()) || supplier.supplierId() != supplierId || !"ACTIVE".equals(supplier.status())
|| !normalizedCategory.equals(supplier.category()) || !StringUtils.hasText(supplier.name())) { || (normalizedCategory != null && !normalizedCategory.equals(supplier.category()))
|| !StringUtils.hasText(supplier.name())) {
throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配"); throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配");
} }
return supplier; return supplier;
@@ -42,7 +43,8 @@ public class FinanceSupplierValidator {
} }
private static String normalizeCategory(String category) { private static String normalizeCategory(String category) {
String normalized = category == null ? "" : category.trim().toUpperCase(Locale.ROOT); if (!StringUtils.hasText(category)) return null;
String normalized = category.trim().toUpperCase(Locale.ROOT);
if (!CATEGORIES.contains(normalized)) { if (!CATEGORIES.contains(normalized)) {
throw new FinanceEventException("FIN_409", "费用来源供应商类别无效"); throw new FinanceEventException("FIN_409", "费用来源供应商类别无效");
} }
@@ -103,6 +103,10 @@ CREATE TABLE IF NOT EXISTS fin_bill (
remark VARCHAR(500) NULL COMMENT '平台账单内部备注', remark VARCHAR(500) NULL COMMENT '平台账单内部备注',
issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空', issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空',
issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空', issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空',
customer_confirmation_status VARCHAR(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认',
customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID',
customer_confirmed_by_name VARCHAR(128) NULL COMMENT '客户确认人姓名快照',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空', voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空',
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空', voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空',
void_reason VARCHAR(500) NULL COMMENT '账单作废原因', void_reason VARCHAR(500) NULL COMMENT '账单作废原因',
@@ -336,6 +340,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction), UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status), KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status), KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
KEY idx_fin_settlement_receivable_statistics (direction, currency_code, generated_date, status, tenant_id),
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0), CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')), CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
@@ -14,10 +14,11 @@ class FinanceExportContractTest {
private static final List<String> TYPES = List.of( private static final List<String> TYPES = List.of(
"FINANCE_CUSTOMER_ACCOUNT_LIST", "FINANCE_CUSTOMER_ACCOUNT_LEDGER", "FINANCE_FEE_SOURCE_LIST", "FINANCE_CUSTOMER_ACCOUNT_LIST", "FINANCE_CUSTOMER_ACCOUNT_LEDGER", "FINANCE_FEE_SOURCE_LIST",
"FINANCE_RECONCILIATION_STATEMENT_LIST", "FINANCE_RECEIVABLE_SETTLEMENT_LIST", "FINANCE_RECONCILIATION_STATEMENT_LIST", "FINANCE_RECEIVABLE_SETTLEMENT_LIST",
"FINANCE_RECEIVABLE_SETTLEMENT_DETAIL", "FINANCE_PAYABLE_SETTLEMENT_LIST", "FINANCE_PAYABLE_SETTLEMENT_DETAIL"); "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL", "FINANCE_PAYABLE_SETTLEMENT_LIST", "FINANCE_PAYABLE_SETTLEMENT_DETAIL",
"FINANCE_RECEIVABLE_STATISTICS");
@Test @Test
void keepsOpenApiAndAllFileDefinitionsOnTheSameEightTypes() throws Exception { void keepsOpenApiAndAllFileDefinitionsOnTheSameNineTypes() throws Exception {
String openApi = Files.readString(resolve("docs", "财务管理", "openapi", "finance-phase1-five-pages-v1.yaml"), StandardCharsets.UTF_8); String openApi = Files.readString(resolve("docs", "财务管理", "openapi", "finance-phase1-five-pages-v1.yaml"), StandardCharsets.UTF_8);
String application = Files.readString(resolve("baiyue-firstleg", "file", "src", "main", "resources", "application.yml"), StandardCharsets.UTF_8); String application = Files.readString(resolve("baiyue-firstleg", "file", "src", "main", "resources", "application.yml"), StandardCharsets.UTF_8);
String local = Files.readString(resolve("baiyue-firstleg", "config", "nacos", "local", "file-service.yml"), StandardCharsets.UTF_8); String local = Files.readString(resolve("baiyue-firstleg", "config", "nacos", "local", "file-service.yml"), StandardCharsets.UTF_8);
@@ -46,10 +46,10 @@ class FinanceFeeSourceEventServiceTest {
void importsApprovedSettlementInboundFee() { void importsApprovedSettlementInboundFee() {
FinanceFeeSourceEvent event = inboundEvent("published-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); FinanceFeeSourceEvent event = inboundEvent("published-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent(); givenNewEvent();
when(firstleg.inbound("test-token", 21, 31)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null))); when(firstleg.inbound("test-token", 21, 31, 1)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L); assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).inbound("test-token", 21, 31); verify(firstleg).inbound("test-token", 21, 31, 1);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
} }
@@ -57,10 +57,10 @@ class FinanceFeeSourceEventServiceTest {
void importsApprovedSettlementContainerFeeWithCustomerOwnership() { void importsApprovedSettlementContainerFeeWithCustomerOwnership() {
FinanceFeeSourceEvent event = containerEvent("container-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); FinanceFeeSourceEvent event = containerEvent("container-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent(); givenNewEvent();
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L))); when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
assertThat(service.consume(event)).isEqualTo(101L); assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).container("test-token", 41, 31); verify(firstleg).container("test-token", 41, 31, 1);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L)); verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
} }
@@ -69,12 +69,12 @@ class FinanceFeeSourceEventServiceTest {
FinanceFeeSourceEvent event = containerEvent("container-payable-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); FinanceFeeSourceEvent event = containerEvent("container-payable-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent(); givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1, var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有往来单位", null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
"TRUCKING", 61L, "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11), "TRUCKING", 61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED", "CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null, LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-payable", 1); "source-hash-payable", 1);
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot)); when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L); assertThat(service.consume(event)).isEqualTo(101L);
@@ -82,14 +82,30 @@ class FinanceFeeSourceEventServiceTest {
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class); ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString()) assertThat(values.getValue()[14].toString())
.contains("\"supplierId\":71", "\"supplierName\":\"现有往来单位\"", "\"supplierCategory\":\"TRUCKING\""); .contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\"");
}
@Test
void importsPayableWithUnrestrictedActiveSupplier() {
FinanceFeeSourceEvent event = containerEvent("container-unrestricted-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
null, 61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-unrestricted", 1);
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).requireActive(71L, null);
} }
@Test @Test
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() { void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7); FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
givenNewEvent(); givenNewEvent();
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L); assertThat(service.consume(event)).isEqualTo(101L);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L)); verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
@@ -100,7 +116,7 @@ class FinanceFeeSourceEventServiceTest {
void voidMarksExistingVersionWithoutDeletingHistory() { void voidMarksExistingVersionWithoutDeletingHistory() {
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7); FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2))) when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE"))); .thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
@@ -135,7 +151,7 @@ class FinanceFeeSourceEventServiceTest {
void rejectsSnapshotBelongingToAnotherCustomer() { void rejectsSnapshotBelongingToAnotherCustomer() {
FinanceFeeSourceEvent event = inboundEvent("tenant-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); FinanceFeeSourceEvent event = inboundEvent("tenant-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("tenant-1"))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("tenant-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null))); when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null)));
assertThatThrownBy(() -> service.consume(event)) assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class) .isInstanceOf(FinanceEventException.class)
@@ -147,7 +163,7 @@ class FinanceFeeSourceEventServiceTest {
void rejectsApprovedButUnpublishedNonVoidedFee() { void rejectsApprovedButUnpublishedNonVoidedFee() {
FinanceFeeSourceEvent event = inboundEvent("draft-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); FinanceFeeSourceEvent event = inboundEvent("draft-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("draft-1"))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("draft-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn( when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "DRAFT", new BigDecimal("100.00")))); ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "DRAFT", new BigDecimal("100.00"))));
assertThatThrownBy(() -> service.consume(event)) assertThatThrownBy(() -> service.consume(event))
@@ -209,7 +225,7 @@ class FinanceFeeSourceEventServiceTest {
private void assertInvalidAmount(String eventId, BigDecimal amount) { private void assertInvalidAmount(String eventId, BigDecimal amount) {
FinanceFeeSourceEvent event = inboundEvent(eventId, "FIRSTLEG_FEE_PUBLISHED", 1, 7); FinanceFeeSourceEvent event = inboundEvent(eventId, "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq(eventId))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq(eventId))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn( when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "PUBLISHED", amount))); ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "PUBLISHED", amount)));
assertThatThrownBy(() -> service.consume(event)) assertThatThrownBy(() -> service.consume(event))
@@ -139,7 +139,7 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("currency_code")).thenReturn("CNY"); when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED"); when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE"); when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"partnerId\":79,\"partnerName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}"); when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":79,\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00")); when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("statement_generated")).thenReturn(false); when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0)); return List.of(mapper.mapRow(rs, 0));
@@ -318,8 +318,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.summary().direction()).isEqualTo("PAYABLE"); assertThat(result.summary().direction()).isEqualTo("PAYABLE");
assertThat(result.summary().supplierId()).isEqualTo(20L); assertThat(result.summary().supplierId()).isEqualTo(20L);
assertThat(result.summary().totalAmount()).isEqualByComparingTo("100.00"); assertThat(result.summary().totalAmount()).isEqualByComparingTo("100.00");
verify(supplierValidator).categoryFor("FBA_HEAD_FREIGHT", "头程运费"); verify(supplierValidator).requireActive(20L, null);
verify(supplierValidator).requireActive(20L, "BOOKING");
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L), verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L),
eq(new BigDecimal("100.00")), eq("CNY")); eq(new BigDecimal("100.00")), eq("CNY"));
} }
@@ -541,8 +540,8 @@ class FinanceSettlementBehaviorTest {
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient, private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient,
FinanceSupplierValidator supplierValidator) { FinanceSupplierValidator supplierValidator) {
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT f.fee_item_code"), anyLong(), anyLong())) org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
.thenReturn(List.of(Map.of("fee_item_code", "FBA_HEAD_FREIGHT", "fee_item_name", "头程运费"))); .thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class); FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001"); org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001"); org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
@@ -552,7 +551,6 @@ class FinanceSettlementBehaviorTest {
private static FinanceSupplierValidator supplierValidator() { private static FinanceSupplierValidator supplierValidator() {
FinanceSupplierValidator supplierValidator = org.mockito.Mockito.mock(FinanceSupplierValidator.class); FinanceSupplierValidator supplierValidator = org.mockito.Mockito.mock(FinanceSupplierValidator.class);
org.mockito.Mockito.lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("BOOKING");
org.mockito.Mockito.lenient().when(supplierValidator.requireActive(anyLong(), any())) org.mockito.Mockito.lenient().when(supplierValidator.requireActive(anyLong(), any()))
.thenReturn(new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot( .thenReturn(new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
20L, "供应商20", "BOOKING", "ACTIVE")); 20L, "供应商20", "BOOKING", "ACTIVE"));
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
} }
} }
assertThat(tableCount).isEqualTo(17); assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(239); assertThat(columnCount).isEqualTo(243);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
} }
@@ -18,8 +18,12 @@ class FinanceSupplierValidatorTest {
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token"); private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
@Test @Test
void rejectsMissingOrInvalidExplicitFeeCategory() { void acceptsMissingCategoryAndRejectsInvalidExplicitCategory() {
assertCode(() -> validator.requireActive(20L, null), "FIN_409"); var unrestricted = new WarehouseSupplierClient.SupplierSnapshot(
20L, "任意启用供应商", "BOOKING", "ACTIVE");
when(client.get("internal-token", 20L, true, null)).thenReturn(ApiResponse.success(unrestricted));
assertThat(validator.requireActive(20L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 20L, true, null);
assertCode(() -> validator.requireActive(20L, "UNKNOWN"), "FIN_409"); assertCode(() -> validator.requireActive(20L, "UNKNOWN"), "FIN_409");
} }
@@ -47,7 +47,6 @@ public final class ContainerDtos {
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑") @Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
public record SaveRequest( public record SaveRequest(
@Size(max = 64) String containerNo, @Size(max = 64) String containerNo,
@NotBlank @Size(max = 64) String cabinetNo,
@NotNull @Positive Long warehouseId, @NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName, @Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo, @Size(max = 128) String billOfLadingNo,
@@ -171,11 +171,11 @@ public class ContainerFeeDetailService {
if (supplierId == null || supplierId <= 0) { if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效供应商"); throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效供应商");
} }
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory()); String category = FeeItemSupplierCategory.normalize(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category); var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId()); value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name()); value.setSupplierName(snapshot.name());
value.setSupplierCategory(snapshot.category()); value.setSupplierCategory(category);
} }
private void refreshSupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item, private void refreshSupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
@@ -139,7 +139,7 @@ public class ContainerService {
ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg()); ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId()); WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany()); var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id); ensureUnique(trim(request.data().containerNo()), current.getCabinetNo(), id);
copy(current, request.data()); copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName()); current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId()); current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId());
@@ -222,7 +222,7 @@ public class ContainerService {
} }
private static void copy(ContainerBookingEntity v, SaveRequest r) { private static void copy(ContainerBookingEntity v, SaveRequest r) {
var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg());
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim()); v.setContainerNo(trim(r.containerNo()));
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName())); v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName()));
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName())); v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType()); v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType());
@@ -104,11 +104,6 @@ public class FeeItemService {
private static void copy(FeeItemEntity value, SaveRequest request) { private static void copy(FeeItemEntity value, SaveRequest request) {
boolean payable = Boolean.TRUE.equals(request.pickupPayable()) boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|| Boolean.TRUE.equals(request.warehousePayable()); || Boolean.TRUE.equals(request.warehousePayable());
if (payable && (request.supplierCategory() == null
|| request.supplierCategory().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"启用应付方向时必须配置供应商类别");
}
value.setCategory(request.category().trim()); value.setCategory(request.category().trim());
value.setTradeScope(request.tradeScope()); value.setTradeScope(request.tradeScope());
value.setFeeCode(request.feeCode() == null || request.feeCode().isBlank() value.setFeeCode(request.feeCode() == null || request.feeCode().isBlank()
@@ -6,7 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
/** 费用项目显式供应商类别校验;未配置的应付费用失败关闭。 */ /** 费用项目可选供应商类别约束校验。 */
public final class FeeItemSupplierCategory { public final class FeeItemSupplierCategory {
private static final Set<String> ALLOWED = Set.of( private static final Set<String> ALLOWED = Set.of(
"BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE"); "BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE");
@@ -23,12 +23,4 @@ public final class FeeItemSupplierCategory {
return normalized; return normalized;
} }
public static String requirePayable(String supplierCategory) {
String category = normalize(supplierCategory);
if (category == null) {
throw new BizException(ResultCode.CONFLICT,
"费用项目未配置供应商类别,不能用于应付费用");
}
return category;
}
} }
@@ -414,11 +414,11 @@ public class InboundFeeDetailService {
if (supplierId == null || supplierId <= 0) { if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付入库费用必须选择有效供应商"); throw new BizException(ResultCode.VALIDATION_FAILED, "应付入库费用必须选择有效供应商");
} }
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory()); String category = FeeItemSupplierCategory.normalize(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category); var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId()); value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name()); value.setSupplierName(snapshot.name());
value.setSupplierCategory(snapshot.category()); value.setSupplierCategory(category);
} }
private void refreshSupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item, private void refreshSupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
@@ -48,7 +48,7 @@ public class TruckAppointmentService {
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");} @Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;} @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return enrich(e);}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);} @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@@ -26,12 +26,9 @@ public class WarehouseSupplierSnapshotService {
return resolve(supplierId, suppliedName, "CUSTOMS"); return resolve(supplierId, suppliedName, "CUSTOMS");
} }
/** 费用来源只提交供应商 ID,名称与类别均以 Warehouse 返回快照为准。 */ /** 费用来源只提交供应商 ID;类别为空时仅校验 ACTIVE,名称始终取 Warehouse 快照。 */
public WarehouseSupplierClient.Snapshot fee(Long supplierId, String category) { public WarehouseSupplierClient.Snapshot fee(Long supplierId, String category) {
if (!StringUtils.hasText(category)) { return resolve(supplierId, null, FeeItemSupplierCategory.normalize(category));
throw new BizException(ResultCode.CONFLICT, "费用项目未配置供应商类别");
}
return resolve(supplierId, null, category);
} }
private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName, private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName,
@@ -46,7 +43,7 @@ public class WarehouseSupplierSnapshotService {
if (response == null || !response.successful() || response.data() == null if (response == null || !response.successful() || response.data() == null
|| response.data().supplierId() != supplierId || response.data().supplierId() != supplierId
|| !"ACTIVE".equals(response.data().status()) || !"ACTIVE".equals(response.data().status())
|| !category.equals(response.data().category()) || (StringUtils.hasText(category) && !category.equals(response.data().category()))
|| !StringUtils.hasText(response.data().name())) { || !StringUtils.hasText(response.data().name())) {
throw new BizException(ResultCode.CONFLICT, "所选供应商不存在、未启用或类别不匹配"); throw new BizException(ResultCode.CONFLICT, "所选供应商不存在、未启用或类别不匹配");
} }
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
category VARCHAR(64) NOT NULL COMMENT '费用分类', category VARCHAR(64) NOT NULL COMMENT '费用分类',
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码', fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭', supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称', fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与', pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与', pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
@@ -23,7 +23,7 @@ DELIMITER ;
CALL phase2_publish_add_column('fl_fee_item', 'fee_code', CALL phase2_publish_add_column('fl_fee_item', 'fee_code',
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope"); "VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
CALL phase2_publish_add_column('fl_fee_item', 'supplier_category', CALL phase2_publish_add_column('fl_fee_item', 'supplier_category',
"VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭' AFTER fee_code"); "VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商' AFTER fee_code");
ALTER TABLE fl_fee_item ALTER TABLE fl_fee_item
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id',
COMMENT = '平台公共费用项目主数据'; COMMENT = '平台公共费用项目主数据';
@@ -304,8 +304,6 @@ components:
currency: {type: string, pattern: '^[A-Z]{3}$'} currency: {type: string, pattern: '^[A-Z]{3}$'}
amount: {type: number, minimum: 0} amount: {type: number, minimum: 0}
supplierId: {type: string, nullable: true} supplierId: {type: string, nullable: true}
supplierName: {type: string, maxLength: 200, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
remark: {type: string, maxLength: 500, nullable: true} remark: {type: string, maxLength: 500, nullable: true}
InboundFeeDetailVersionRequest: InboundFeeDetailVersionRequest:
type: object type: object
@@ -332,7 +330,7 @@ components:
direction: {type: string, enum: [RECEIVABLE, PAYABLE]} direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
supplierId: {type: string, nullable: true} supplierId: {type: string, nullable: true}
supplierName: {type: string, nullable: true, readOnly: true} supplierName: {type: string, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true} supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: 费用项目供应商类别约束快照;为空表示仅要求供应商ACTIVE}
businessDate: {type: string, format: date} businessDate: {type: string, format: date}
customerId: {type: string, nullable: true} customerId: {type: string, nullable: true}
customerName: {type: string, nullable: true} customerName: {type: string, nullable: true}
@@ -100,7 +100,7 @@ components:
category: {type: string, maxLength: 64} category: {type: string, maxLength: 64}
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]} tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'} feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: pickupPayable或warehousePayable为true时必填} supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
feeTypeName: {type: string, maxLength: 128} feeTypeName: {type: string, maxLength: 128}
pickupPayable: {type: boolean} pickupPayable: {type: boolean}
pickupReceivable: {type: boolean} pickupReceivable: {type: boolean}
@@ -116,7 +116,7 @@ components:
category: {type: string} category: {type: string}
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]} tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
feeCode: {type: string} feeCode: {type: string}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true} supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
feeTypeName: {type: string} feeTypeName: {type: string}
pickupPayable: {type: boolean} pickupPayable: {type: boolean}
pickupReceivable: {type: boolean} pickupReceivable: {type: boolean}
@@ -70,6 +70,30 @@ paths:
requireInboundCodePrefix: {type: boolean} requireInboundCodePrefix: {type: boolean}
responses: responses:
'200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix} '200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix}
/internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot:
get:
summary: Finance 按入库费用来源版本精确回查不可变快照
description: sourceVersion 必填;只返回 sourceFeeId 与该版本精确匹配的已发布费用,不回退到其他版本。
security:
- internalServiceToken: []
parameters:
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: feeId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
responses:
'200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误}
/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:
get:
summary: Finance 按柜次费用来源版本精确回查不可变快照
description: sourceVersion 必填;只返回 sourceFeeId 与该版本精确匹配的已发布费用,不回退到当前可变版本。
security:
- internalServiceToken: []
parameters:
- {name: containerId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: feeId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
responses:
'200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误}
/api/platform/v1/first-leg/customer-dimension-config: /api/platform/v1/first-leg/customer-dimension-config:
get: get:
summary: 查询客户固定四箱规尺寸配置 summary: 查询客户固定四箱规尺寸配置
@@ -1495,10 +1519,9 @@ components:
description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED
ContainerSaveRequest: ContainerSaveRequest:
type: object type: object
required: [cabinetNo, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg] required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
properties: properties:
containerNo: {type: string, maxLength: 64, nullable: true} containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string, maxLength: 128} warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, maxLength: 128, nullable: true} billOfLadingNo: {type: string, maxLength: 128, nullable: true}
@@ -1820,3 +1843,39 @@ components:
expectedSourceVersion: {type: integer, minimum: 1} expectedSourceVersion: {type: integer, minimum: 1}
businessDate: {type: string, format: date} businessDate: {type: string, format: date}
amount: {type: number, minimum: 0} amount: {type: number, minimum: 0}
FinanceFeeSourceSnapshot:
type: object
description: Finance 消费的版本化费用来源快照;supplierCategory 为空表示仅约束供应商 ACTIVE,非空才约束明确类别。
required: [tenantId, sourceType, feeId, feeVersion, customerId, customerName, feeItemName, direction, businessDate, currency, totalAmount, reviewStatus, publicationStatus, sourceHash, snapshotVersion]
properties:
tenantId: {type: integer, format: int64}
sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]}
feeId: {type: integer, format: int64}
feeVersion: {type: integer, minimum: 1}
inboundId: {type: integer, format: int64, nullable: true}
inboundCode: {type: string, nullable: true}
orderId: {type: integer, format: int64, nullable: true}
containerId: {type: integer, format: int64, nullable: true}
containerBookingId: {type: integer, format: int64, nullable: true}
containerNo: {type: string, nullable: true}
customerId: {type: integer, format: int64}
customerName: {type: string}
supplierId: {type: integer, format: int64, nullable: true}
supplierName: {type: string, nullable: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时仅要求供应商ACTIVE}
feeItemId: {type: integer, format: int64, nullable: true}
feeItemCode: {type: string, nullable: true}
feeItemName: {type: string}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
businessDate: {type: string, format: date}
currency: {type: string, enum: [CNY]}
totalAmount: {type: number, minimum: 0}
reviewStatus: {type: string, enum: [APPROVED]}
publicationStatus: {type: string, enum: [PUBLISHED]}
publishedAt: {type: string, format: date-time, nullable: true}
chargeableQuantity: {type: number, nullable: true}
chargeUnit: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true}
sourceHash: {type: string}
snapshotVersion: {type: integer, minimum: 1}
@@ -29,7 +29,7 @@ paths:
application/json: application/json:
schema: {$ref: '#/components/schemas/SaveRequest'} schema: {$ref: '#/components/schemas/SaveRequest'}
responses: responses:
'200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} '200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
/api/platform/v1/truck-appointments/{id}: /api/platform/v1/truck-appointments/{id}:
parameters: [{$ref: '#/components/parameters/AppointmentId'}] parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get: get:
@@ -138,6 +138,18 @@ components:
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}} RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}}
schemas: schemas:
DetailEnvelope:
type: object
required: [code, message, data, timestamp]
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]
nullable: true
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
AttachmentRequest: AttachmentRequest:
type: object type: object
required: [fileId, attachmentType] required: [fileId, attachmentType]
@@ -16,19 +16,19 @@ class InternalFinanceFeeSourceControllerTest {
@Test @Test
void rejectsMissingOrWrongInternalTokenBeforeSnapshotLookup() { void rejectsMissingOrWrongInternalTokenBeforeSnapshotLookup() {
assertThatThrownBy(() -> controller.inbound(null, 21L, 31L)) assertThatThrownBy(() -> controller.inbound(null, 21L, 31L, 2))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); .isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
assertThatThrownBy(() -> controller.container("wrong", 41L, 51L)) assertThatThrownBy(() -> controller.container("wrong", 41L, 51L, 2))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); .isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
} }
@Test @Test
void routesAuthorizedSnapshotLookup() { void routesAuthorizedSnapshotLookup() {
controller.inbound("finance-token", 21L, 31L); controller.inbound("finance-token", 21L, 31L, 2);
controller.container("finance-token", 41L, 51L); controller.container("finance-token", 41L, 51L, 3);
verify(service).inbound(21L, 31L); verify(service).inbound(21L, 31L, 2);
verify(service).container(41L, 51L); verify(service).container(41L, 51L, 3);
} }
@Test @Test
@@ -30,7 +30,7 @@ class TruckAppointmentControllerContractTest {
} }
} }
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null");} @Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");}
@Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","BILL_OF_LADING","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:");} @Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","BILL_OF_LADING","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries( Map<String,String> expected=Map.ofEntries(
@@ -21,7 +21,7 @@ class ContainerFeeDetailRepositoryTest {
"getReviewStatus", "getReviewerId", "getReviewerName", "getReviewedAt", "getReviewStatus", "getReviewerId", "getReviewerName", "getReviewedAt",
"getReviewReason", "getUpdatedBy", "getUpdatedAt") "getReviewReason", "getUpdatedBy", "getUpdatedAt")
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot", .doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
"getDirection", "getPartnerId", "getPartnerName", "getCurrency", "getDirection", "getSupplierId", "getSupplierName", "getSupplierCategory", "getCurrency",
"getRemark"); "getRemark");
} }
} }
@@ -9,19 +9,21 @@ import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceMigrationContractTest { class FinanceFeeSourceMigrationContractTest {
@Test @Test
void freshSchemaAndMigrationExposeInboundFeePartnerSnapshotsUsedByAllFeeFlows() throws Exception { void freshSchemaAndMigrationExposeInboundFeeSupplierSnapshotsUsedByAllFeeFlows() throws Exception {
String baseline = new ClassPathResource("db/phase2-fee-item-and-inbound-detail.sql") String baseline = new ClassPathResource("db/phase2-fee-item-and-inbound-detail.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
String migration = new ClassPathResource("db/finance-fee-source-migration.sql") String migration = new ClassPathResource("db/finance-fee-source-migration.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(baseline).contains( assertThat(baseline).contains(
"partner_id BIGINT UNSIGNED NULL", "supplier_id BIGINT UNSIGNED NULL",
"partner_name VARCHAR(200) NULL"); "supplier_name VARCHAR(200) NULL",
"supplier_category VARCHAR(16) NULL");
assertThat(migration).contains( assertThat(migration).contains(
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'", "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name'", "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name'",
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整"); "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整");
} }
@Test @Test
@@ -32,10 +34,12 @@ class FinanceFeeSourceMigrationContractTest {
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash", assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by", "uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail", "idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"source_version", "partner_id", "partner_name", "source_version", "supplier_id", "supplier_name", "supplier_category",
"firstleg_finance_add_column('fl_container_fee_detail', 'partner_id'", "RECEIVABLE", "PAYABLE", "CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'", "uk_container_fee_source_version",
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整", "firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema"); "chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
} }
} }
@@ -152,8 +152,9 @@ class Phase3FinalUnifiedSqlContractTest {
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150); assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(sql) assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_id'") .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_name'"); .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'");
assertThat(sql.indexOf("CALL apply_queued_comments();", assertThat(sql.indexOf("CALL apply_queued_comments();",
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"))) sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")); .isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository; import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository; import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -34,8 +35,10 @@ class ContainerFeeDetailServiceTest {
private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class); private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class);
private final FeeItemRepository feeItems = mock(FeeItemRepository.class); private final FeeItemRepository feeItems = mock(FeeItemRepository.class);
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class); private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class);
private final ContainerFeeDetailService service = private final ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox); new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots);
@Test @Test
void rejectsContainerThatDoesNotContainCustomerOrders() { void rejectsContainerThatDoesNotContainCustomerOrders() {
@@ -58,8 +61,13 @@ class ContainerFeeDetailServiceTest {
approved.setId(51L); approved.setId(51L);
approved.setTenantId(10001L); approved.setTenantId(10001L);
approved.setLoadingPlanId(41L); approved.setLoadingPlanId(41L);
approved.setFeeItemId(11L);
approved.setDirection("RECEIVABLE");
approved.setSourceVersion(1); approved.setSourceVersion(1);
when(repository.find(10001L, 41L, 51L)).thenReturn(approved); when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
when(feeItems.findItem(11L)).thenReturn(item());
when(repository.updateSupplierSnapshot(eq(10001L), eq(41L), eq(51L),
eq(null), eq(null), eq(null), eq(9L))).thenReturn(1);
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1); when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
service.approve(10001L, 41L, 51L, 9L, "admin"); service.approve(10001L, 41L, 51L, 9L, "admin");
@@ -89,7 +97,7 @@ class ContainerFeeDetailServiceTest {
assertThat(result.getReviewStatus()).isEqualTo("PENDING"); assertThat(result.getReviewStatus()).isEqualTo("PENDING");
assertThat(result.getFeeItemId()).isEqualTo(11L); assertThat(result.getFeeItemId()).isEqualTo(11L);
assertThat(result.getDirection()).isEqualTo("PAYABLE"); assertThat(result.getDirection()).isEqualTo("PAYABLE");
assertThat(result.getPartnerId()).isEqualTo(71L); assertThat(result.getSupplierId()).isEqualTo(71L);
assertThat(result.getCurrency()).isEqualTo("CNY"); assertThat(result.getCurrency()).isEqualTo("CNY");
assertThat(result.getRemark()).isEqualTo("原费用备注"); assertThat(result.getRemark()).isEqualTo("原费用备注");
verify(feeItems, never()).findItem(anyLong()); verify(feeItems, never()).findItem(anyLong());
@@ -162,16 +170,16 @@ class ContainerFeeDetailServiceTest {
} }
@Test @Test
void payableRequiresCounterpartySnapshot() { void payableRequiresSupplierSnapshot() {
ownedPlan(); ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item()); when(feeItems.findItem(11L)).thenReturn(item());
var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", var missingSupplier = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
null, null, "整柜拖车费"); null, "整柜拖车费");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner)) assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingSupplier))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位"); .isInstanceOf(BizException.class).hasMessageContaining("必须选择有效供应商");
verify(repository, never()).insert(any()); verify(repository, never()).insert(any());
} }
@@ -182,13 +190,13 @@ class ContainerFeeDetailServiceTest {
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
71L, "不应保留", "整柜操作费"); 71L, "整柜操作费");
service.create(10001L, 41L, 9L, request); service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class); ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).insert(captor.capture()); verify(repository).insert(captor.capture());
assertThat(captor.getValue().getPartnerId()).isNull(); assertThat(captor.getValue().getSupplierId()).isNull();
assertThat(captor.getValue().getPartnerName()).isNull(); assertThat(captor.getValue().getSupplierName()).isNull();
} }
private void ownedPlan() { private void ownedPlan() {
@@ -220,8 +228,8 @@ class ContainerFeeDetailServiceTest {
value.setFeeItemCodeSnapshot("TRUCKING"); value.setFeeItemCodeSnapshot("TRUCKING");
value.setFeeItemNameSnapshot("拖车费"); value.setFeeItemNameSnapshot("拖车费");
value.setDirection("PAYABLE"); value.setDirection("PAYABLE");
value.setPartnerId(71L); value.setSupplierId(71L);
value.setPartnerName("现有往来单位"); value.setSupplierName("现有供应商");
value.setCurrency("CNY"); value.setCurrency("CNY");
value.setRemark("原费用备注"); value.setRemark("原费用备注");
value.setBusinessDate(LocalDate.of(2026, 8, 11)); value.setBusinessDate(LocalDate.of(2026, 8, 11));
@@ -239,6 +247,6 @@ class ContainerFeeDetailServiceTest {
private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) { private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
71L, " 现有往来单位 ", "整柜拖车费"); 71L, "整柜拖车费");
} }
} }
@@ -104,30 +104,30 @@ class ContainerServiceTest {
} }
@Test @Test
@DisplayName("智能装柜草稿阶段允许完整编辑") @DisplayName("智能装柜草稿阶段完整编辑时保留稳定柜次号")
void updatesAllFieldsWhileEditing() { void updatesAllFieldsWhileEditing() {
ContainerBookingEntity current = booking("EDITING", 2); ContainerBookingEntity current = booking("EDITING", 2);
when(repository.find(11L)).thenReturn(current); when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2), when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1); org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))); service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request()));
assertThat(current.getCabinetNo()).isEqualTo("柜-NEW"); assertThat(current.getCabinetNo()).isEqualTo("柜-001");
verify(repository).updateDraft(current, 2, 9L, "admin"); verify(repository).updateDraft(current, 2, 9L, "admin");
} }
@Test @Test
@DisplayName("待装柜阶段允许完整编辑") @DisplayName("待装柜阶段完整编辑时保留稳定柜次号")
void updatesAllFieldsWhileWaitingForLoading() { void updatesAllFieldsWhileWaitingForLoading() {
ContainerBookingEntity current = booking("WAIT_LOADING", 2); ContainerBookingEntity current = booking("WAIT_LOADING", 2);
when(repository.find(11L)).thenReturn(current); when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2), when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1); org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜 NEW"))); service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request()));
assertThat(current.getCabinetNo()).isEqualTo("柜 NEW"); assertThat(current.getCabinetNo()).isEqualTo("柜-001");
verify(repository).updateDraft(current, 2, 9L, "admin"); verify(repository).updateDraft(current, 2, 9L, "admin");
} }
@@ -137,7 +137,7 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(booking("LOADING", 2)); when(repository.find(11L)).thenReturn(booking("LOADING", 2));
assertThatThrownBy(() -> service.updateDraft( assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")))) 11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("只能修改集装箱号和航次"); .hasMessageContaining("只能修改集装箱号和航次");
@@ -240,7 +240,7 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(booking("EDITING", 3)); when(repository.find(11L)).thenReturn(booking("EDITING", 3));
assertThatThrownBy(() -> service.updateDraft( assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")))) 11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试"); .hasMessageContaining("刷新后重试");
} }
@@ -394,8 +394,8 @@ class ContainerServiceTest {
return value; return value;
} }
private static SaveRequest request(String cabinetNo) { private static SaveRequest request() {
return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮", return new SaveRequest("MSCU1234567", 101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", "V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A", "ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null, LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
@@ -43,6 +43,8 @@ class FeeManagementServiceTest {
private FbaPricingRepository pricingRepository; private FbaPricingRepository pricingRepository;
@MockBean @MockBean
private FinanceFeeSourceOutboxService financeOutbox; private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private WarehouseSupplierSnapshotService suppliers;
@Resource @Resource
private FeeItemService feeItems; private FeeItemService feeItems;
@Resource @Resource
@@ -96,6 +98,8 @@ class FeeManagementServiceTest {
assertThat(created.getId()).isEqualTo(11L); assertThat(created.getId()).isEqualTo(11L);
assertThat(created.getAllocationFee()).isTrue(); assertThat(created.getAllocationFee()).isTrue();
assertThat(created.getWarehouseReceivable()).isTrue(); assertThat(created.getWarehouseReceivable()).isTrue();
assertThat(created.getWarehousePayable()).isTrue();
assertThat(created.getSupplierCategory()).isNull();
assertThat(created.getFeeCode()).startsWith("MANUAL_"); assertThat(created.getFeeCode()).startsWith("MANUAL_");
assertThat(created.getTenantId()).isZero(); assertThat(created.getTenantId()).isZero();
assertThat(created.getCreatedByName()).isEqualTo("admin"); assertThat(created.getCreatedByName()).isEqualTo("admin");
@@ -327,9 +331,13 @@ class FeeManagementServiceTest {
pending.setId(31L); pending.setId(31L);
pending.setTenantId(10001L); pending.setTenantId(10001L);
pending.setInboundId(21L); pending.setInboundId(21L);
pending.setFeeItemId(11L);
pending.setDirection("RECEIVABLE");
pending.setSourceType("MANUAL"); pending.setSourceType("MANUAL");
pending.setSourceFeeVersion(1); pending.setSourceFeeVersion(1);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending); when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending);
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.updateDetail(pending)).thenReturn(1);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1); when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin"); details.approve(10001L, 21L, 31L, 9L, "admin");
@@ -347,9 +355,12 @@ class FeeManagementServiceTest {
approved.setTenantId(10001L); approved.setTenantId(10001L);
approved.setInboundId(21L); approved.setInboundId(21L);
approved.setFeeItemId(11L); approved.setFeeItemId(11L);
approved.setDirection("RECEIVABLE");
approved.setSourceType("MANUAL"); approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1); approved.setSourceFeeVersion(1);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved); when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.updateDetail(approved)).thenReturn(1);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1); when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin"); details.approve(10001L, 21L, 31L, 9L, "admin");
@@ -59,6 +59,7 @@ class TruckAppointmentServiceTest {
"Canonical Customer", "ACTIVE", null, null)); "Canonical Customer", "ACTIVE", null, null));
when(suppliers.trucking(any(), any())).thenReturn( when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE")); new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE"));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
} }
@Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() { @Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
@@ -100,7 +101,7 @@ class TruckAppointmentServiceTest {
when(request.loadingPlanId()).thenReturn(null); when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null); when(request.warehouseId()).thenReturn(null);
service.create(7L, "admin", request); TruckAppointmentEntity created = service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture()); verify(repository).insert(entity.capture());
@@ -110,6 +111,7 @@ class TruckAppointmentServiceTest {
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer"); assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier"); assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001"); assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
assertThat(created.getAttachmentCount()).isZero();
verifyNoInteractions(oceanBookings, containers, loadingPlans); verifyNoInteractions(oceanBookings, containers, loadingPlans);
} }
@@ -42,6 +42,16 @@ class WarehouseSupplierSnapshotServiceTest {
verify(client).get("internal-token", 7L, true, "BOOKING"); verify(client).get("internal-token", 7L, true, "BOOKING");
} }
@Test
void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() {
when(client.get("internal-token", 8L, true, null))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE")));
assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 8L, true, null);
}
@Test @Test
void rejectsClientNameThatDiffersFromWarehouseSnapshot() { void rejectsClientNameThatDiffersFromWarehouseSnapshot() {
when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS"))) when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS")))
@@ -22,11 +22,14 @@ VALUES
(5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0), (5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0),
(5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0), (5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0),
(5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0), (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0),
(5105045,0,'PLATFORM','MENU',5105000,'platform:finance:receivable-statistics:list','应收款统计表','/finance/receivable-statistics','GET',25,'ACTIVE',1,0),
(5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0), (5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0),
(5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0), (5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0),
(5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0), (5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0),
(5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0),
(5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0) (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0),
(5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0),
(5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
permission_name=VALUES(permission_name), route_path=VALUES(route_path), permission_name=VALUES(permission_name), route_path=VALUES(route_path),
@@ -62,6 +65,7 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
'platform:finance:settlement-ap:list','platform:finance:customer-account:export', 'platform:finance:settlement-ap:list','platform:finance:customer-account:export',
'platform:finance:customer-arap:export','platform:finance:customer-statement:export', 'platform:finance:customer-arap:export','platform:finance:customer-statement:export',
'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export', 'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export',
'platform:finance:receivable-statistics:list','platform:finance:receivable-statistics:export',
'platform:download:list', 'platform:download:list',
'platform:firstleg:inbound:measure:view', 'platform:firstleg:inbound:measure:view',
'firstleg:inbound:measurement-review','platform:supplier:list', 'firstleg:inbound:measurement-review','platform:supplier:list',
@@ -83,7 +87,7 @@ UPDATE iam_permission p
UPDATE iam_permission p UPDATE iam_permission p
JOIN (SELECT 'platform:finance:fee-source:review' code, 5105041 parent_id UNION ALL JOIN (SELECT 'platform:finance:fee-source:review' code, 5105041 parent_id UNION ALL
SELECT 'platform:finance:fee-source:settlement', 5105041 UNION ALL SELECT 'platform:finance:fee-source:edit', 5105041 UNION ALL
SELECT 'platform:finance:reconciliation:create', 5105042 UNION ALL SELECT 'platform:finance:reconciliation:create', 5105042 UNION ALL
SELECT 'platform:finance:reconciliation:review', 5105042 UNION ALL SELECT 'platform:finance:reconciliation:review', 5105042 UNION ALL
SELECT 'platform:finance:reconciliation:issue', 5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue', 5105042 UNION ALL
@@ -95,3 +99,10 @@ UPDATE iam_permission p
ON mapping.code=p.permission_code ON mapping.code=p.permission_code
SET p.parent_id=mapping.parent_id SET p.parent_id=mapping.parent_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON';
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3)
FROM iam_role r
JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT'
WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN')
AND p.permission_code='merchant:finance:statement:confirm';
@@ -3,6 +3,14 @@
USE baiyue_iam; USE baiyue_iam;
SET NAMES utf8mb4; SET NAMES utf8mb4;
DELETE rp FROM iam_role_permission rp
JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue');
DELETE FROM iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM'
AND permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue');
DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions; DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE migrate_finance_g1_g2_permissions() CREATE PROCEDURE migrate_finance_g1_g2_permissions()
@@ -12,10 +20,10 @@ BEGIN
FROM iam_permission FROM iam_permission
WHERE (id = 5105000 AND permission_code <> 'platform:finance') WHERE (id = 5105000 AND permission_code <> 'platform:finance')
OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review') OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement') OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:edit')
OR (permission_code = 'platform:finance' AND id <> 5105000) OR (permission_code = 'platform:finance' AND id <> 5105000)
OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024) OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025) OR (permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025)
OR (id IN (5105000, 5105024, 5105025) OR (id IN (5105000, 5105024, 5105025)
AND (tenant_id <> 0 OR app_code <> 'PLATFORM')) AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN ) THEN
@@ -31,8 +39,8 @@ BEGIN
'财务管理', '/finance', NULL, 60, 'ACTIVE', 1, 0), '财务管理', '/finance', NULL, 60, 'ACTIVE', 1, 0),
(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review', (5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review',
'审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0), '审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0),
(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement', (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit',
'设置费用来源参与结算', NULL, 'PUT', 160, 'ACTIVE', 1, 0) '编辑财务费用来源', NULL, 'PUT', 160, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path), permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -55,7 +63,7 @@ BEGIN
AND deleted = 0 AND deleted = 0
AND permission_code IN ( AND permission_code IN (
'platform:finance:fee-source:review', 'platform:finance:fee-source:review',
'platform:finance:fee-source:settlement')) <> 2 THEN 'platform:finance:fee-source:edit')) <> 2 THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete'; SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete';
END IF; END IF;
@@ -13,12 +13,12 @@ class AdminPrototypeMenuPermissionSqlContractTest {
private static final String[] FINANCE_MENUS = { private static final String[] FINANCE_MENUS = {
"platform:finance:customer-account:list", "platform:finance:customer-arap:list", "platform:finance:customer-account:list", "platform:finance:customer-arap:list",
"platform:finance:customer-statement:list", "platform:finance:settlement-ar:list", "platform:finance:customer-statement:list", "platform:finance:settlement-ar:list",
"platform:finance:settlement-ap:list" "platform:finance:settlement-ap:list", "platform:finance:receivable-statistics:list"
}; };
private static final String[] FINANCE_EXPORTS = { private static final String[] FINANCE_EXPORTS = {
"platform:finance:customer-account:export", "platform:finance:customer-arap:export", "platform:finance:customer-account:export", "platform:finance:customer-arap:export",
"platform:finance:customer-statement:export", "platform:finance:settlement-ar:export", "platform:finance:customer-statement:export", "platform:finance:settlement-ar:export",
"platform:finance:settlement-ap:export" "platform:finance:settlement-ap:export", "platform:finance:receivable-statistics:export"
}; };
@Test @Test
@@ -32,6 +32,7 @@ class AdminPrototypeMenuPermissionSqlContractTest {
} }
assertThat(sql) assertThat(sql)
.contains("'platform:finance:fee-source:review' code, 5105041 parent_id") .contains("'platform:finance:fee-source:review' code, 5105041 parent_id")
.contains("'platform:finance:fee-source:edit', 5105041")
.contains("'platform:finance:reconciliation:create', 5105042") .contains("'platform:finance:reconciliation:create', 5105042")
.contains("'platform:finance:settlement-ar:create', 5105043") .contains("'platform:finance:settlement-ar:create', 5105043")
.contains("'platform:finance:settlement-ap:create', 5105044") .contains("'platform:finance:settlement-ap:create', 5105044")
@@ -53,10 +54,11 @@ class AdminPrototypeMenuPermissionSqlContractTest {
assertThat(unified).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); assertThat(unified).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'");
} }
assertThat(unified) assertThat(unified)
.contains("'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'") .contains("platform:finance:receivable-statistics:export")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
.contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33")
.contains("Finance 导出和下载中心授权不完整") .contains("Finance 导出和下载中心授权不完整")
.contains("merchant:finance:statement:confirm")
.satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'"))
.isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()")));
} }
@@ -17,7 +17,7 @@ class FinanceG1G2PermissionSqlContractTest {
assertThat(sql) assertThat(sql)
.contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'") .contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'")
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement'") .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'")
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
.contains("ON DUPLICATE KEY UPDATE") .contains("ON DUPLICATE KEY UPDATE")
@@ -35,7 +35,7 @@ class FinanceG1G2PermissionSqlContractTest {
assertThat(sql) assertThat(sql)
.contains("SIGNAL SQLSTATE '45000'") .contains("SIGNAL SQLSTATE '45000'")
.contains("permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025") .contains("permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025")
.contains("tenant_id <> 0 OR app_code <> 'PLATFORM'") .contains("tenant_id <> 0 OR app_code <> 'PLATFORM'")
.contains("parent_id = 5105000") .contains("parent_id = 5105000")
.contains("permission_type = 'MENU'") .contains("permission_type = 'MENU'")
@@ -52,7 +52,7 @@ class FinanceG1G2PermissionSqlContractTest {
assertThat(financeSection) assertThat(financeSection)
.contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'") .contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'")
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement'") .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'")
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
.contains("permission_code IN (\n 'platform:finance',") .contains("permission_code IN (\n 'platform:finance',")
@@ -63,9 +63,9 @@ class FinanceG1G2PermissionSqlContractTest {
assertThat(sql) assertThat(sql)
.contains("platform:finance:settlement-ar:list") .contains("platform:finance:settlement-ar:list")
.contains("platform:finance:settlement-ap:list") .contains("platform:finance:settlement-ap:list")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27") .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
.contains("rp.role_id=4000001") .contains("rp.role_id=4000001")
.contains("LIKE 'platform:finance:%')) <> 28") .contains("LIKE 'platform:finance:%')) <> 33")
.contains("平台管理员 Finance 授权不完整"); .contains("平台管理员 Finance 授权不完整");
} }
@@ -7,8 +7,9 @@ import { cleanupSucceeded, isAcceptanceFeeItem } from './fee-items.mjs'
const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8')
test('fee detail binding explicitly sends the settlement inclusion contract field', () => { test('fee detail binding does not send the removed settlement switch', () => {
assert.match(source, /const detailPayload = \{[\s\S]*settlementIncluded: false/) assert.match(source, /const detailPayload = \{/)
assert.doesNotMatch(source, /settlementIncluded/)
}) })
test('referenced fee-item deletion remains a conflict assertion', () => { test('referenced fee-item deletion remains a conflict assertion', () => {
+1 -1
View File
@@ -215,7 +215,7 @@ export async function run(context) {
const feeDetailPath = `/api/platform/v1/inbounds/${encodeURIComponent(inbound.inboundId)}/fee-details?accountId=${encodeURIComponent(inbound.tenantId)}` const feeDetailPath = `/api/platform/v1/inbounds/${encodeURIComponent(inbound.inboundId)}/fee-details?accountId=${encodeURIComponent(inbound.tenantId)}`
const detailPayload = { const detailPayload = {
feeItemId, direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), feeItemId, direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 1.25, currency: 'CNY', amount: 1.25, settlementIncluded: false, unitPrice: 1.25, currency: 'CNY', amount: 1.25,
remark: context.marker, remark: context.marker,
} }
const disabledBindingResponse = await context.request('POST', feeDetailPath, detailPayload) const disabledBindingResponse = await context.request('POST', feeDetailPath, detailPayload)
@@ -22,7 +22,7 @@ test('覆盖费用、对账单、AR 和 AP 页面分层状态', () => {
for (const status of ['DRAFT', 'PENDING_REVIEW', 'ISSUED']) assert.match(source, new RegExp(`'${status}'`)) for (const status of ['DRAFT', 'PENDING_REVIEW', 'ISSUED']) assert.match(source, new RegExp(`'${status}'`))
assert.match(source, /reviewStatus === 'PENDING'/) assert.match(source, /reviewStatus === 'PENDING'/)
assert.match(source, /reviewStatus === 'APPROVED'/) assert.match(source, /reviewStatus === 'APPROVED'/)
assert.match(source, /settlementIncluded === true/) assert.doesNotMatch(source, /settlementIncluded|settlement-inclusion/)
}) })
test('平台列表默认全量查询不带 customerId,未部署时登记 blocker', () => { test('平台列表默认全量查询不带 customerId,未部署时登记 blocker', () => {
@@ -36,7 +36,7 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () =>
assert.match(source, /fixture\.voucherFileId/) assert.match(source, /fixture\.voucherFileId/)
assert.match(source, /finance-full-fixtures\.mjs/) assert.match(source, /finance-full-fixtures\.mjs/)
assert.match(source, /context\.createdResources\.financeDemo = matrix/) assert.match(source, /context\.createdResources\.financeDemo = matrix/)
assert.match(source, /`source-\$\{role\}-\$\{source\.feeSourceId\}-include`/) assert.match(source, /`source-\$\{role\}-\$\{source\.feeSourceId\}-submit`/)
assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-\$\{source\.supplierId\}-create`/) assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-\$\{source\.supplierId\}-create`/)
}) })
@@ -49,8 +49,8 @@ test('费用来源使用 operationVersion,应付供应商长整型保持字符
assert.match(source, /expectedVersion: operationVersion\(current\.value\)/) assert.match(source, /expectedVersion: operationVersion\(current\.value\)/)
assert.match(source, /supplierId: id\(source\.supplierId\)/) assert.match(source, /supplierId: id\(source\.supplierId\)/)
assert.doesNotMatch(source, /supplierId: Number\(source\.supplierId\)/) assert.doesNotMatch(source, /supplierId: Number\(source\.supplierId\)/)
assert.match(source, /partnerId, partnerName:/) assert.match(source, /supplierId, remark:/)
assert.doesNotMatch(source, /partnerId: Number\(partnerId\)/) assert.doesNotMatch(source, /partnerId|partnerName/)
}) })
test('恢复模式只续补固定报告来源并校验 AR AP 金额状态', () => { test('恢复模式只续补固定报告来源并校验 AR AP 金额状态', () => {
@@ -52,22 +52,21 @@ async function sourceDetail(context, feeSourceId) {
return { response, value: data(response) } return { response, value: data(response) }
} }
async function approveAndInclude(context, source, role) { async function approveSource(context, source, role) {
let current = await sourceDetail(context, source.feeSourceId) let current = await sourceDetail(context, source.feeSourceId)
if (current.value?.reviewStatus === 'DRAFT' || current.value?.reviewStatus === 'REJECTED') {
const response = await write(context, 'POST', `${BASE}/fee-sources/${source.feeSourceId}/submit`,
{ reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-submit`)
current = { response, value: data(response) }
}
if (current.value?.reviewStatus === 'PENDING') { if (current.value?.reviewStatus === 'PENDING') {
const response = await write(context, 'POST', `${BASE}/fee-sources/${source.feeSourceId}/review`, const response = await write(context, 'POST', `${BASE}/fee-sources/${source.feeSourceId}/review`,
{ action: 'APPROVE', reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-approve`) { action: 'APPROVE', reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-approve`)
current = { response, value: data(response) } current = { response, value: data(response) }
} }
if (current.value?.settlementIncluded !== true) { record(context, `${role} 来源已审核并自动进入结算候选`, current.response,
const response = await write(context, 'PUT', `${BASE}/fee-sources/${source.feeSourceId}/settlement-inclusion`, current.value?.reviewStatus === 'APPROVED' && current.value?.sourceStatus === 'AVAILABLE',
{ included: true, reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-include`) `feeSourceId=${source.feeSourceId}, review=${current.value?.reviewStatus}`)
current = { response, value: data(response) }
}
record(context, `${role} 来源已审核并纳入结算`, current.response,
current.value?.reviewStatus === 'APPROVED' && current.value?.settlementIncluded === true
&& current.value?.sourceStatus === 'AVAILABLE',
`feeSourceId=${source.feeSourceId}, review=${current.value?.reviewStatus}, included=${current.value?.settlementIncluded}`)
return current.value return current.value
} }
@@ -75,20 +74,20 @@ async function createExtraPayable(context, fixture, index) {
const itemResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200') const itemResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const feeItem = rows(data(itemResponse)).find((item) => item.status === 'ACTIVE' && item.warehousePayable === true) const feeItem = rows(data(itemResponse)).find((item) => item.status === 'ACTIVE' && item.warehousePayable === true)
const inboundId = id(fixture.orders.at(-1)?.inboundId) const inboundId = id(fixture.orders.at(-1)?.inboundId)
const partnerId = id(fixture.supplier?.partnerId ?? fixture.supplier?.supplierId) const supplierId = id(fixture.supplier?.supplierId)
if (!feeItem || !inboundId || !partnerId) return null if (!feeItem || !inboundId || !supplierId) return null
const body = { feeItemId: Number(feeItem.id), direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), const body = { feeItemId: Number(feeItem.id), direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true, unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY',
partnerId, partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` } supplierId, remark: `${MARKER}-AP-${index}` }
const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details` const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details`
const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`) const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`)
const created = data(createdResponse) const created = data(createdResponse)
if (!ok(createdResponse) || !created?.id) return null if (!ok(createdResponse) || !created?.id) return null
const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=10001`, undefined, `extra-payable-${index}-approve`) const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=10001`, undefined, `extra-payable-${index}-approve`)
record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse), record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse),
`sourceFeeId=${created.id}, inboundId=${inboundId}, partnerId=${partnerId}`) `sourceFeeId=${created.id}, inboundId=${inboundId}, supplierId=${supplierId}`)
return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate, return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate,
supplierId: partnerId, supplierName: body.partnerName } supplierId, supplierName: fixture.supplier?.supplierName || null }
} }
async function createStatement(context, source, role, targetStatus) { async function createStatement(context, source, role, targetStatus) {
@@ -206,7 +205,7 @@ async function recoverExistingDemo(context, reportPath) {
'invalid-finance-demo-recovery-source') 'invalid-finance-demo-recovery-source')
continue continue
} }
const approved = await approveAndInclude(context, { feeSourceId: payableIds[index] }, role) const approved = await approveSource(context, { feeSourceId: payableIds[index] }, role)
const existing = await recoverPayable(context, matrix.payableSettlements[role], payableIds[index]) const existing = await recoverPayable(context, matrix.payableSettlements[role], payableIds[index])
const recoverySupplierId = role === 'apPartial' const recoverySupplierId = role === 'apPartial'
? id(matrix.payableSettlements?.apPaid?.supplierId) ? id(matrix.payableSettlements?.apPaid?.supplierId)
@@ -234,7 +233,7 @@ async function recoverExistingDemo(context, reportPath) {
matrix.receivableSettlements[role] = value matrix.receivableSettlements[role] = value
verifySettlementState(context, 'RECEIVABLE', role, response, value, expectedStatus) verifySettlementState(context, 'RECEIVABLE', role, response, value, expectedStatus)
} }
matrix.feeSources.approvedUnbilled = await approveAndInclude(context, { feeSourceId: '300' }, 'approved-unbilled') matrix.feeSources.approvedUnbilled = await approveSource(context, { feeSourceId: '300' }, 'approved-unbilled')
context.createdResources.financeFullFixtures = fixture context.createdResources.financeFullFixtures = fixture
context.createdResources.financeDemo = matrix context.createdResources.financeDemo = matrix
} }
@@ -294,7 +293,7 @@ export async function run(context) {
const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item])) const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item]))
const sources = produced.map((source) => ({ ...source, const sources = produced.map((source) => ({ ...source,
feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker,
supplierId: id(source.supplierId ?? source.partnerId ?? fixture.supplier?.supplierId) })) supplierId: id(source.supplierId ?? fixture.supplier?.supplierId) }))
.filter((source) => source.feeSourceId) .filter((source) => source.feeSourceId)
const receivables = sources.filter((source) => source.direction === 'RECEIVABLE') const receivables = sources.filter((source) => source.direction === 'RECEIVABLE')
const payables = sources.filter((source) => source.direction === 'PAYABLE' && source.supplierId) const payables = sources.filter((source) => source.direction === 'PAYABLE' && source.supplierId)
@@ -311,10 +310,10 @@ export async function run(context) {
const pending = await sourceDetail(context, receivables[0].feeSourceId) const pending = await sourceDetail(context, receivables[0].feeSourceId)
record(context, '保留费用待审核', pending.response, record(context, '保留费用待审核', pending.response,
pending.value?.reviewStatus === 'PENDING' && pending.value?.settlementIncluded === false, pending.value?.reviewStatus === 'PENDING',
`feeSourceId=${receivables[0].feeSourceId}, review=${pending.value?.reviewStatus}`) `feeSourceId=${receivables[0].feeSourceId}, review=${pending.value?.reviewStatus}`)
matrix.feeSources.pendingReview = pending.value matrix.feeSources.pendingReview = pending.value
matrix.feeSources.approvedUnbilled = await approveAndInclude(context, receivables[1], 'approved-unbilled') matrix.feeSources.approvedUnbilled = await approveSource(context, receivables[1], 'approved-unbilled')
const statementRoles = [ const statementRoles = [
['draft', 'DRAFT'], ['pendingReview', 'PENDING_REVIEW'], ['arUnpaid', 'ISSUED'], ['draft', 'DRAFT'], ['pendingReview', 'PENDING_REVIEW'], ['arUnpaid', 'ISSUED'],
@@ -322,7 +321,7 @@ export async function run(context) {
] ]
for (let index = 0; index < statementRoles.length; index += 1) { for (let index = 0; index < statementRoles.length; index += 1) {
const [role, status] = statementRoles[index] const [role, status] = statementRoles[index]
await approveAndInclude(context, receivables[index + 2], role) await approveSource(context, receivables[index + 2], role)
const statement = await createStatement(context, receivables[index + 2], role, status) const statement = await createStatement(context, receivables[index + 2], role, status)
if (statement.value) matrix.statements[role] = statement.value if (statement.value) matrix.statements[role] = statement.value
} }
@@ -344,7 +343,7 @@ export async function run(context) {
} }
for (const [index, role] of ['apUnpaid', 'apPartial', 'apPaid'].entries()) { for (const [index, role] of ['apUnpaid', 'apPartial', 'apPaid'].entries()) {
await approveAndInclude(context, payables[index], role) await approveSource(context, payables[index], role)
const settlement = await createAp(context, fixture, payables[index], role) const settlement = await createAp(context, fixture, payables[index], role)
if (!settlement) continue if (!settlement) continue
if (role === 'apUnpaid') matrix.payableSettlements[role] = settlement if (role === 'apUnpaid') matrix.payableSettlements[role] = settlement
@@ -35,23 +35,23 @@ async function loadFeeItem(context, direction) {
const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS) const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE' return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true ? item.warehouseReceivable === true
: item.warehousePayable === true && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`))) || null : item.warehousePayable === true)) || null
} }
async function loadActivePartner(context) { async function loadActiveSupplier(context, category) {
const response = await context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING') const suffix = category ? `&category=${encodeURIComponent(category)}` : ''
const data = context.apiSuccess(MODULE, '读取 Warehouse ACTIVE 订舱供应商', response, 'category=BOOKING', OPTIONS) const response = await context.request('GET', `/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE${suffix}`)
return rows(data).find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING') || null const data = context.apiSuccess(MODULE, '读取 Warehouse ACTIVE 供应商', response, category ? `category=${category}` : 'all categories', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && (!category || item?.category === category)) || null
} }
async function createFee(context, resource, item, spec, partner) { async function createFee(context, resource, item, spec, supplier) {
const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details` const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details`
const path = `${base}?accountId=${encodeURIComponent(resource.tenantId)}` const path = `${base}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = { const body = {
feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate, feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate,
unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, settlementIncluded: true, unitPrice: spec.amount, currency: 'CNY', amount: spec.amount,
partnerId: spec.direction === 'PAYABLE' ? String(partner.id) : null, supplierId: spec.direction === 'PAYABLE' ? String(supplier.id) : null,
partnerName: spec.direction === 'PAYABLE' ? (partner.name || null) : null,
remark: `${FIXED_MARKER}-${spec.name}`, remark: `${FIXED_MARKER}-${spec.name}`,
} }
const response = await request(context, 'POST', path, body, `create-${spec.name}`) const response = await request(context, 'POST', path, body, `create-${spec.name}`)
@@ -127,10 +127,10 @@ export async function run(context) {
const receivableItem = await loadFeeItem(context, 'RECEIVABLE') const receivableItem = await loadFeeItem(context, 'RECEIVABLE')
const payableItem = await loadFeeItem(context, 'PAYABLE') const payableItem = await loadFeeItem(context, 'PAYABLE')
const partner = await loadActivePartner(context) const supplier = await loadActiveSupplier(context, payableItem?.supplierCategory || null)
if (!receivableItem || !payableItem || !partner?.id) { if (!receivableItem || !payableItem || !supplier?.id) {
blocker(context, '费用状态矩阵前置主数据完整', blocker(context, '费用状态矩阵前置主数据完整',
`receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activePartner=${id(partner?.id) || 'missing'}`) `receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activeSupplier=${id(supplier?.id) || 'missing'}`)
return return
} }
@@ -146,7 +146,7 @@ export async function run(context) {
for (const [index, spec] of specs.entries()) { for (const [index, spec] of specs.entries()) {
const resource = resources[index % resources.length] const resource = resources[index % resources.length]
const fixture = await createFee(context, resource, const fixture = await createFee(context, resource,
spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, partner) spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, supplier)
if (!fixture) continue if (!fixture) continue
if (spec.action === 'reject') { if (spec.action === 'reject') {
const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/reject?accountId=${encodeURIComponent(resource.tenantId)}`, const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/reject?accountId=${encodeURIComponent(resource.tenantId)}`,
@@ -168,8 +168,7 @@ export async function run(context) {
const conflictTarget = fixtures.find((item) => item.name === 'AR-APPROVED') const conflictTarget = fixtures.find((item) => item.name === 'AR-APPROVED')
if (conflictTarget) { if (conflictTarget) {
const adjustBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: conflictTarget.businessDate, const adjustBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: conflictTarget.businessDate,
unitPrice: 115, currency: 'CNY', amount: 115, settlementIncluded: true, unitPrice: 115, currency: 'CNY', amount: 115, remark: `${FIXED_MARKER}-CONFLICT` }
partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-CONFLICT` }
const conflict = await request(context, 'POST', const conflict = await request(context, 'POST',
`${conflictTarget.base}/${conflictTarget.feeId}/adjust?accountId=${encodeURIComponent(conflictTarget.resource.tenantId)}&expectedSourceVersion=999`, `${conflictTarget.base}/${conflictTarget.feeId}/adjust?accountId=${encodeURIComponent(conflictTarget.resource.tenantId)}&expectedSourceVersion=999`,
adjustBody, 'stale-adjust') adjustBody, 'stale-adjust')
@@ -182,8 +181,7 @@ export async function run(context) {
if (replayTarget) { if (replayTarget) {
const replayPath = `${replayTarget.base}?accountId=${encodeURIComponent(replayTarget.resource.tenantId)}` const replayPath = `${replayTarget.base}?accountId=${encodeURIComponent(replayTarget.resource.tenantId)}`
const replayBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: replayTarget.businessDate, const replayBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: replayTarget.businessDate,
unitPrice: 116, currency: 'CNY', amount: 116, settlementIncluded: true, unitPrice: 116, currency: 'CNY', amount: 116, remark: `${FIXED_MARKER}-IDEMPOTENT` }
partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-IDEMPOTENT` }
const first = await request(context, 'POST', replayPath, replayBody, 'idempotent-create') const first = await request(context, 'POST', replayPath, replayBody, 'idempotent-create')
const second = await request(context, 'POST', replayPath, replayBody, 'idempotent-create') const second = await request(context, 'POST', replayPath, replayBody, 'idempotent-create')
const firstData = first?.body?.data; const secondData = second?.body?.data const firstData = first?.body?.data; const secondData = second?.body?.data
@@ -47,7 +47,7 @@ test('扩展财务验收输出可回传资源与柜次部署阻断', () => {
}) })
test('按柜 PAYABLE 费用携带 Warehouse 供应商', () => { test('按柜 PAYABLE 费用携带 Warehouse 供应商', () => {
assert.match(fixtureSource, /partnerId: id\(supplier\.id\)/) assert.match(fixtureSource, /supplierId: id\(supplier\.id\)/)
assert.match(fixtureSource, /partnerName: supplier\.name \|\| null/) assert.doesNotMatch(fixtureSource, /partnerId|partnerName/)
assert.match(fixtureSource, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/) assert.match(fixtureSource, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/)
}) })
@@ -68,8 +68,8 @@ async function createWaybillPayable(context, fixture, order, input, index) {
if (!inboundId) return null if (!inboundId) return null
const path = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(fixture.customerId)}` const path = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(fixture.customerId)}`
const body = { feeItemId: Number(input.feeItem.id), direction: 'PAYABLE', businessDate: dateOffset(-3 - index), const body = { feeItemId: Number(input.feeItem.id), direction: 'PAYABLE', businessDate: dateOffset(-3 - index),
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true, unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY',
partnerId: id(input.supplier.id), partnerName: input.supplier.name || null, supplierId: id(input.supplier.id),
remark: `${context.marker}-WAYBILL-PAYABLE-${index + 1}` } remark: `${context.marker}-WAYBILL-PAYABLE-${index + 1}` }
const response = await write(context, 'POST', path, body, `waybill-payable-${index + 1}`) const response = await write(context, 'POST', path, body, `waybill-payable-${index + 1}`)
const detail = context.apiSuccess(MODULE, `创建第 ${index + 1} 条独占运单 PAYABLE 来源`, response, const detail = context.apiSuccess(MODULE, `创建第 ${index + 1} 条独占运单 PAYABLE 来源`, response,
@@ -262,7 +262,7 @@ export async function run(context) {
const legacyPayable = { ...pool.resolved[0], feeSourceId: pool.resolved[0].sourceFeeId, const legacyPayable = { ...pool.resolved[0], feeSourceId: pool.resolved[0].sourceFeeId,
direction: 'PAYABLE', currency: 'CNY', sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', sourceType: 'MANUAL',
marker: context.marker, partnerId: pool.resolved[0].supplierId } marker: context.marker, supplierId: pool.resolved[0].supplierId }
await runReconciliation(context, fixture, legacyPayable) await runReconciliation(context, fixture, legacyPayable)
const receivables = fixture.receivableSettlements || [] const receivables = fixture.receivableSettlements || []
const firstPayable = fixture.payableSettlements?.[0] const firstPayable = fixture.payableSettlements?.[0]
@@ -31,15 +31,13 @@ test('fee writes use verified Firstleg paths and do not fabricate historical IDs
assert.match(source, /chargeUnit === 'CBM'/) assert.match(source, /chargeUnit === 'CBM'/)
}) })
test('PAYABLE fee selection uses the formal feeCode and feeTypeName fields', () => { test('PAYABLE fee selection uses only the explicit nullable supplierCategory field', () => {
const selector = source.slice(source.indexOf('async function loadFeeItem'), source.indexOf('async function createManualFee')) const selector = source.slice(source.indexOf('async function loadFeeItem'), source.indexOf('async function createManualFee'))
assert.match(selector, /item\.warehousePayable === true/) assert.match(selector, /item\.warehousePayable === true/)
assert.match(selector, /supplierCategoryForFeeItem\(item\)/) assert.doesNotMatch(selector, /Boolean\(supplierCategoryForFeeItem/)
assert.equal(supplierCategoryForFeeItem({ feeCode: 'FBA_HEAD_FREIGHT', feeTypeName: '头程运费' }), 'BOOKING') assert.equal(supplierCategoryForFeeItem({ supplierCategory: 'BOOKING', feeTypeName: '操作费' }), 'BOOKING')
assert.equal(supplierCategoryForFeeItem({ feeCode: 'FEE_TYPE_001', feeTypeName: '拖车费2' }), 'TRUCKING') assert.equal(supplierCategoryForFeeItem({ supplierCategory: null, feeTypeName: '拖车费' }), null)
assert.equal(supplierCategoryForFeeItem({ feeCode: 'CUSTOMS_CLEARANCE', feeTypeName: '报关费' }), 'CUSTOMS') assert.equal(supplierCategoryForFeeItem({ supplierCategory: 'INVALID' }), null)
assert.equal(supplierCategoryForFeeItem({ feeCode: 'UNSUPPORTED', feeTypeName: '操作费' }), null)
assert.equal(supplierCategoryForFeeItem({ feeCode: 'FEE_TYPE_001', feeName: '拖车费2' }), null)
}) })
test('Finance PAYABLE fixture creates an isolated ACTIVE supplier through the formal API', () => { test('Finance PAYABLE fixture creates an isolated ACTIVE supplier through the formal API', () => {
@@ -232,7 +230,7 @@ test('automatic fixture reuses the published review-chain source without recalcu
assert.match(source, /sourceType === 'FBA_PRICING'/) assert.match(source, /sourceType === 'FBA_PRICING'/)
assert.match(source, /reviewStatus === 'APPROVED'/) assert.match(source, /reviewStatus === 'APPROVED'/)
assert.match(source, /publicationStatus === 'PUBLISHED'/) assert.match(source, /publicationStatus === 'PUBLISHED'/)
assert.match(source, /settlementIncluded === true/) assert.doesNotMatch(source, /settlementIncluded/)
assert.match(source, /const feeSourceId = id\(published\.sourceFeeId\)/) assert.match(source, /const feeSourceId = id\(published\.sourceFeeId\)/)
assert.doesNotMatch(source, /\/fees\/calculate/) assert.doesNotMatch(source, /\/fees\/calculate/)
assert.doesNotMatch(source, /\/fees\/confirm/) assert.doesNotMatch(source, /\/fees\/confirm/)
@@ -28,12 +28,8 @@ export function normalizeFinanceFixtureSources(sources, orders, allocations, cus
} }
export function supplierCategoryForFeeItem(item) { export function supplierCategoryForFeeItem(item) {
const value = `${item?.feeCode ?? ''} ${item?.feeTypeName ?? ''}`.toUpperCase() return ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE'].includes(item?.supplierCategory)
if (['TRUCK', 'TRAILER', 'DRAYAGE', '拖车', '卡车'].some((candidate) => value.includes(candidate))) return 'TRUCKING' ? item.supplierCategory : null
if (['CUSTOMS', 'CLEARANCE', 'DECLARATION', '报关', '清关'].some((candidate) => value.includes(candidate))) return 'CUSTOMS'
if (['CERTIFICATE', 'CERTIFICATION', 'CERT_', '认证', '证书'].some((candidate) => value.includes(candidate))) return 'CERTIFICATE'
if (['BOOKING', 'FREIGHT', 'OCEAN', 'SHIPPING', '订舱', '海运', '运费'].some((candidate) => value.includes(candidate))) return 'BOOKING'
return null
} }
export function buildSupplierBody(marker, category) { export function buildSupplierBody(marker, category) {
@@ -145,16 +141,16 @@ async function loadFeeItem(context, direction) {
const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS) const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS)
return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE' return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true ? item.warehouseReceivable === true
: item.warehousePayable === true && Boolean(supplierCategoryForFeeItem(item)))) || null : item.warehousePayable === true)) || null
} }
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, partner = null) { async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, supplier = null) {
if (context.financeFixtureHooks?.createManualFee) return context.financeFixtureHooks.createManualFee(context, resource, feeItem, direction, amount, basis, businessDate) if (context.financeFixtureHooks?.createManualFee) return context.financeFixtureHooks.createManualFee(context, resource, feeItem, direction, amount, basis, businessDate)
const basePath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details` const basePath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details`
const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}` const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = { feeItemId: String(feeItem.id), direction, businessDate, const body = { feeItemId: String(feeItem.id), direction, businessDate,
unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true, unitPrice: amount, currency: 'CNY', amount,
partnerId: partner?.id ? String(partner.id) : null, partnerName: partner?.name || null, supplierId: supplier?.id ? String(supplier.id) : null,
remark: `${context.marker}-${basis}` } remark: `${context.marker}-${basis}` }
const response = await request(context, 'POST', listPath, body) const response = await request(context, 'POST', listPath, body)
const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS) const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS)
@@ -214,8 +210,7 @@ async function loadPublishedAutomaticFee(context, resource) {
const details = context.apiSuccess(MODULE, '回读审核链已发布自动费用来源', listResponse, const details = context.apiSuccess(MODULE, '回读审核链已发布自动费用来源', listResponse,
`inboundId=${resource.inboundId}`, OPTIONS) `inboundId=${resource.inboundId}`, OPTIONS)
const published = rows(details).find((item) => item?.sourceType === 'FBA_PRICING' const published = rows(details).find((item) => item?.sourceType === 'FBA_PRICING'
&& item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED' && item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED')
&& item?.settlementIncluded === true)
if (!published) { if (!published) {
blocker(context, '审核链已生成可结算自动费用来源', blocker(context, '审核链已生成可结算自动费用来源',
`inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeId=${id(latest.feeId)}, feeStatus=${latest.feeStatus}, feeDetails=${rows(details).length};确认和发送成功后应同步生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源`) `inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeId=${id(latest.feeId)}, feeStatus=${latest.feeStatus}, feeDetails=${rows(details).length};确认和发送成功后应同步生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源`)
@@ -241,20 +236,20 @@ async function loadPublishedAutomaticFee(context, resource) {
async function loadSupplier(context, category) { async function loadSupplier(context, category) {
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category) if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
if (!category) return null const effectiveCategory = category || 'BOOKING'
const body = buildSupplierBody(context.marker, category) const body = buildSupplierBody(context.marker, effectiveCategory)
const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body) const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body)
const supplierId = id(context.apiSuccess(MODULE, '创建本轮 Finance 隔离供应商', response, const supplierId = id(context.apiSuccess(MODULE, '创建本轮 Finance 隔离供应商', response,
`category=${category}`, OPTIONS)) `category=${effectiveCategory}`, OPTIONS))
if (!supplierId) return null if (!supplierId) return null
const detailResponse = await context.request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`) const detailResponse = await context.request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`)
const detail = context.apiSuccess(MODULE, '回读本轮 Finance 隔离供应商', detailResponse, const detail = context.apiSuccess(MODULE, '回读本轮 Finance 隔离供应商', detailResponse,
`supplierId=${supplierId}`, OPTIONS) `supplierId=${supplierId}`, OPTIONS)
context.assert(MODULE, 'Finance 供应商为 ACTIVE 且类别匹配费用项目', detailResponse, context.assert(MODULE, 'Finance 供应商为 ACTIVE 且类别匹配费用项目', detailResponse,
id(detail?.id) === supplierId && detail?.status === 'ACTIVE' && detail?.category === category id(detail?.id) === supplierId && detail?.status === 'ACTIVE' && (!category || detail?.category === category)
&& detail?.name === body.name, && detail?.name === body.name,
`supplierId=${supplierId}, status=${detail?.status || 'missing'}, category=${detail?.category || 'missing'}`, OPTIONS) `supplierId=${supplierId}, status=${detail?.status || 'missing'}, category=${detail?.category || 'missing'}`, OPTIONS)
return detail?.status === 'ACTIVE' && detail?.category === category ? detail : null return detail?.status === 'ACTIVE' && (!category || detail?.category === category) ? detail : null
} }
async function uploadVoucher(context, customerId) { async function uploadVoucher(context, customerId) {
@@ -405,7 +400,7 @@ export async function run(context) {
const payableSupplierCategory = supplierCategoryForFeeItem(feeItems.PAYABLE) const payableSupplierCategory = supplierCategoryForFeeItem(feeItems.PAYABLE)
const supplier = await loadSupplier(context, payableSupplierCategory) const supplier = await loadSupplier(context, payableSupplierCategory)
if (!supplier?.id) blocker(context, '取得 Warehouse PAYABLE 供应商', if (!supplier?.id) blocker(context, '取得 Warehouse PAYABLE 供应商',
`未找到 ACTIVE ${payableSupplierCategory || 'SUPPORTED'} 供应商或 feeCode + feeTypeName 无法映射正式类别的 PAYABLE 费用项;不能伪造 supplierId/partnerId`) `未找到 ACTIVE ${payableSupplierCategory || '任意类别'} 供应商;不能伪造 supplierId`)
for (const direction of ['RECEIVABLE', 'PAYABLE']) { for (const direction of ['RECEIVABLE', 'PAYABLE']) {
if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`, if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`,
`没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item') `没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item')
@@ -434,7 +429,7 @@ export async function run(context) {
for (const [index, manualResource] of manualResources.entries()) { for (const [index, manualResource] of manualResources.entries()) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10, const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
'MANUAL_ORDER_FEE', dateOffset(-3 - index), supplier) 'MANUAL_ORDER_FEE', dateOffset(-3 - index), supplier)
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id),
supplierName: supplier.name || null, supplierCategory: supplier.category || payableSupplierCategory }) supplierName: supplier.name || null, supplierCategory: supplier.category || payableSupplierCategory })
} }
} }
@@ -448,8 +443,7 @@ export async function run(context) {
const basePath = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details` const basePath = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details`
const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}` const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}`
const body = { feeItemId: String(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2), const body = { feeItemId: String(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2),
amount: 300, currency: 'CNY', settlementIncluded: true, partnerId: id(supplier.id), amount: 300, currency: 'CNY', supplierId: id(supplier.id), remark: `${context.marker}-CONTAINER_SHARED` }
partnerName: supplier.name || null, remark: `${context.marker}-CONTAINER_SHARED` }
const response = await request(context, 'POST', path, body) const response = await request(context, 'POST', path, body)
if (response?.http === 404) { if (response?.http === 404) {
blocker(context, '按柜费用来源接口已部署并可路由', blocker(context, '按柜费用来源接口已部署并可路由',
@@ -460,7 +454,7 @@ export async function run(context) {
if (feeId) { const approval = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(container.tenantId)}`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) } if (feeId) { const approval = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(container.tenantId)}`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) }
containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300, containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300,
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED', businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
supplierId: supplier?.id ? id(supplier.id) : null, partnerId: supplier?.id ? id(supplier.id) : null, supplierId: supplier?.id ? id(supplier.id) : null,
supplierName: supplier?.name || null, supplierCategory: supplier?.category || payableSupplierCategory, supplierName: supplier?.name || null, supplierCategory: supplier?.category || payableSupplierCategory,
containerBookingId: id(container.containerBookingId), containerNo: container.containerNo || null, containerBookingId: id(container.containerBookingId), containerNo: container.containerNo || null,
loadingPlanId: id(container.loadingPlanId) } loadingPlanId: id(container.loadingPlanId) }
@@ -471,7 +465,7 @@ export async function run(context) {
'当前上下文没有同 marker 的已完成柜次及真实 Firstleg 体积快照,禁止借用历史柜次或模拟 Finance 分摊成功', 'no-marker-owned-container-snapshot') '当前上下文没有同 marker 的已完成柜次及真实 Firstleg 体积快照,禁止借用历史柜次或模拟 Finance 分摊成功', 'no-marker-owned-container-snapshot')
} else { } else {
context.addCoverageGap(MODULE, 'container-shared-cost-allocation', '按柜添加费用并按订单/运单体积分摊', context.addCoverageGap(MODULE, 'container-shared-cost-allocation', '按柜添加费用并按订单/运单体积分摊',
'已识别同 marker 的 COMPLETED 柜次,但 PAYABLE 费用项目无法按 feeCode + feeTypeName 映射类别或缺少 ACTIVE 供应商', 'missing-payable-supplier') '已识别同 marker 的 COMPLETED 柜次,但缺少 PAYABLE 费用项目或符合其显式可空类别约束的 ACTIVE 供应商', 'missing-payable-supplier')
} }
if (containerFixture) fixtures.push(containerFixture) if (containerFixture) fixtures.push(containerFixture)
@@ -521,7 +515,7 @@ export async function run(context) {
feeSources: fixtureSources, feeSources: fixtureSources,
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId, container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null }, containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), allocations, supplier: supplier?.id ? { supplierId: id(supplier.id),
supplierName: supplier.name || null, category: supplier.category || payableSupplierCategory } : null, supplierName: supplier.name || null, category: supplier.category || payableSupplierCategory } : null,
voucherFileId, pricingEvidence, voucherFileId, pricingEvidence,
deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence), deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence),
@@ -38,12 +38,12 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
] ]
const feeSources = [...receivables, const feeSources = [...receivables,
{ feeSourceId: '105', orderId: '205', direction: 'PAYABLE', currency: 'CNY', { feeSourceId: '105', orderId: '205', direction: 'PAYABLE', currency: 'CNY',
businessDate: '2026-08-11', partnerId: '501', partnerName: 'Fixture Partner' }, businessDate: '2026-08-11', supplierId: '501', supplierName: 'Fixture Supplier' },
{ feeSourceId: null, sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', { feeSourceId: null, sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY',
businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', partnerId: '501', businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', supplierId: '501',
containerBookingId: 'CB-FAILED' }] containerBookingId: 'CB-FAILED' }]
feeSources.push({ feeSourceId: '106', sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', feeSources.push({ feeSourceId: '106', sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY',
businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', partnerId: '501', businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', supplierId: '501',
containerBookingId: 'CB-COST-RESPONSE' }) containerBookingId: 'CB-COST-RESPONSE' })
const fixture = { marker, customerId: '11', const fixture = { marker, customerId: '11',
orders: feeSources.filter((item) => item.orderId) orders: feeSources.filter((item) => item.orderId)
@@ -109,7 +109,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
return ok(feeSources.filter((item) => item.feeSourceId && (!omitPayableSource || item.feeSourceId !== '105')).map((item, index) => { return ok(feeSources.filter((item) => item.feeSourceId && (!omitPayableSource || item.feeSourceId !== '105')).map((item, index) => {
const { feeSourceId, ...rest } = item const { feeSourceId, ...rest } = item
return { ...rest, poolItemId: `${item.direction}:${1001 + index}`, sourceId: feeSourceId, return { ...rest, poolItemId: `${item.direction}:${1001 + index}`, sourceId: feeSourceId,
reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE' } reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE' }
})) }))
} }
if (method === 'POST' && requestPath.endsWith('/statements')) { if (method === 'POST' && requestPath.endsWith('/statements')) {
@@ -252,7 +252,7 @@ test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张
assert.equal(new Set(fixture.receivableSettlements.map((item) => item.statementId)).size, 3) assert.equal(new Set(fixture.receivableSettlements.map((item) => item.statementId)).size, 3)
assert.equal(fixture.payableSettlements.length, 1) assert.equal(fixture.payableSettlements.length, 1)
assert.equal(fixture.payableSettlements[0].supplierId, '501') assert.equal(fixture.payableSettlements[0].supplierId, '501')
assert.equal(fixture.payableSettlements[0].partnerId, '501') assert.equal(fixture.payableSettlements[0].supplierId, '501')
assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Partner') assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Partner')
assert.equal(context.coverageGaps.some((gap) => gap.category === 'incomplete-container-cost-attempt' assert.equal(context.coverageGaps.some((gap) => gap.category === 'incomplete-container-cost-attempt'
&& gap.reason.includes('containerBookingId=CB-FAILED') && gap.reason.includes('containerBookingId=CB-FAILED')
@@ -292,12 +292,12 @@ test('run 在 fixture 缺失时记录 blocker 且不发送 HTTP 请求', async (
assert.equal(context.coverageGaps[0].category, 'missing-marker-owned-finance-fixture') assert.equal(context.coverageGaps[0].category, 'missing-marker-owned-finance-fixture')
}) })
test('PAYABLE 接受 partnerId 及名称快照,不依赖 SUPPLIER 类型', async () => { test('PAYABLE 只接受 supplierId 及 Warehouse 名称快照', async () => {
const context = fakeContext() const context = fakeContext()
await run(context) await run(context)
const payableRequest = context.calls.find((call) => call.path.endsWith('/payable-settlements')) const payableRequest = context.calls.find((call) => call.path.endsWith('/payable-settlements'))
assert.equal(payableRequest.body.supplierId, '501') assert.equal(payableRequest.body.supplierId, '501')
assert.doesNotMatch(source, /partnerType|types\?\.includes|SUPPLIER.*阻断/) assert.doesNotMatch(source, /partnerId|partnerName|partnerType/)
}) })
test('来源缺失逐条 customerId 时不得由 fixture 根字段静默补齐', async () => { test('来源缺失逐条 customerId 时不得由 fixture 根字段静默补齐', async () => {
@@ -206,8 +206,8 @@ async function normalizeFixture(context) {
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId), raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId),
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(), direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw), marker: raw.marker, businessDate: sourceDate(raw), waybillId: sourceWaybill(raw), marker: raw.marker,
supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId), supplierId: id(raw.supplierId ?? root.supplierId),
supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null, supplierName: raw.supplierName ?? root.supplierName ?? null,
} }
}) })
const hasContainerAllocations = list(root.allocations).length > 0 const hasContainerAllocations = list(root.allocations).length > 0
@@ -526,7 +526,7 @@ export async function run(context) {
const body = { customerId: fixture.customerId, supplierId, const body = { customerId: fixture.customerId, supplierId,
feeSourceIds: payables.map((source) => source.feeSourceId), dueDate, currency: 'CNY' } feeSourceIds: payables.map((source) => source.feeSourceId), dueDate, currency: 'CNY' }
const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, 'payable-settlement') const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, 'payable-settlement')
payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 partnerId/supplierId 生成应付结算单', payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 supplierId 生成应付结算单',
response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS) response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS)
if (payableSettlement) { if (payableSettlement) {
const listed = await findSettlementInOfficialList(context, 'PAYABLE', fixture.customerId, const listed = await findSettlementInOfficialList(context, 'PAYABLE', fixture.customerId,
@@ -543,7 +543,7 @@ export async function run(context) {
} }
} else { } else {
blocker(context, 'PAYABLE 结算 Fixture 门禁', blocker(context, 'PAYABLE 结算 Fixture 门禁',
`需要至少一条同客户 CNY PAYABLE,且全部提供相同 partnerId/supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`, `需要至少一条同客户 CNY PAYABLE,且全部提供相同 supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`,
'insufficient-payable-supplier-fixture') 'insufficient-payable-supplier-fixture')
} }
@@ -568,7 +568,7 @@ export async function run(context) {
fixture.root.receivableSettlementMap = { byStatementId: Object.fromEntries(arRows.map((row) => [row.statementId, row])) } fixture.root.receivableSettlementMap = { byStatementId: Object.fromEntries(arRows.map((row) => [row.statementId, row])) }
fixture.root.payableSettlements = payableSettlement ? [{ fixture.root.payableSettlements = payableSettlement ? [{
settlementId: settlementId(payableSettlement), direction: payableSettlement.direction, settlementId: settlementId(payableSettlement), direction: payableSettlement.direction,
supplierId: id(payableSettlement.supplierId), partnerId: id(payableSettlement.partnerId ?? payableSettlement.supplierId), supplierId: id(payableSettlement.supplierId),
supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null, supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null,
status: payableSettlement.status, status: payableSettlement.status,
paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version, paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version,
+7 -7
View File
@@ -53,19 +53,19 @@ export async function run(context) {
const feeSourceId = id(source.feeSourceId || source.id) const feeSourceId = id(source.feeSourceId || source.id)
let detailResponse = await context.request('GET', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}`) let detailResponse = await context.request('GET', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}`)
let detail = context.apiSuccess(MODULE, '读取本轮费用来源详情', detailResponse, '', OPTIONS) let detail = context.apiSuccess(MODULE, '读取本轮费用来源详情', detailResponse, '', OPTIONS)
if (detail?.reviewStatus === 'DRAFT' || detail?.reviewStatus === 'REJECTED') {
const submitBody = { reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) }
detailResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/submit`, submitBody, 'fee-submit')
detail = context.apiSuccess(MODULE, '提交本轮费用来源审核', detailResponse, '', OPTIONS)
}
if (detail?.reviewStatus === 'PENDING') { if (detail?.reviewStatus === 'PENDING') {
const reviewBody = { action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) } const reviewBody = { action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) }
detailResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/review`, reviewBody, 'fee-review') detailResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/review`, reviewBody, 'fee-review')
detail = context.apiSuccess(MODULE, '审核本轮费用来源', detailResponse, '', OPTIONS) detail = context.apiSuccess(MODULE, '审核本轮费用来源', detailResponse, '', OPTIONS)
} }
if (detail?.reviewStatus === 'APPROVED' && detail?.settlementIncluded !== true) {
const inclusionBody = { included: true, reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) }
detailResponse = await write(context, 'PUT', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/settlement-inclusion`, inclusionBody, 'fee-include')
detail = context.apiSuccess(MODULE, '将本轮费用来源纳入结算', detailResponse, '', OPTIONS)
}
context.assert(MODULE, '本轮来源满足对账单生成条件', detailResponse, context.assert(MODULE, '本轮来源满足对账单生成条件', detailResponse,
detail?.reviewStatus === 'APPROVED' && detail?.settlementIncluded === true && detail?.sourceStatus === 'AVAILABLE' && detail?.currency === 'CNY', detail?.reviewStatus === 'APPROVED' && detail?.sourceStatus === 'AVAILABLE' && detail?.currency === 'CNY',
`review=${detail?.reviewStatus}, included=${detail?.settlementIncluded}, status=${detail?.sourceStatus}`, OPTIONS) `review=${detail?.reviewStatus}, status=${detail?.sourceStatus}`, OPTIONS)
const businessDate = detail?.businessDate || new Date().toISOString().slice(0, 10) const businessDate = detail?.businessDate || new Date().toISOString().slice(0, 10)
const createBody = { customerId, currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [feeSourceId], periodStart: businessDate, periodEnd: businessDate, remark: context.marker } const createBody = { customerId, currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [feeSourceId], periodStart: businessDate, periodEnd: businessDate, remark: context.marker }
@@ -17,8 +17,9 @@ test('inbound fee lifecycle covers frozen source, version, publication and WAIT_
for (const token of [ for (const token of [
'/fees/latest', '/fees/published', '/fees/confirm', '/fees/send', '/fee-details', '/fees/latest', '/fees/published', '/fees/confirm', '/fees/send', '/fee-details',
'FBA_PRICING', 'MANUAL', 'CALCULATED', 'CONFIRMED', 'SENT', 'PUBLISHED', 'FBA_PRICING', 'MANUAL', 'CALCULATED', 'CONFIRMED', 'SENT', 'PUBLISHED',
'sourceFeeVersion', 'reviewStatus', 'publicationStatus', 'settlementIncluded', 'WAIT_SPLIT', 'sourceFeeVersion', 'reviewStatus', 'publicationStatus', 'WAIT_SPLIT',
]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) ]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
assert.match(source, /人工发布头程费用并触发 Finance Outbox/) assert.match(source, /人工发布头程费用并触发 Finance Outbox/)
assert.match(source, /对客合计仅含已发布头程与已审核其他应收且其他应付单列/) assert.match(source, /对客合计仅含已发布头程与已审核其他应收且其他应付单列/)
assert.doesNotMatch(source, /settlementIncluded|是否结算|参与结算/)
}) })
@@ -58,7 +58,7 @@ export async function run(context) {
{ feeItemId: initialDerived.feeItemId, direction: 'RECEIVABLE', { feeItemId: initialDerived.feeItemId, direction: 'RECEIVABLE',
businessDate: initialDerived.businessDate, unitPrice: initialDerived.unitPrice, businessDate: initialDerived.businessDate, unitPrice: initialDerived.unitPrice,
currency: initialDerived.currency, amount: initialDerived.amount, currency: initialDerived.currency, amount: initialDerived.amount,
settlementIncluded: initialDerived.settlementIncluded, remark: `${context.marker}-禁止修改派生` }) remark: `${context.marker}-禁止修改派生` })
context.assert(MODULE, 'FBA_PRICING 派生详情拒绝通用编辑', derivedUpdateResponse, context.assert(MODULE, 'FBA_PRICING 派生详情拒绝通用编辑', derivedUpdateResponse,
derivedUpdateResponse.http === 200 && !success(derivedUpdateResponse) derivedUpdateResponse.http === 200 && !success(derivedUpdateResponse)
&& String(derivedUpdateResponse.body?.message || '').includes('只读派生'), && String(derivedUpdateResponse.body?.message || '').includes('只读派生'),
@@ -108,8 +108,7 @@ export async function run(context) {
&& id(row.sourceFeeId) === id(sent?.feeId)) && id(row.sourceFeeId) === id(sent?.feeId))
context.assert(MODULE, '仅人工发布后费用为 SENT 且派生明细为 PUBLISHED', sendResponse, context.assert(MODULE, '仅人工发布后费用为 SENT 且派生明细为 PUBLISHED', sendResponse,
sent?.feeStatus === 'SENT' && sent?.nextProcessStatus === 'WAIT_SPLIT' sent?.feeStatus === 'SENT' && sent?.nextProcessStatus === 'WAIT_SPLIT'
&& published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED' && published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED',
&& published?.settlementIncluded === true,
`status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS) `status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS)
const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=500&status=ACTIVE') const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=500&status=ACTIVE')
@@ -125,7 +124,7 @@ export async function run(context) {
const detailsBase = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details` const detailsBase = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details`
const manualPayload = { feeItemId: manualItem.id, direction: 'RECEIVABLE', const manualPayload = { feeItemId: manualItem.id, direction: 'RECEIVABLE',
businessDate: new Date().toISOString().slice(0, 10), unitPrice: 12.34, currency: 'CNY', amount: 12.34, businessDate: new Date().toISOString().slice(0, 10), unitPrice: 12.34, currency: 'CNY', amount: 12.34,
settlementIncluded: true, remark: `${context.marker}-其他应收` } remark: `${context.marker}-其他应收` }
const manualCreateResponse = await context.request('POST', `${detailsBase}?accountId=${encodeURIComponent(tenantId)}`, manualPayload) const manualCreateResponse = await context.request('POST', `${detailsBase}?accountId=${encodeURIComponent(tenantId)}`, manualPayload)
const manual = context.apiSuccess(MODULE, '新增独立 MANUAL 其他应收', manualCreateResponse, '', OPTIONS) const manual = context.apiSuccess(MODULE, '新增独立 MANUAL 其他应收', manualCreateResponse, '', OPTIONS)
context.assert(MODULE, 'MANUAL 初始版本待审核且不冒充 FBA_PRICING', manualCreateResponse, context.assert(MODULE, 'MANUAL 初始版本待审核且不冒充 FBA_PRICING', manualCreateResponse,
@@ -139,10 +138,10 @@ export async function run(context) {
context.apiSuccess(MODULE, '审核通过 MANUAL 其他应收', approveResponse, '', OPTIONS) context.apiSuccess(MODULE, '审核通过 MANUAL 其他应收', approveResponse, '', OPTIONS)
const afterApproval = await listDetails(context, tenantId, inboundId, '回查已审核 MANUAL 其他应收') const afterApproval = await listDetails(context, tenantId, inboundId, '回查已审核 MANUAL 其他应收')
const approved = afterApproval.rows.find((row) => id(row.id) === id(manual.id)) const approved = afterApproval.rows.find((row) => id(row.id) === id(manual.id))
context.assert(MODULE, '已审核 MANUAL 纳入对客应收但与头程费用独立', afterApproval.response, context.assert(MODULE, '已审核 MANUAL 自动进入对客应收候选且与头程费用独立', afterApproval.response,
approved?.sourceType === 'MANUAL' && approved?.reviewStatus === 'APPROVED' approved?.sourceType === 'MANUAL' && approved?.reviewStatus === 'APPROVED'
&& approved?.settlementIncluded === true, && approved?.publicationStatus === 'PUBLISHED',
`review=${approved?.reviewStatus || 'missing'}, settlementIncluded=${approved?.settlementIncluded ?? 'missing'}`, OPTIONS) `review=${approved?.reviewStatus || 'missing'}, publication=${approved?.publicationStatus || 'missing'}`, OPTIONS)
const adjustedManualPayload = { ...manualPayload, unitPrice: 15.67, amount: 15.67, const adjustedManualPayload = { ...manualPayload, unitPrice: 15.67, amount: 15.67,
remark: `${context.marker}-其他应收版本调整` } remark: `${context.marker}-其他应收版本调整` }