This commit is contained in:
2026-08-13 16:14:54 +08:00
parent 8bcd23f307
commit 226ef6a839
20 changed files with 486 additions and 58 deletions
@@ -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) {
+74
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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',
+16
View File
@@ -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', () => {
+2 -2
View File
@@ -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.')