diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5d86ee7b7..2cae72e5f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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')) diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 7ee0e7246..7086b4c1d 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -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 feePool(Long customerId, String direction) { diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 822cf776b..35096e21d 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -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='正式结算单人工收付款与凭证记录'; diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 2f64e8416..2635ea67c 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -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); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java new file mode 100644 index 000000000..c8b178151 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java @@ -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"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index e6481dac1..aa36ba141 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -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(); } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index f42852107..e84f24792 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -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); diff --git a/iam/src/test/java/com/baiyue/iam/service/BusinessAuditCoverageTest.java b/iam/src/test/java/com/baiyue/iam/service/BusinessAuditCoverageTest.java index 408471235..ac05f776c 100644 --- a/iam/src/test/java/com/baiyue/iam/service/BusinessAuditCoverageTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/BusinessAuditCoverageTest.java @@ -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); diff --git a/scripts/README.md b/scripts/README.md index f1d035b10..16c309270 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 601c94a7f..c3c8b270d 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 51b483cc1..edff2e81c 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 +``` diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs new file mode 100644 index 000000000..4da2e138d --- /dev/null +++ b/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) +}) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs new file mode 100644 index 000000000..e860d1591 --- /dev/null +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -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 } +} diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs new file mode 100644 index 000000000..a4eff3ad3 --- /dev/null +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 57ac0c44a..38439d421 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index e187b2d26..e31e2adab 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index c3d1bbaba..f9b6e8d09 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -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: '人工完成官网查询并复核船期' }, diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index afeaa0717..f8e223b3b 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 191acb999..a901e3b8e 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -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', () => { diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index ff30d315b..27d0b69f5 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -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.')