优化
This commit is contained in:
@@ -10436,6 +10436,80 @@ CREATE TABLE IF NOT EXISTS fin_audit_log (
|
|||||||
KEY idx_fin_audit_operator (operator_id, created_at)
|
KEY idx_fin_audit_operator (operator_id, created_at)
|
||||||
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
|
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fin_settlement (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
settlement_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一结算单号',
|
||||||
|
direction VARCHAR(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付',
|
||||||
|
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '结算生成时客户名称快照',
|
||||||
|
supplier_id BIGINT UNSIGNED NULL COMMENT '应付结算供应商ID;应收结算为空',
|
||||||
|
supplier_name_snapshot VARCHAR(200) NULL COMMENT '应付结算供应商名称快照',
|
||||||
|
reconciliation_statement_id BIGINT UNSIGNED NULL COMMENT '应收结算关联的已出账客户对账单ID',
|
||||||
|
business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
|
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
|
||||||
|
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数',
|
||||||
|
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数',
|
||||||
|
generated_date DATE NOT NULL COMMENT '结算单生成日期',
|
||||||
|
due_date DATE NOT NULL COMMENT '结算到期日期',
|
||||||
|
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
|
||||||
|
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
|
||||||
|
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no),
|
||||||
|
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_supplier_status (supplier_id, direction, status),
|
||||||
|
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
|
||||||
|
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
|
||||||
|
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)
|
||||||
|
) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fin_settlement_line (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
|
||||||
|
fee_source_id BIGINT UNSIGNED NOT NULL COMMENT '结算固化的Finance费用来源ID',
|
||||||
|
amount DECIMAL(20,6) NOT NULL COMMENT '结算明细金额,精度6位小数',
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细创建时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fin_settlement_line_source (settlement_id, fee_source_id),
|
||||||
|
KEY idx_fin_settlement_line_customer (tenant_id, settlement_id),
|
||||||
|
KEY idx_fin_settlement_line_source (fee_source_id),
|
||||||
|
CONSTRAINT chk_fin_settlement_line_tenant CHECK (tenant_id > 0),
|
||||||
|
CONSTRAINT chk_fin_settlement_line_currency CHECK (currency_code = 'CNY'),
|
||||||
|
CONSTRAINT chk_fin_settlement_line_amount CHECK (amount > 0)
|
||||||
|
) ENGINE=InnoDB COMMENT='正式结算单费用来源金额快照明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fin_settlement_payment (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
|
||||||
|
transaction_type VARCHAR(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款',
|
||||||
|
transaction_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收付款业务号',
|
||||||
|
transaction_date DATE NOT NULL COMMENT '实际收款或付款日期',
|
||||||
|
amount DECIMAL(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数',
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
|
method VARCHAR(32) NOT NULL COMMENT '人工登记的收付款方式',
|
||||||
|
reference_no VARCHAR(128) NULL COMMENT '银行流水号或外部参考号',
|
||||||
|
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
|
||||||
|
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
|
||||||
|
KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at),
|
||||||
|
CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0),
|
||||||
|
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
|
||||||
|
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
|
||||||
|
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
|
||||||
|
|
||||||
USE baiyue_firstleg;
|
USE baiyue_firstleg;
|
||||||
-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql
|
-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql
|
||||||
-- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行)
|
-- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行)
|
||||||
@@ -13569,11 +13643,19 @@ BEGIN
|
|||||||
WHERE table_schema = 'baiyue_finance'
|
WHERE table_schema = 'baiyue_finance'
|
||||||
AND table_name IN ('fin_fee_source', 'fin_inbox_event', 'fin_subject',
|
AND table_name IN ('fin_fee_source', 'fin_inbox_event', 'fin_subject',
|
||||||
'fin_customer_settlement_profile', 'fin_bill', 'fin_bill_line',
|
'fin_customer_settlement_profile', 'fin_bill', 'fin_bill_line',
|
||||||
'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation',
|
'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation',
|
||||||
'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log')) <> 13 THEN
|
'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log',
|
||||||
|
'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16 THEN
|
||||||
SIGNAL SQLSTATE '45000'
|
SIGNAL SQLSTATE '45000'
|
||||||
SET MESSAGE_TEXT = '统一迁移失败:Finance 首期人工财务表未完整创建';
|
SET MESSAGE_TEXT = '统一迁移失败:Finance 首期人工财务表未完整创建';
|
||||||
END IF;
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema='baiyue_finance' AND table_name='fin_settlement'
|
||||||
|
AND index_name='idx_fin_settlement_customer_direction_status'
|
||||||
|
AND column_name IN ('tenant_id','direction','status')) <> 3 THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = '统一迁移失败:Finance 客户结算汇总索引未完整创建';
|
||||||
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||||
WHERE table_schema = 'baiyue_finance'
|
WHERE table_schema = 'baiyue_finance'
|
||||||
AND ((table_name = 'fin_customer_settlement_profile' AND column_name IN ('tenant_id', 'credit_days'))
|
AND ((table_name = 'fin_customer_settlement_profile' AND column_name IN ('tenant_id', 'credit_days'))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.baiyue.finance.dto.FinanceReadModels.Statement;
|
|||||||
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
|
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
|
||||||
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
|
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.BadSqlGrammarException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -69,17 +70,23 @@ public class FinanceQueryService {
|
|||||||
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
|
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
|
||||||
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
|
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
|
||||||
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
|
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
|
||||||
"AND JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.reviewStatus'))='APPROVED' " +
|
"AND f.review_status='APPROVED' AND f.settlement_included=1 " +
|
||||||
"AND JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.settlementIncluded'))='true' " +
|
|
||||||
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
|
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
|
||||||
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
|
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
|
||||||
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
|
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
|
||||||
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')) last_changed_at " +
|
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')) last_changed_at " +
|
||||||
"FROM fin_settlement s WHERE s.tenant_id=?";
|
"FROM fin_settlement s WHERE s.tenant_id=?";
|
||||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
try {
|
||||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||||
money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
||||||
customerId, customerId, customerId);
|
money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
||||||
|
customerId, customerId, customerId);
|
||||||
|
} catch (BadSqlGrammarException ignored) {
|
||||||
|
// Empty/partially migrated Finance databases may not have a settlement projection yet.
|
||||||
|
// The read-only account summary must still be usable as a zero CNY baseline.
|
||||||
|
return new CustomerAccountSummary(customerId, CNY, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Instant.now(), null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<FeePoolItem> feePool(Long customerId, String direction) {
|
public List<FeePoolItem> feePool(Long customerId, String direction) {
|
||||||
|
|||||||
@@ -290,3 +290,77 @@ CREATE TABLE IF NOT EXISTS fin_audit_log (
|
|||||||
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
|
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
|
||||||
KEY idx_fin_audit_operator (operator_id, created_at)
|
KEY idx_fin_audit_operator (operator_id, created_at)
|
||||||
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
|
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fin_settlement (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
settlement_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一结算单号',
|
||||||
|
direction VARCHAR(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付',
|
||||||
|
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '结算生成时客户名称快照',
|
||||||
|
supplier_id BIGINT UNSIGNED NULL COMMENT '应付结算供应商ID;应收结算为空',
|
||||||
|
supplier_name_snapshot VARCHAR(200) NULL COMMENT '应付结算供应商名称快照',
|
||||||
|
reconciliation_statement_id BIGINT UNSIGNED NULL COMMENT '应收结算关联的已出账客户对账单ID',
|
||||||
|
business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
|
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
|
||||||
|
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数',
|
||||||
|
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数',
|
||||||
|
generated_date DATE NOT NULL COMMENT '结算单生成日期',
|
||||||
|
due_date DATE NOT NULL COMMENT '结算到期日期',
|
||||||
|
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
|
||||||
|
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
|
||||||
|
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no),
|
||||||
|
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_supplier_status (supplier_id, direction, status),
|
||||||
|
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
|
||||||
|
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
|
||||||
|
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)
|
||||||
|
) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fin_settlement_line (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
|
||||||
|
fee_source_id BIGINT UNSIGNED NOT NULL COMMENT '结算固化的Finance费用来源ID',
|
||||||
|
amount DECIMAL(20,6) NOT NULL COMMENT '结算明细金额,精度6位小数',
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细创建时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fin_settlement_line_source (settlement_id, fee_source_id),
|
||||||
|
KEY idx_fin_settlement_line_customer (tenant_id, settlement_id),
|
||||||
|
KEY idx_fin_settlement_line_source (fee_source_id),
|
||||||
|
CONSTRAINT chk_fin_settlement_line_tenant CHECK (tenant_id > 0),
|
||||||
|
CONSTRAINT chk_fin_settlement_line_currency CHECK (currency_code = 'CNY'),
|
||||||
|
CONSTRAINT chk_fin_settlement_line_amount CHECK (amount > 0)
|
||||||
|
) ENGINE=InnoDB COMMENT='正式结算单费用来源金额快照明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fin_settlement_payment (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
|
||||||
|
transaction_type VARCHAR(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款',
|
||||||
|
transaction_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收付款业务号',
|
||||||
|
transaction_date DATE NOT NULL COMMENT '实际收款或付款日期',
|
||||||
|
amount DECIMAL(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数',
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||||
|
method VARCHAR(32) NOT NULL COMMENT '人工登记的收付款方式',
|
||||||
|
reference_no VARCHAR(128) NULL COMMENT '银行流水号或外部参考号',
|
||||||
|
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
|
||||||
|
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
|
||||||
|
KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at),
|
||||||
|
CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0),
|
||||||
|
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
|
||||||
|
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
|
||||||
|
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyString;
|
|||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import org.springframework.jdbc.BadSqlGrammarException;
|
||||||
|
|
||||||
class CustomerAccountSummaryBehaviorTest {
|
class CustomerAccountSummaryBehaviorTest {
|
||||||
|
|
||||||
@@ -51,8 +52,9 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
||||||
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
||||||
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
|
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
|
||||||
.contains("f.customer_id=?", "f.status='AVAILABLE'", "'$.reviewStatus'", "'$.settlementIncluded'")
|
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.settlement_included=1")
|
||||||
.contains("b.status<>'VOIDED'")
|
.contains("b.status<>'VOIDED'")
|
||||||
|
.doesNotContain("JSON_EXTRACT")
|
||||||
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
|
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,24 @@ class CustomerAccountSummaryBehaviorTest {
|
|||||||
assertThat(result.outstandingPayable()).isZero();
|
assertThat(result.outstandingPayable()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingSettlementProjectionReturnsZeroCnyBaseline() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any()))
|
||||||
|
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
|
||||||
|
|
||||||
|
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
|
||||||
|
|
||||||
|
assertThat(result.customerId()).isEqualTo(88L);
|
||||||
|
assertThat(result.currency()).isEqualTo("CNY");
|
||||||
|
assertThat(result.totalReceivable()).isZero();
|
||||||
|
assertThat(result.receivedAmount()).isZero();
|
||||||
|
assertThat(result.outstandingReceivable()).isZero();
|
||||||
|
assertThat(result.pendingBilling()).isZero();
|
||||||
|
assertThat(result.outstandingPayable()).isZero();
|
||||||
|
assertThat(result.lastChangedAt()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void controllerUsesListPermissionAndDelegatesSummary() throws Exception {
|
void controllerUsesListPermissionAndDelegatesSummary() throws Exception {
|
||||||
FinanceQueryService query = mock(FinanceQueryService.class);
|
FinanceQueryService query = mock(FinanceQueryService.class);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FinanceSettlementSchemaContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void moduleSchemaCreatesFormalSettlementTablesAndSummaryIndex() throws Exception {
|
||||||
|
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||||
|
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertThat(sql)
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
|
||||||
|
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
|
||||||
|
.contains("total_amount DECIMAL(20,6)", "paid_amount DECIMAL(20,6)", "outstanding_amount DECIMAL(20,6)")
|
||||||
|
.contains("updated_at DATETIME(3)");
|
||||||
|
assertThat(unified)
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
|
||||||
|
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
|
||||||
|
.contains("'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path resolveUnifiedSql() {
|
||||||
|
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||||
|
return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
|
|||||||
missing.add(currentTable + "." + column.group(1));
|
missing.add(currentTable + "." + column.group(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertThat(tableCount).isEqualTo(13);
|
assertThat(tableCount).isEqualTo(16);
|
||||||
assertThat(columnCount).isEqualTo(190);
|
assertThat(columnCount).isEqualTo(233);
|
||||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ public class TenantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId",
|
@BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", tenantId = "#tenantId",
|
||||||
description = "平台编辑企业客户档案")
|
description = "平台编辑企业客户档案")
|
||||||
public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
|
public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
|
||||||
IamTenant tenant = requireMerchant(tenantId);
|
IamTenant tenant = requireMerchant(tenantId);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.baiyue.iam.dto.RbacDtos.AssignRolesRequest;
|
|||||||
import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest;
|
import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest;
|
||||||
import com.baiyue.iam.dto.RbacDtos.GrantPermissionsRequest;
|
import com.baiyue.iam.dto.RbacDtos.GrantPermissionsRequest;
|
||||||
import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest;
|
import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest;
|
||||||
|
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -49,6 +50,17 @@ class BusinessAuditCoverageTest {
|
|||||||
long.class, long.class, boolean.class, String.class);
|
long.class, long.class, boolean.class, String.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldBindPlatformCustomerUpdateAuditToTargetCustomer() throws Exception {
|
||||||
|
BusinessAudit audit = annotation(TenantService.class, "updateMerchant",
|
||||||
|
long.class, UpdateTenantRequest.class);
|
||||||
|
|
||||||
|
assertEquals("PLATFORM_TENANT_UPDATE", audit.event());
|
||||||
|
assertEquals("TENANT", audit.resourceType());
|
||||||
|
assertEquals("#tenantId", audit.resourceId());
|
||||||
|
assertEquals("#tenantId", audit.tenantId());
|
||||||
|
}
|
||||||
|
|
||||||
private void assertAudit(Class<?> type, String methodName, String event, String resourceType,
|
private void assertAudit(Class<?> type, String methodName, String event, String resourceType,
|
||||||
String resourceId, Class<?>... parameterTypes) throws Exception {
|
String resourceId, Class<?>... parameterTypes) throws Exception {
|
||||||
BusinessAudit audit = annotation(type, methodName, parameterTypes);
|
BusinessAudit audit = annotation(type, methodName, parameterTypes);
|
||||||
|
|||||||
+1
-1
@@ -87,7 +87,7 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令
|
|||||||
|
|
||||||
### 本地 MySQL 全量验收
|
### 本地 MySQL 全量验收
|
||||||
|
|
||||||
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `10FDF9175B579D564AB5B6FACB8DBE0B8DB52032C9DC11928147E179D21D2041` 和 14730 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
|
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `9F804D3C0133BDEA888202215E71191E22DAAB0649A2CD26C09923E3FCB57899` 和 14812 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\local-db-full-acceptance.ps1 -DryRun
|
.\scripts\local-db-full-acceptance.ps1 -DryRun
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import process from 'node:process'
|
|||||||
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
||||||
const DEFAULT_USERNAME = 'platform_admin'
|
const DEFAULT_USERNAME = 'platform_admin'
|
||||||
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
||||||
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
|
const DEFAULT_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
|
||||||
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
||||||
const CUSTOMER_FIELDS = [
|
const CUSTOMER_FIELDS = [
|
||||||
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
||||||
|
|||||||
@@ -59,3 +59,19 @@ node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
|
|||||||
```powershell
|
```powershell
|
||||||
node --test scripts/admin-test-env-modules/domestic-container-contract.test.mjs
|
node --test scripts/admin-test-env-modules/domestic-container-contract.test.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 清库后的商户下单前置
|
||||||
|
|
||||||
|
`merchant-qualification-fixture.mjs` 是默认全量验收的首个模块。它通过平台正式接口创建本轮隔离客户,经 File 上传真实营业执照和法人身份证文件,再走 IAM 提交审核和平台审核通过;审核完成后把本轮动态加载模块使用的商户账号切换到该客户。
|
||||||
|
|
||||||
|
依赖商户下单资格的模块不要单独运行。定向排查时至少保留前置模块,例如:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixture,orders
|
||||||
|
```
|
||||||
|
|
||||||
|
该前置不会向初始化 SQL 写入伪造资质,也不会绕过订单服务的企业资格校验。静态契约可离线执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||||
|
const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('默认全量验收先准备已审核商户再运行订单链路', () => {
|
||||||
|
const modules = acceptance.match(/const DEFAULT_MODULES = \[([^\n]+)\]/)?.[1] || ''
|
||||||
|
assert.match(modules, /^'merchant-qualification-fixture', 'customers'/)
|
||||||
|
assert.ok(modules.indexOf("'merchant-qualification-fixture'") < modules.indexOf("'orders'"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => {
|
||||||
|
assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/)
|
||||||
|
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/)
|
||||||
|
assert.match(fixture, /iam\/reviews\/\$\{encodeURIComponent\(review\.id\)\}\/approve/)
|
||||||
|
assert.match(fixture, /item\.status === 'APPROVED'/)
|
||||||
|
assert.match(fixture, /MERCHANT_TEST_USERNAME/)
|
||||||
|
assert.match(fixture, /MERCHANT_TEST_PASSWORD/)
|
||||||
|
assert.doesNotMatch(fixture, /internal\/iam|UPDATE\s+iam_|INSERT\s+INTO\s+iam_/i)
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const MODULE = 'merchant-qualification-fixture'
|
||||||
|
const REQUIRED_TYPES = new Set(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'])
|
||||||
|
const QUALIFICATION_IMAGE = Uint8Array.from(Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
))
|
||||||
|
|
||||||
|
function id(value) {
|
||||||
|
return value === undefined || value === null ? '' : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(context) {
|
||||||
|
const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0')
|
||||||
|
const phone = await context.findAvailablePhone(MODULE)
|
||||||
|
const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', {
|
||||||
|
tenantName: `全量验收下单客户-${suffix}`,
|
||||||
|
legalName: `全量验收下单有限公司-${suffix}`,
|
||||||
|
legalRepresentative: '全量验收法人',
|
||||||
|
contactName: '全量验收管理员',
|
||||||
|
contactPhone: phone,
|
||||||
|
contactEmail: `merchant-fixture-${suffix.toLowerCase()}@example.com`,
|
||||||
|
servicePackage: '全量验收',
|
||||||
|
salesOwnerName: '全量验收',
|
||||||
|
paymentTermDays: 30,
|
||||||
|
enterpriseType: 'LIMITED_COMPANY',
|
||||||
|
establishedDate: '2020-01-02',
|
||||||
|
registeredCapital: 1000000,
|
||||||
|
registeredCapitalCurrency: 'CNY',
|
||||||
|
registeredAddress: '深圳市全量验收地址',
|
||||||
|
internalRemark: `${context.marker}-MERCHANT-QUALIFICATION`,
|
||||||
|
})
|
||||||
|
const created = context.apiSuccess(MODULE, '创建隔离下单客户', createResponse)
|
||||||
|
const customer = created?.tenant || created?.customer || created
|
||||||
|
const tenantId = id(customer?.id || created?.tenantId)
|
||||||
|
const username = created?.administratorUsername
|
||||||
|
const password = created?.temporaryPassword
|
||||||
|
context.assert(MODULE, '下单客户和一次性管理员凭据完整', createResponse,
|
||||||
|
Boolean(tenantId && username && password),
|
||||||
|
`tenantId=${tenantId || 'missing'}, username=${username || 'missing'}, passwordGenerated=${Boolean(password)}`,
|
||||||
|
{ deploymentBlocker: true })
|
||||||
|
if (!tenantId || !username || !password) throw new Error('创建下单客户未返回完整管理员凭据')
|
||||||
|
|
||||||
|
const packageForm = new FormData()
|
||||||
|
packageForm.append('businessLicense', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `business-${suffix}.png`)
|
||||||
|
packageForm.append('legalPersonId', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `legal-person-${suffix}.png`)
|
||||||
|
packageForm.append('unifiedSocialCreditCode', `91440300${suffix}`)
|
||||||
|
const packageResponse = await context.requestWithBody('POST',
|
||||||
|
`/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package`, packageForm)
|
||||||
|
context.apiSuccess(MODULE, '通过 File 上传真实必填资质包', packageResponse,
|
||||||
|
`tenantId=${tenantId}`, { deploymentBlocker: true })
|
||||||
|
|
||||||
|
const submitResponse = await context.request('POST',
|
||||||
|
`/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/submit-review`)
|
||||||
|
const submitted = context.apiSuccess(MODULE, '提交企业资质审核', submitResponse,
|
||||||
|
`tenantId=${tenantId}`, { deploymentBlocker: true })
|
||||||
|
context.assert(MODULE, '企业进入待审核状态', submitResponse, submitted?.status === 'PENDING',
|
||||||
|
`status=${submitted?.status ?? 'missing'}`, { deploymentBlocker: true })
|
||||||
|
|
||||||
|
const reviewsResponse = await context.request('GET', '/api/platform/v1/iam/reviews')
|
||||||
|
const reviews = context.apiSuccess(MODULE, '查询待审核企业', reviewsResponse, '', { deploymentBlocker: true })
|
||||||
|
const review = Array.isArray(reviews) ? reviews.find((item) => id(item.tenantId) === tenantId) : null
|
||||||
|
context.assert(MODULE, '待审核列表包含隔离下单客户', reviewsResponse, Boolean(review?.id),
|
||||||
|
`tenantId=${tenantId}, reviewId=${id(review?.id) || 'missing'}`, { deploymentBlocker: true })
|
||||||
|
if (!review?.id) throw new Error('未找到隔离下单客户的待审核单')
|
||||||
|
|
||||||
|
const approveResponse = await context.request('POST',
|
||||||
|
`/api/platform/v1/iam/reviews/${encodeURIComponent(review.id)}/approve`,
|
||||||
|
{ comment: `${context.marker} 全量验收正式资质审核通过` })
|
||||||
|
context.apiSuccess(MODULE, '平台审核通过企业资质', approveResponse,
|
||||||
|
`tenantId=${tenantId}`, { deploymentBlocker: true })
|
||||||
|
|
||||||
|
const qualificationsResponse = await context.request('GET',
|
||||||
|
`/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/qualifications`)
|
||||||
|
const qualifications = context.apiSuccess(MODULE, '回查企业资质审核结果', qualificationsResponse)
|
||||||
|
const approvedTypes = new Set((Array.isArray(qualifications) ? qualifications : [])
|
||||||
|
.filter((item) => item.status === 'APPROVED')
|
||||||
|
.map((item) => item.qualificationType))
|
||||||
|
const eligible = [...REQUIRED_TYPES].every((type) => approvedTypes.has(type))
|
||||||
|
context.assert(MODULE, '全部必填企业资质已审核通过', qualificationsResponse, eligible,
|
||||||
|
`approved=${[...approvedTypes].join(',') || 'missing'}`, { deploymentBlocker: true })
|
||||||
|
if (!eligible) throw new Error('隔离下单客户必填资质未全部审核通过')
|
||||||
|
|
||||||
|
process.env.MERCHANT_TEST_USERNAME = username
|
||||||
|
process.env.MERCHANT_TEST_PASSWORD = password
|
||||||
|
context.context.merchantQualificationFixture = { tenantId, username, marker: context.marker }
|
||||||
|
context.createdResources.merchantQualificationFixture = { tenantId, username, marker: context.marker }
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
|
||||||
|
|
||||||
|
test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => {
|
||||||
|
const steps = []
|
||||||
|
const context = {
|
||||||
|
marker: 'ADMIN-RUN-1',
|
||||||
|
createdResources: { orchestrationFixtures: { phase3Container: {
|
||||||
|
marker: 'ADMIN-RUN-1', tenantId: '21', inboundId: '31',
|
||||||
|
} } },
|
||||||
|
request: async () => ({ body: { code: '200', data: { summary: {
|
||||||
|
tenantId: 21, customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
|
||||||
|
warehouseId: 41, warehouseName: '深圳仓',
|
||||||
|
} } } }),
|
||||||
|
addStep: (...args) => steps.push(args),
|
||||||
|
}
|
||||||
|
const master = await resolveOceanBookingMaster(context, 'test')
|
||||||
|
assert.deepEqual(master, { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US',
|
||||||
|
channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' })
|
||||||
|
assert.equal(steps.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ocean booking fixture blocks creation instead of inventing missing master data', async () => {
|
||||||
|
const steps = []
|
||||||
|
const context = { marker: 'ADMIN-RUN-2', createdResources: {}, addStep: (...args) => steps.push(args) }
|
||||||
|
assert.equal(await resolveOceanBookingMaster(context, 'test'), null)
|
||||||
|
assert.equal(steps.length, 1)
|
||||||
|
assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shared booking draft carries all required DTO fields without release transport data', () => {
|
||||||
|
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', {
|
||||||
|
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
|
||||||
|
warehouseId: '41', warehouseName: '深圳仓',
|
||||||
|
})
|
||||||
|
for (const key of ['customerId', 'customerName', 'channelCode', 'channelName', 'warehouseId', 'warehouseName']) {
|
||||||
|
assert.ok(draft[key], `${key} is required`)
|
||||||
|
}
|
||||||
|
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
|
||||||
|
assert.equal(draft.containerRequirements[0].maxWeightKg, 28000)
|
||||||
|
for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
|
||||||
|
assert.equal(draft[key], undefined, `${key} belongs to release, not draft`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all independent ocean booking producers use the shared master-data contract', () => {
|
||||||
|
for (const name of ['ocean-bookings.mjs', 'phase3-port-schedules.mjs', 'phase3-carrier-schedule-queries.mjs']) {
|
||||||
|
const source = readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||||
|
assert.match(source, /resolveOceanBookingMaster\(context, MODULE\)/, name)
|
||||||
|
assert.match(source, /oceanBookingDraft\(context, suffix, master/, name)
|
||||||
|
}
|
||||||
|
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
|
||||||
|
for (const snippet of ['channelCode: channelCode', 'channelName', 'warehouseId', 'warehouseName',
|
||||||
|
'capacityCbm: 100', 'maxWeightKg: 100000']) assert.match(container, new RegExp(snippet), snippet)
|
||||||
|
})
|
||||||
@@ -9,9 +9,9 @@ export async function resolveOceanBookingMaster(context, module) {
|
|||||||
const tenantId = text(source?.tenantId || source?.customerId)
|
const tenantId = text(source?.tenantId || source?.customerId)
|
||||||
const inboundId = text(source?.inboundId)
|
const inboundId = text(source?.inboundId)
|
||||||
let summary = source?.summary || null
|
let summary = source?.summary || null
|
||||||
if (inboundId && typeof context.request === 'function') {
|
if (tenantId && inboundId && typeof context.request === 'function') {
|
||||||
const response = await context.request('GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
|
const response = await context.request('GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
|
||||||
if (response?.body?.code === '200') summary = response.body.data?.summary || summary
|
if (String(response?.body?.code) === '200') summary = response.body.data?.summary || summary
|
||||||
}
|
}
|
||||||
const master = {
|
const master = {
|
||||||
customerId: text(summary?.tenantId || tenantId),
|
customerId: text(summary?.tenantId || tenantId),
|
||||||
@@ -36,7 +36,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
|
|||||||
...master,
|
...master,
|
||||||
shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`,
|
shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`,
|
||||||
bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`,
|
bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`,
|
||||||
vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V${suffix}`,
|
vesselName: labels.vesselName, voyageNo: labels.voyageNo,
|
||||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||||
estimatedSailingAt: labels.estimatedSailingAt, estimatedArrivalAt: labels.estimatedArrivalAt,
|
estimatedSailingAt: labels.estimatedSailingAt, estimatedArrivalAt: labels.estimatedArrivalAt,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function request(context, method, path, body, headers = {}) {
|
|||||||
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
||||||
}
|
}
|
||||||
function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
|
function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
|
||||||
async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00' }) }, { 'Idempotency-Key': key }) }
|
async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master) }, { 'Idempotency-Key': key }) }
|
||||||
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
|
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
|
||||||
|
|
||||||
export async function run(context) {
|
export async function run(context) {
|
||||||
@@ -41,20 +41,20 @@ export async function run(context) {
|
|||||||
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
|
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
|
||||||
context.assert(MODULE, '订舱 DTO 字段、DRAFT 和9类关联字段完整', createResponse, Boolean(bookingId) && detail?.oceanBooking?.status === 'DRAFT' && detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1, `bookingId=${bookingId || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS)
|
context.assert(MODULE, '订舱 DTO 字段、DRAFT 和9类关联字段完整', createResponse, Boolean(bookingId) && detail?.oceanBooking?.status === 'DRAFT' && detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1, `bookingId=${bookingId || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS)
|
||||||
if (!bookingId) return
|
if (!bookingId) return
|
||||||
context.createdResources.oceanBooking = { id: bookingId, customerId: '10001', marker: context.marker,
|
context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker,
|
||||||
containerBookingId: null, loadingPlanId: null, finalStatus: 'DRAFT' }
|
containerBookingId: null, loadingPlanId: null, finalStatus: 'DRAFT' }
|
||||||
const replay = await createBooking(context, suffix, key)
|
const replay = await createBooking(context, suffix, key, master)
|
||||||
context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS)
|
context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS)
|
||||||
const path = `${BASE}/${bookingId}`
|
const path = `${BASE}/${bookingId}`
|
||||||
const initialVersion = Number(detail.oceanBooking.version)
|
const initialVersion = Number(detail.oceanBooking.version)
|
||||||
const edited = { ...oceanBookingDraft(context, suffix, master, { estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00' }), vesselName: `编辑船-${suffix}`, voyageNo: `EV${suffix}` }
|
const edited = { ...oceanBookingDraft(context, suffix, master), shippingCompanyName: `编辑船司-${suffix}` }
|
||||||
const update = await request(context, 'PUT', path, { expectedVersion: initialVersion, data: edited }, { 'Idempotency-Key': `${context.marker}-ocean-edit` })
|
const update = await request(context, 'PUT', path, { expectedVersion: initialVersion, data: edited }, { 'Idempotency-Key': `${context.marker}-ocean-edit` })
|
||||||
context.apiSuccess(MODULE, '编辑 DRAFT 订舱', update, `version=${initialVersion}`, OPTIONS)
|
context.apiSuccess(MODULE, '编辑 DRAFT 订舱', update, `version=${initialVersion}`, OPTIONS)
|
||||||
const stale = await request(context, 'PUT', path, { expectedVersion: initialVersion, data: { ...edited, vesselName: `STALE-${suffix}` } }, { 'Idempotency-Key': `${context.marker}-ocean-stale` })
|
const stale = await request(context, 'PUT', path, { expectedVersion: initialVersion, data: { ...edited, shippingCompanyName: `STALE-${suffix}` } }, { 'Idempotency-Key': `${context.marker}-ocean-stale` })
|
||||||
step(context, '订舱旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`)
|
step(context, '订舱旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`)
|
||||||
const afterStale = await request(context, 'GET', path); const afterData = data(afterStale)
|
const afterStale = await request(context, 'GET', path); const afterData = data(afterStale)
|
||||||
context.apiSuccess(MODULE, '冲突后回查订舱', afterStale, '', OPTIONS)
|
context.apiSuccess(MODULE, '冲突后回查订舱', afterStale, '', OPTIONS)
|
||||||
context.assert(MODULE, '订舱冲突未部分更新', afterStale, afterData?.oceanBooking?.vesselName === edited.vesselName, `vessel=${afterData?.oceanBooking?.vesselName || 'missing'}`, OPTIONS)
|
context.assert(MODULE, '订舱冲突未部分更新', afterStale, afterData?.oceanBooking?.shippingCompanyName === edited.shippingCompanyName, `shippingCompanyName=${afterData?.oceanBooking?.shippingCompanyName || 'missing'}`, OPTIONS)
|
||||||
let version = Number(afterData?.oceanBooking?.version)
|
let version = Number(afterData?.oceanBooking?.version)
|
||||||
const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d }
|
const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d }
|
||||||
await action('提交订舱', 'submit', version, 'marker submit')
|
await action('提交订舱', 'submit', version, 'marker submit')
|
||||||
@@ -80,7 +80,14 @@ export async function run(context) {
|
|||||||
&& Number.isInteger(version),
|
&& Number.isInteger(version),
|
||||||
`containerBookingId=${id(containerFlow.containerBookingId)}, version=${version}`, OPTIONS)
|
`containerBookingId=${id(containerFlow.containerBookingId)}, version=${version}`, OPTIONS)
|
||||||
}
|
}
|
||||||
await action('放舱', 'release', version, 'release')
|
const released = await request(context, 'POST', `${path}/release`, {
|
||||||
|
expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`,
|
||||||
|
voyageNo: `RV${suffix}`, estimatedSailingAt: '2026-09-01T08:00:00',
|
||||||
|
estimatedArrivalAt: '2026-09-20T08:00:00', reason: 'release',
|
||||||
|
}, { 'Idempotency-Key': `${context.marker}-release` })
|
||||||
|
const releasedDetail = data(released)
|
||||||
|
context.apiSuccess(MODULE, '放舱', released, `status=${releasedDetail?.oceanBooking?.status || 'missing'}`, OPTIONS)
|
||||||
|
version = Number(releasedDetail?.oceanBooking?.version)
|
||||||
const final = await request(context, 'GET', path); const fd = data(final)
|
const final = await request(context, 'GET', path); const fd = data(final)
|
||||||
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
|
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
|
||||||
context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final,
|
context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker:
|
|||||||
const BASE = '/api/platform/v1/carrier-schedule-queries'
|
const BASE = '/api/platform/v1/carrier-schedule-queries'
|
||||||
const OCEAN = '/api/platform/v1/ocean-bookings'
|
const OCEAN = '/api/platform/v1/ocean-bookings'
|
||||||
const PORT = '/api/platform/v1/port-schedules'
|
const PORT = '/api/platform/v1/port-schedules'
|
||||||
|
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
export function matchesManualScheduleScope(resource, expected) {
|
export function matchesManualScheduleScope(resource, expected) {
|
||||||
@@ -38,40 +39,40 @@ async function request(context, method, path, body, headers = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function oceanDraft(context, suffix) {
|
function oceanDraft(context, suffix, master) {
|
||||||
return {
|
return {
|
||||||
customerId: '10001', customerName: `船司查询客户-${suffix}`, billOfLadingNo: `CSQ-BL-${suffix}`,
|
...oceanBookingDraft(context, suffix, master),
|
||||||
shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`,
|
shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`,
|
||||||
vesselName: `船司查询船-${suffix}`, voyageNo: `CQ${suffix}`, departurePortCode: 'CNSZX',
|
departurePortCode: 'CNSZX',
|
||||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||||
estimatedSailingAt: at(7 * 24 * 60), estimatedArrivalAt: at(28 * 24 * 60), remark: context.marker,
|
remark: context.marker,
|
||||||
parties: [
|
parties: [
|
||||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||||
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
||||||
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
|
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
|
||||||
],
|
],
|
||||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1 }],
|
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureOcean(context, suffix) {
|
async function ensureOcean(context, suffix, master) {
|
||||||
const existing = context.createdResources?.oceanBooking
|
const existing = context.createdResources?.oceanBooking
|
||||||
if (existing?.id && id(existing.customerId || '10001') === '10001') return { id: id(existing.id), containerBookingId: null }
|
if (existing?.id && id(existing.customerId) === id(master.customerId)) return { id: id(existing.id), containerBookingId: null }
|
||||||
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix) },
|
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) },
|
||||||
{ 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) })
|
{ 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) })
|
||||||
const detail = context.apiSuccess(MODULE, '创建船司查询隔离订舱', response, '', OPTIONS)
|
const detail = context.apiSuccess(MODULE, '创建船司查询隔离订舱', response, '', OPTIONS)
|
||||||
return { id: id(detail?.oceanBooking?.id), containerBookingId: null }
|
return { id: id(detail?.oceanBooking?.id), containerBookingId: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createManualSchedule(context, suffix, oceanBookingId) {
|
async function createManualSchedule(context, suffix, oceanBookingId, customerId) {
|
||||||
const existing = context.createdResources?.portSchedule
|
const existing = context.createdResources?.portSchedule
|
||||||
const expectedScope = { customerId: '10001', oceanBookingId, containerBookingId: null }
|
const expectedScope = { customerId, oceanBookingId, containerBookingId: null }
|
||||||
if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id)
|
if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id)
|
||||||
const body = {
|
const body = {
|
||||||
expectedVersion: 0,
|
expectedVersion: 0,
|
||||||
data: {
|
data: {
|
||||||
customerId: '10001', oceanBookingId, containerBookingId: null, sourceType: 'MANUAL',
|
customerId, oceanBookingId, containerBookingId: null, sourceType: 'MANUAL',
|
||||||
sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`,
|
sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`,
|
||||||
vesselName: `人工船期船-${suffix}`, voyageNo: `CM${suffix}`, departurePortCode: 'CNSZX',
|
vesselName: `人工船期船-${suffix}`, voyageNo: `CM${suffix}`, departurePortCode: 'CNSZX',
|
||||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||||
@@ -106,10 +107,12 @@ export async function run(context) {
|
|||||||
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false),
|
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false),
|
||||||
`codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS)
|
`codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const ocean = await ensureOcean(context, suffix)
|
const master = await resolveOceanBookingMaster(context, MODULE)
|
||||||
|
if (!master) return
|
||||||
|
const ocean = await ensureOcean(context, suffix, master)
|
||||||
if (!ocean.id) return
|
if (!ocean.id) return
|
||||||
const createBody = {
|
const createBody = {
|
||||||
expectedVersion: 0, customerId: '10001', oceanBookingId: ocean.id,
|
expectedVersion: 0, customerId: master.customerId, oceanBookingId: ocean.id,
|
||||||
containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'BILL_OF_LADING',
|
containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'BILL_OF_LADING',
|
||||||
trackingNo: `${context.marker}-ZIM`, remark: context.marker,
|
trackingNo: `${context.marker}-ZIM`, remark: context.marker,
|
||||||
}
|
}
|
||||||
@@ -167,7 +170,7 @@ export async function run(context) {
|
|||||||
forgedCustomer.http === 200 && ['A0400', '400'].includes(codeOf(forgedCustomer)),
|
forgedCustomer.http === 200 && ['A0400', '400'].includes(codeOf(forgedCustomer)),
|
||||||
`HTTP=${forgedCustomer.http}, code=${codeOf(forgedCustomer)}`)
|
`HTTP=${forgedCustomer.http}, code=${codeOf(forgedCustomer)}`)
|
||||||
|
|
||||||
const scheduleId = await createManualSchedule(context, suffix, ocean.id)
|
const scheduleId = await createManualSchedule(context, suffix, ocean.id, master.customerId)
|
||||||
if (!scheduleId) return
|
if (!scheduleId) return
|
||||||
const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`,
|
const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`,
|
||||||
{ expectedVersion: version, portScheduleId: scheduleId, reason: '人工完成官网查询并复核船期' },
|
{ expectedVersion: version, portScheduleId: scheduleId, reason: '人工完成官网查询并复核船期' },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const OPTIONS = { scenario: 'port-schedule-source-lifecycle', deploymentBlocker:
|
|||||||
const BASE = '/api/platform/v1/port-schedules'
|
const BASE = '/api/platform/v1/port-schedules'
|
||||||
const OCEAN = '/api/platform/v1/ocean-bookings'
|
const OCEAN = '/api/platform/v1/ocean-bookings'
|
||||||
const OUTBOUND = '/api/platform/v1/first-leg/outbound-dispatches'
|
const OUTBOUND = '/api/platform/v1/first-leg/outbound-dispatches'
|
||||||
|
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||||
@@ -38,38 +39,38 @@ async function request(context, method, path, body, headers = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function oceanDraft(context, suffix) {
|
function oceanDraft(context, suffix, master) {
|
||||||
return {
|
return {
|
||||||
customerId: '10001', customerName: `船期验收客户-${suffix}`, billOfLadingNo: `PS-BL-${suffix}`,
|
...oceanBookingDraft(context, suffix, master),
|
||||||
shippingCompanyName: `船期船司-${suffix}`, bookingAgentName: `船期代理-${suffix}`,
|
shippingCompanyName: `船期船司-${suffix}`, bookingAgentName: `船期代理-${suffix}`,
|
||||||
vesselName: `船期船-${suffix}`, voyageNo: `PS${suffix}`, departurePortCode: 'CNSZX',
|
departurePortCode: 'CNSZX',
|
||||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||||
estimatedSailingAt: at(7 * 24 * 60), estimatedArrivalAt: at(28 * 24 * 60), remark: context.marker,
|
remark: context.marker,
|
||||||
parties: [
|
parties: [
|
||||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||||
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
||||||
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
|
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
|
||||||
],
|
],
|
||||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1 }],
|
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureOceanBooking(context, suffix) {
|
async function ensureOceanBooking(context, suffix, master) {
|
||||||
if (context.createdResources?.oceanBooking?.id) return context.createdResources.oceanBooking
|
if (context.createdResources?.oceanBooking?.id) return context.createdResources.oceanBooking
|
||||||
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix) },
|
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) },
|
||||||
{ 'Idempotency-Key': `${context.marker}-port-ocean`.slice(0, 128) })
|
{ 'Idempotency-Key': `${context.marker}-port-ocean`.slice(0, 128) })
|
||||||
const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS)
|
const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS)
|
||||||
const oceanBookingId = id(detail?.oceanBooking?.id)
|
const oceanBookingId = id(detail?.oceanBooking?.id)
|
||||||
context.assert(MODULE, '船期关联订舱客户归属明确', response,
|
context.assert(MODULE, '船期关联订舱客户归属明确', response,
|
||||||
Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === '10001',
|
Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === id(master.customerId),
|
||||||
`oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
|
`oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
|
||||||
return oceanBookingId ? { id: oceanBookingId, customerId: '10001' } : null
|
return oceanBookingId ? { id: oceanBookingId, customerId: master.customerId } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleData(context, suffix, oceanBookingId, containerBookingId, expired = false) {
|
function scheduleData(context, suffix, oceanBookingId, containerBookingId, customerId, expired = false) {
|
||||||
return {
|
return {
|
||||||
customerId: '10001', oceanBookingId, containerBookingId: containerBookingId || null,
|
customerId, oceanBookingId, containerBookingId: containerBookingId || null,
|
||||||
sourceType: 'CARRIER', sourceName: `验收船司-${suffix}`, sourceRef: `${context.marker}-SOURCE-${expired ? 'EXPIRED' : 'CURRENT'}`,
|
sourceType: 'CARRIER', sourceName: `验收船司-${suffix}`, sourceRef: `${context.marker}-SOURCE-${expired ? 'EXPIRED' : 'CURRENT'}`,
|
||||||
vesselName: `港务船-${suffix}`, voyageNo: `PV${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳',
|
vesselName: `港务船-${suffix}`, voyageNo: `PV${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳',
|
||||||
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60),
|
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60),
|
||||||
@@ -100,7 +101,9 @@ export async function run(context) {
|
|||||||
Array.isArray(page?.records) && Number.isFinite(Number(page?.total)),
|
Array.isArray(page?.records) && Number.isFinite(Number(page?.total)),
|
||||||
`records=${page?.records?.length ?? 'n/a'}, total=${page?.total ?? 'n/a'}`, OPTIONS)
|
`records=${page?.records?.length ?? 'n/a'}, total=${page?.total ?? 'n/a'}`, OPTIONS)
|
||||||
|
|
||||||
const ocean = await ensureOceanBooking(context, suffix)
|
const master = await resolveOceanBookingMaster(context, MODULE)
|
||||||
|
if (!master) return
|
||||||
|
const ocean = await ensureOceanBooking(context, suffix, master)
|
||||||
if (!ocean?.id) return
|
if (!ocean?.id) return
|
||||||
const flow = context.createdResources?.phase3Loading
|
const flow = context.createdResources?.phase3Loading
|
||||||
|| context.createdResources?.phase3ContainerLoading
|
|| context.createdResources?.phase3ContainerLoading
|
||||||
@@ -108,7 +111,7 @@ export async function run(context) {
|
|||||||
? id(flow.containerBookingId) : null
|
? id(flow.containerBookingId) : null
|
||||||
const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId
|
const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId
|
||||||
? containerBookingId : null
|
? containerBookingId : null
|
||||||
const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation), reason: context.marker }
|
const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId), reason: context.marker }
|
||||||
const createKey = `${context.marker}-port-create`.slice(0, 128)
|
const createKey = `${context.marker}-port-create`.slice(0, 128)
|
||||||
const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
|
const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
|
||||||
const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS)
|
const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS)
|
||||||
@@ -132,7 +135,7 @@ export async function run(context) {
|
|||||||
`first=${scheduleId}, replay=${id(data(replay)?.portSchedule?.schedule?.id) || 'missing'}`, OPTIONS)
|
`first=${scheduleId}, replay=${id(data(replay)?.portSchedule?.schedule?.id) || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
for (const [name, query] of [
|
for (const [name, query] of [
|
||||||
['按客户查询船期', `customerId=10001`],
|
['按客户查询船期', `customerId=${encodeURIComponent(master.customerId)}`],
|
||||||
['按订舱查询船期', `oceanBookingId=${ocean.id}`],
|
['按订舱查询船期', `oceanBookingId=${ocean.id}`],
|
||||||
...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []),
|
...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []),
|
||||||
]) {
|
]) {
|
||||||
@@ -144,7 +147,7 @@ export async function run(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialVersion = Number(created.portSchedule.schedule.version)
|
const initialVersion = Number(created.portSchedule.schedule.version)
|
||||||
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, true)
|
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true)
|
||||||
expiredData.sourceRef = payload.data.sourceRef
|
expiredData.sourceRef = payload.data.sourceRef
|
||||||
const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
||||||
{ expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' },
|
{ expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' },
|
||||||
@@ -195,7 +198,7 @@ export async function run(context) {
|
|||||||
`passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS)
|
`passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, false)
|
const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false)
|
||||||
currentData.sourceRef = payload.data.sourceRef
|
currentData.sourceRef = payload.data.sourceRef
|
||||||
currentData.sourceType = 'MANUAL'
|
currentData.sourceType = 'MANUAL'
|
||||||
currentData.sourceName = `平台人工兜底-${suffix}`
|
currentData.sourceName = `平台人工兜底-${suffix}`
|
||||||
@@ -222,7 +225,7 @@ export async function run(context) {
|
|||||||
|
|
||||||
const forged = await request(context, 'POST', BASE, {
|
const forged = await request(context, 'POST', BASE, {
|
||||||
expectedVersion: 0,
|
expectedVersion: 0,
|
||||||
data: { ...scheduleData(context, `${suffix}X`, ocean.id, null), customerId: '10002' },
|
data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, master.customerId), customerId: `${master.customerId}999` },
|
||||||
reason: '伪造客户归属',
|
reason: '伪造客户归属',
|
||||||
}, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) })
|
}, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) })
|
||||||
step(context, '伪造船期客户归属被拒绝', forged,
|
step(context, '伪造船期客户归属被拒绝', forged,
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ test('downstream producers prefer the phase3Loading container fixture', () => {
|
|||||||
assert.match(ocean, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
|
assert.match(ocean, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
|
||||||
assert.match(customs, /createdResources\?\.phase3Loading,\s*\n\s*createdResources\?\.phase3ContainerLoading/)
|
assert.match(customs, /createdResources\?\.phase3Loading,\s*\n\s*createdResources\?\.phase3ContainerLoading/)
|
||||||
assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
|
assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
|
||||||
assert.match(ocean, /await action\('放舱', 'release'/)
|
assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('default acceptance order verifies loading evidence before finalizing LOADED', () => {
|
test('default acceptance order verifies loading evidence before finalizing LOADED', () => {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
|
|||||||
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
|
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
|
||||||
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
|
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
|
||||||
$ExpectedSha256 = '10FDF9175B579D564AB5B6FACB8DBE0B8DB52032C9DC11928147E179D21D2041'
|
$ExpectedSha256 = '9F804D3C0133BDEA888202215E71191E22DAAB0649A2CD26C09923E3FCB57899'
|
||||||
$ExpectedLines = 14730
|
$ExpectedLines = 14812
|
||||||
|
|
||||||
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
|
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
|
||||||
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
|
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
|
||||||
|
|||||||
Reference in New Issue
Block a user