优化
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)
|
||||
) 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;
|
||||
-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql
|
||||
-- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行)
|
||||
@@ -13569,11 +13643,19 @@ BEGIN
|
||||
WHERE table_schema = 'baiyue_finance'
|
||||
AND table_name IN ('fin_fee_source', 'fin_inbox_event', 'fin_subject',
|
||||
'fin_customer_settlement_profile', 'fin_bill', 'fin_bill_line',
|
||||
'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation',
|
||||
'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log')) <> 13 THEN
|
||||
'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation',
|
||||
'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log',
|
||||
'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:Finance 首期人工财务表未完整创建';
|
||||
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
|
||||
WHERE table_schema = 'baiyue_finance'
|
||||
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.StatementLine;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.BadSqlGrammarException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
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," +
|
||||
"(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 JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.reviewStatus'))='APPROVED' " +
|
||||
"AND JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.settlementIncluded'))='true' " +
|
||||
"AND f.review_status='APPROVED' AND f.settlement_included=1 " +
|
||||
"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," +
|
||||
"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 " +
|
||||
"FROM fin_settlement s WHERE s.tenant_id=?";
|
||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
||||
money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
||||
customerId, customerId, customerId);
|
||||
try {
|
||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
||||
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) {
|
||||
|
||||
@@ -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_operator (operator_id, created_at)
|
||||
) 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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.springframework.jdbc.BadSqlGrammarException;
|
||||
|
||||
class CustomerAccountSummaryBehaviorTest {
|
||||
|
||||
@@ -51,8 +52,9 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
||||
.contains("s.direction='RECEIVABLE' 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'")
|
||||
.doesNotContain("JSON_EXTRACT")
|
||||
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
|
||||
}
|
||||
|
||||
@@ -72,6 +74,24 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
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
|
||||
void controllerUsesListPermissionAndDelegatesSummary() throws Exception {
|
||||
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));
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(13);
|
||||
assertThat(columnCount).isEqualTo(190);
|
||||
assertThat(tableCount).isEqualTo(16);
|
||||
assertThat(columnCount).isEqualTo(233);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ public class TenantService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId",
|
||||
@BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", tenantId = "#tenantId",
|
||||
description = "平台编辑企业客户档案")
|
||||
public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
|
||||
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.GrantPermissionsRequest;
|
||||
import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
@@ -49,6 +50,17 @@ class BusinessAuditCoverageTest {
|
||||
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,
|
||||
String resourceId, Class<?>... parameterTypes) throws Exception {
|
||||
BusinessAudit audit = annotation(type, methodName, parameterTypes);
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令
|
||||
|
||||
### 本地 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
|
||||
.\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_USERNAME = 'platform_admin'
|
||||
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 CUSTOMER_FIELDS = [
|
||||
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
||||
|
||||
@@ -59,3 +59,19 @@ node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
|
||||
```powershell
|
||||
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 inboundId = text(source?.inboundId)
|
||||
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)}`)
|
||||
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 = {
|
||||
customerId: text(summary?.tenantId || tenantId),
|
||||
@@ -36,7 +36,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
|
||||
...master,
|
||||
shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${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',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||
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 } }
|
||||
}
|
||||
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 }) }
|
||||
|
||||
export async function run(context) {
|
||||
@@ -41,20 +41,20 @@ export async function run(context) {
|
||||
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)
|
||||
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' }
|
||||
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)
|
||||
const path = `${BASE}/${bookingId}`
|
||||
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` })
|
||||
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)}`)
|
||||
const afterStale = await request(context, 'GET', path); const afterData = data(afterStale)
|
||||
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)
|
||||
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')
|
||||
@@ -80,7 +80,14 @@ export async function run(context) {
|
||||
&& Number.isInteger(version),
|
||||
`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)
|
||||
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
|
||||
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 OCEAN = '/api/platform/v1/ocean-bookings'
|
||||
const PORT = '/api/platform/v1/port-schedules'
|
||||
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
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 {
|
||||
customerId: '10001', customerName: `船司查询客户-${suffix}`, billOfLadingNo: `CSQ-BL-${suffix}`,
|
||||
...oceanBookingDraft(context, suffix, master),
|
||||
shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`,
|
||||
vesselName: `船司查询船-${suffix}`, voyageNo: `CQ${suffix}`, departurePortCode: 'CNSZX',
|
||||
departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||
estimatedSailingAt: at(7 * 24 * 60), estimatedArrivalAt: at(28 * 24 * 60), remark: context.marker,
|
||||
remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', 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
|
||||
if (existing?.id && id(existing.customerId || '10001') === '10001') return { id: id(existing.id), containerBookingId: null }
|
||||
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix) },
|
||||
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, master) },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) })
|
||||
const detail = context.apiSuccess(MODULE, '创建船司查询隔离订舱', response, '', OPTIONS)
|
||||
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 expectedScope = { customerId: '10001', oceanBookingId, containerBookingId: null }
|
||||
const expectedScope = { customerId, oceanBookingId, containerBookingId: null }
|
||||
if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id)
|
||||
const body = {
|
||||
expectedVersion: 0,
|
||||
data: {
|
||||
customerId: '10001', oceanBookingId, containerBookingId: null, sourceType: 'MANUAL',
|
||||
customerId, oceanBookingId, containerBookingId: null, sourceType: 'MANUAL',
|
||||
sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`,
|
||||
vesselName: `人工船期船-${suffix}`, voyageNo: `CM${suffix}`, departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
@@ -106,10 +107,12 @@ export async function run(context) {
|
||||
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false),
|
||||
`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
|
||||
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',
|
||||
trackingNo: `${context.marker}-ZIM`, remark: context.marker,
|
||||
}
|
||||
@@ -167,7 +170,7 @@ export async function run(context) {
|
||||
forgedCustomer.http === 200 && ['A0400', '400'].includes(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
|
||||
const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`,
|
||||
{ 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 OCEAN = '/api/platform/v1/ocean-bookings'
|
||||
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 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 {
|
||||
customerId: '10001', customerName: `船期验收客户-${suffix}`, billOfLadingNo: `PS-BL-${suffix}`,
|
||||
...oceanBookingDraft(context, suffix, master),
|
||||
shippingCompanyName: `船期船司-${suffix}`, bookingAgentName: `船期代理-${suffix}`,
|
||||
vesselName: `船期船-${suffix}`, voyageNo: `PS${suffix}`, departurePortCode: 'CNSZX',
|
||||
departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||
estimatedSailingAt: at(7 * 24 * 60), estimatedArrivalAt: at(28 * 24 * 60), remark: context.marker,
|
||||
remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', 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
|
||||
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) })
|
||||
const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS)
|
||||
const oceanBookingId = id(detail?.oceanBooking?.id)
|
||||
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)
|
||||
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 {
|
||||
customerId: '10001', oceanBookingId, containerBookingId: containerBookingId || null,
|
||||
customerId, oceanBookingId, containerBookingId: containerBookingId || null,
|
||||
sourceType: 'CARRIER', sourceName: `验收船司-${suffix}`, sourceRef: `${context.marker}-SOURCE-${expired ? 'EXPIRED' : 'CURRENT'}`,
|
||||
vesselName: `港务船-${suffix}`, voyageNo: `PV${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳',
|
||||
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)),
|
||||
`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
|
||||
const flow = context.createdResources?.phase3Loading
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
@@ -108,7 +111,7 @@ export async function run(context) {
|
||||
? id(flow.containerBookingId) : null
|
||||
const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId
|
||||
? 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 createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
|
||||
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)
|
||||
|
||||
for (const [name, query] of [
|
||||
['按客户查询船期', `customerId=10001`],
|
||||
['按客户查询船期', `customerId=${encodeURIComponent(master.customerId)}`],
|
||||
['按订舱查询船期', `oceanBookingId=${ocean.id}`],
|
||||
...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []),
|
||||
]) {
|
||||
@@ -144,7 +147,7 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
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
|
||||
const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
||||
{ expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' },
|
||||
@@ -195,7 +198,7 @@ export async function run(context) {
|
||||
`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.sourceType = 'MANUAL'
|
||||
currentData.sourceName = `平台人工兜底-${suffix}`
|
||||
@@ -222,7 +225,7 @@ export async function run(context) {
|
||||
|
||||
const forged = await request(context, 'POST', BASE, {
|
||||
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: '伪造客户归属',
|
||||
}, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) })
|
||||
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(customs, /createdResources\?\.phase3Loading,\s*\n\s*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', () => {
|
||||
|
||||
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
|
||||
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
|
||||
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
|
||||
$ExpectedSha256 = '10FDF9175B579D564AB5B6FACB8DBE0B8DB52032C9DC11928147E179D21D2041'
|
||||
$ExpectedLines = 14730
|
||||
$ExpectedSha256 = '9F804D3C0133BDEA888202215E71191E22DAAB0649A2CD26C09923E3FCB57899'
|
||||
$ExpectedLines = 14812
|
||||
|
||||
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
|
||||
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
|
||||
|
||||
Reference in New Issue
Block a user