Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	scripts/admin-test-env-acceptance.mjs
#	scripts/agents-index-contract.ps1
#	scripts/full-regression-entry-contract.test.mjs
This commit is contained in:
2026-09-02 20:32:23 +08:00
28 changed files with 1412 additions and 58 deletions
+2
View File
@@ -33,6 +33,8 @@
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
Vendored
+23 -9
View File
@@ -60,14 +60,15 @@ def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
def parseAutoBuildState(String description, List<String> allowedServices) {
String marker = ' | RESULTS'
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
int markerIndex = description.indexOf(marker)
if (!description?.startsWith('AUTO ')) return null
int markerIndex = description.indexOf(' | ')
if (markerIndex < 0) return null
String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String summary = description.substring(markerIndex + marker.length()).trim()
String state = description.substring(markerIndex + ' | '.length()).trim()
List<String> failedServices = []
if (summary) {
if (state.startsWith('RESULTS')) {
String summary = state.substring('RESULTS'.length()).trim()
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
int separator = item.indexOf('=')
if (separator > 0) {
@@ -76,6 +77,17 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
}
}
} else if (state.startsWith('PENDING')) {
// 上一轮若在统一测试、排队或并行部署期间失败/取消,description 仍是 PENDING。
// 将该轮已选服务作为精确补跑范围,同时让 Git 基线前进到该轮提交,
// 避免回退到很早的成功构建后把历史公共文件再次解释成全量变更。
String selected = state.substring('PENDING'.length()).trim()
int suffixIndex = selected.indexOf(' | ')
if (suffixIndex >= 0) selected = selected.substring(0, suffixIndex).trim()
failedServices.addAll(selected.split(',').collect { it.trim() }
.findAll { allowedServices.contains(it) })
} else {
return null
}
return [commit: commit, failedServices: failedServices.unique()]
}
@@ -85,7 +97,8 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
int inspected = 0
while (candidate != null && inspected < 30) {
def state = parseAutoBuildState(candidate.description, allowedServices)
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state
// 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。
if (state != null) return state
candidate = candidate.previousBuild
inspected++
}
@@ -190,9 +203,10 @@ pipeline {
String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: []
if (!base) {
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
// SELECT/ALL 不会推进 AUTO 基线。
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
// 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
// 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim()
if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}"
}
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
selected = serviceModules
+1
View File
@@ -19,6 +19,7 @@
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
+2 -1
View File
@@ -13,9 +13,10 @@
|---|---|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| Inbox 接收与幂等 | `service/EventInboxService.java` |
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
| 国内 Finance 费用事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;统一内部入口 `/internal/finance/v1/business-fee-source-events`,配置 `baiyue.event-dispatcher.finance-consumer-url`,不得误投 Firstleg |
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
+3 -1
View File
@@ -22,7 +22,7 @@
| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单分别使用 `REC`、`AR`、`AP` 前缀 |
| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 |
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java`、`service/BusinessFeeSourceEventService.java` |
| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java` 的 `/internal/finance/v1/business-fee-source-events`、`service/BusinessFeeSourceEventService.java`;事件模型 `../common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java` |
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
| Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` |
@@ -43,6 +43,8 @@
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
- 海外业务费用事件只接受非 `FIRSTLEG` 来源、`APPROVED` 审核快照和 `PUBLISHED` 活跃版本或明确 `VOIDED` 动作;按 `eventId + payloadHash` 防重放冲突,并以 `sourceSystem + sourceType + sourceFeeId + sourceVersion` 单调落库。事件携带完整客户、业务、方向、供应商、付款与来源哈希事实,Finance 不跨区同步回查 Lastleg。
- 海外 `PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID` 已确认成本并计入已付成本/客户流水;不得进入 ON_ACCOUNT 应付池或再次生成 AP 结算。调整或作废须作废旧版本成本;客户隔离、同键异载荷冲突和已生成单据冻结不得放宽。
- 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。
- 应付费用来源、成本登记和应付结算单引用的供应商必须通过 Warehouse 校验 ID 与 `ACTIVE` 状态;费用项目 `supplierCategory` 为空时允许任意启用供应商,非空时严格校验类别,不得从费用编码或名称猜测。名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或非空类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。
- 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。
@@ -131,6 +131,7 @@ public final class FinanceReadModels {
int sourceVersion,
Long orderId,
Long inboundId,
String inboundCode,
String feeItemCode,
String feeItemName,
BigDecimal amount,
@@ -188,9 +188,13 @@ public class FinanceQueryService {
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
List<StatementLine> lines = jdbc.query("SELECT id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",
List<StatementLine> lines = jdbc.query("SELECT l.id,l.source_type,l.source_id,l.source_version,l.order_id,l.inbound_id," +
"NULLIF(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')),'null') inbound_code," +
"l.fee_item_code,l.fee_item_name,l.amount,l.currency_code FROM fin_bill_line l " +
"LEFT JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id " +
"WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
(rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), rs.getString("fee_item_code"), rs.getString("fee_item_name"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), rs.getString("inbound_code"), rs.getString("fee_item_code"), rs.getString("fee_item_name"),
rs.getBigDecimal("amount"), rs.getString("currency_code")), customerId, billId);
return new StatementDetail(rows.get(0), lines);
}
@@ -79,6 +79,8 @@ class FinancePlatformListContractTest {
"lastReceiptAt", "submittedAt", "reviewedAt", "issuedAt");
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.Statement.class,
"createdBy", "createdByName", "updatedBy", "updatedByName");
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.StatementLine.class,
"orderId", "inboundId", "inboundCode", "feeItemName", "amount");
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.BillRevision.class,
"createdBy", "createdByName");
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail.class,
+1 -1
View File
@@ -28,7 +28,7 @@
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` |
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按真实客户和订单原子分摊,入仓ID/入仓码同时填写或同时留空;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
+1 -1
View File
@@ -42,7 +42,7 @@
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务费用来源 | `financefee/`;海外入仓、采购、本土卡车、本土散板和海外仓服务费审核后以 Outbox/Event Bridge 进入独立 Finance,入仓记录和入仓码按事实选填;OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
+1
View File
@@ -15,6 +15,7 @@
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` |
| Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 |
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
| 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
+41 -21
View File
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', '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-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', '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-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'firstleg-container-fee-allocation', 'finance-direct-paid-isolation', 'phase4-overseas-finance-cross-region', 'customer-operating-statistics']
const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
@@ -78,6 +78,10 @@ const MODULE_DEPENDENCIES = new Map([
['phase5-overseas-fulfillment', ['phase4-overseas-dispatch']],
['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']],
['phase4-carrier-nodes', ['phase4-overseas-pda-operations']],
['phase4-overseas-business', ['merchant-qualification-fixture']],
['firstleg-container-fee-allocation', []],
['finance-direct-paid-isolation', ['finance-full-fixtures', 'finance-reconciliation-full']],
['phase4-overseas-finance-cross-region', ['phase4-overseas-business', 'phase4-overseas-pda-operations']],
])
function stableTopologicalOrder(modules) {
@@ -242,7 +246,7 @@ export function normalizeModuleOrder(modules) {
const missing = dependencyClosure(module).filter((dependency) => !normalized.includes(dependency))
if (missing.length > 0) normalized.splice(normalized.indexOf(module), 0, ...missing)
}
return stableTopologicalOrder(normalized)
return stableTopologicalOrder([...new Set(normalized)])
}
const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES)
@@ -315,7 +319,9 @@ export function parseArguments(argv) {
values.set(name, next); index += 1
}
}
const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean))
const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(','))
.split(',').map((v) => v.trim()).filter(Boolean)
const modules = normalizeModuleOrder(requestedModules)
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
if (merchantUsername && merchantPassword && !modules.includes('phase4-overseas-business')) {
@@ -328,6 +334,7 @@ export function parseArguments(argv) {
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '',
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '',
modules,
requestedModules,
customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '',
merchantUsername,
merchantPassword,
@@ -401,9 +408,9 @@ export function validateExecutionOptions(options) {
}
if (options.waitSplitCount > 1) {
const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement',
'manual-order-splitting-fixture', 'coload-groups'])
'manual-order-splitting-fixture', 'coload-groups', 'firstleg-container-fee-allocation'])
if (options.modules.some((module) => !fixtureModules.has(module))) {
throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement、manual-order-splitting-fixture')
throw new Error('批量WAIT_SPLIT只允许执行WAIT_SPLIT造数、同柜组与柜次跨客户分摊模块')
}
}
if (options.modules.includes('manual-order-splitting-fixture') && options.waitSplitCount < 4) {
@@ -526,23 +533,31 @@ export class AcceptanceRun {
const requestBody = path === '/api/auth/login' && body?.username && !body?.loginId
? { ...body, loginId: body.username, username: undefined }
: body
try {
const response = await fetch(`${this.options.gateway}${path}`, {
method, headers, body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
signal: AbortSignal.timeout(30_000),
})
const content = await response.text()
let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ }
return {
method, path, http: response.status, body: payload,
raw: payload === null ? content.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id'),
const maxAttempts = ['GET', 'HEAD'].includes(String(method).toUpperCase()) ? 3 : 1
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(`${this.options.gateway}${path}`, {
method, headers, body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
signal: AbortSignal.timeout(30_000),
})
const content = await response.text()
let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ }
return {
method, path, http: response.status, body: payload,
raw: payload === null ? content.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
if (attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, attempt * 250))
continue
}
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
} catch (error) {
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
throw new Error('unreachable request attempt state')
}
async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) {
const headers = { Accept: 'application/json', ...extraHeaders }
@@ -1332,6 +1347,7 @@ export class AcceptanceRun {
internalRemark: fixtureOrder.internalRemark,
cargoRemark: fixtureOrder.cargoRemark,
taxMode: fixtureOrder.taxMode || null,
requireInventoryMutation: requireSharedOverflow,
} : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true }
const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC`
this.marker = incrementalMarker
@@ -1738,7 +1754,11 @@ export class AcceptanceRun {
}
if (module === 'phase3-loading') {
try {
restoreFixture = await this.preparePhase3LoadingFixture()
// The A0 stale-inventory mutation is intentionally destructive and expensive on
// a large shared test warehouse. Run it only when phase3-loading was explicitly
// requested; downstream phase4/finance chains need the normal loading fixture only.
const requireSharedOverflow = this.options.requestedModules.includes('phase3-loading')
restoreFixture = await this.preparePhase3LoadingFixture('', requireSharedOverflow)
} catch (error) {
this.marker = this.marker.replace(/-LOAD$/, '')
const reason = error instanceof Error ? error.message : String(error)
+1
View File
@@ -77,6 +77,7 @@ export async function run(context) {
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
@@ -24,7 +24,7 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
'整组原子移除',
'容量竞争时智能推荐不因整组容量失败',
'容量竞争时同柜组只能整组全取或全不取',
'推荐候选仅含当前目标同柜组且成员身份精确',
'推荐候选包含当前目标同柜组且成员身份精确',
'ACTIVE同柜组整组取消成功',
'同柜组PDA原子批次记录短拣异常',
'同柜组任一成员短拣时完成被拒绝',
@@ -461,8 +461,8 @@ export async function run(context) {
`members=${JSON.stringify(crossDestinationFacts)}`, OPTIONS)
if (crossDestinationGroup?.groupId) {
const cleanupResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(crossDestinationGroup.groupId)}/cancel`, {
expectedVersion: Number(crossDestinationGroup.version), reason: '释放跨目的仓正向验收组成员',
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(fifth.inboundCode)}/cancel`, {
reason: '释放跨目的仓正向验收组成员',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-cross-destination-cancel`.slice(0, 128) })
context.apiSuccess(MODULE, '跨目的仓正向组整组取消并释放成员', cleanupResponse,
evidence(cleanupResponse), OPTIONS)
@@ -532,8 +532,8 @@ export async function run(context) {
.map((item) => id(item?.coLoadGroupId)).filter(Boolean))]
const targetCandidateInbounds = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId)
.map((item) => id(item?.inboundId)).sort()
context.assert(MODULE, '推荐候选仅含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.length === 1 && candidateGroupIds[0] === groupId
context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.includes(groupId)
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()),
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS)
const allCandidateVolume = isolatedCandidates
@@ -675,8 +675,8 @@ export async function run(context) {
`before=${beforeMutation}, after=${planSignature(afterMutation.data)}`, OPTIONS)
const cancelLockedResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(groupId)}/cancel`, {
expectedVersion: Number(group?.version) + 1, reason: '锁定组取消反向验收',
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(first.inboundCode)}/cancel`, {
reason: '锁定组取消反向验收',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-cancel-locked`.slice(0, 128) })
conflict(context, '已锁定组不能取消', cancelLockedResponse)
@@ -729,8 +729,8 @@ export async function run(context) {
const cancelGroupId = id(cancelGroup?.groupId)
if (!cancelGroupId) return
const cancelResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(cancelGroupId)}/cancel`, {
expectedVersion: Number(cancelGroup?.version), reason: '验收ACTIVE整组取消',
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(fifth.inboundCode)}/cancel`, {
reason: '验收ACTIVE整组取消',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-active-cancel`.slice(0, 128) })
const cancelled = context.apiSuccess(MODULE, 'ACTIVE同柜组整组取消成功', cancelResponse,
evidence(cancelResponse), OPTIONS)
@@ -917,8 +917,8 @@ export async function run(context) {
`treeStable=${splitTreeBeforeGroup === treeSignature(splitTreeAfter)}`, OPTIONS)
if (splitGroup?.groupId) {
const splitCancelResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(splitGroup.groupId)}/cancel`, {
expectedVersion: Number(splitGroup.version), reason: '完成拆分余量正向验收',
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(eighth.inboundCode)}/cancel`, {
reason: '完成拆分余量正向验收',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-split-cancel`.slice(0, 128) })
context.apiSuccess(MODULE, '拆分余量正向组整组取消', splitCancelResponse,
evidence(splitCancelResponse), OPTIONS)
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { buildDirectPaidBody, findDirectPaidCost, findOpenSettlement } from './finance-direct-paid-isolation.mjs'
const source = await readFile(new URL('./finance-direct-paid-isolation.mjs', import.meta.url), 'utf8')
test('DIRECT_PAID 请求只绑定客户、仓库、供应商且不伪造业务关联', () => {
const body = buildDirectPaidBody('RUN-001', { customerId: '101', customerName: '客户A' },
{ id: '201', name: '供应商A' }, { id: '301' })
assert.equal(body.businessType, 'WAREHOUSE_SERVICE')
assert.equal(body.sourceType, 'OVERSEAS_WAREHOUSE_FEE')
assert.equal(body.settlementMode, 'DIRECT_PAID')
assert.equal(body.direction, 'PAYABLE')
assert.equal(body.customerId, '101')
assert.equal(body.warehouseId, '301')
assert.equal(body.supplierId, '201')
assert.equal(body.orderId, null)
assert.equal(body.inboundId, null)
assert.equal(body.inboundCode, null)
assert.equal(body.containerId, null)
assert.match(body.paymentReferenceNo, /DIRECT-PAID/)
})
test('成本与未结清结算单选择严格命中客户金额和可支付状态', () => {
const body = { customerId: '101', amount: 17.31, feeItemName: '海外仓服务已付费用' }
assert.equal(findDirectPaidCost([
{ id: 1, customerId: '102', amount: 17.31, feeItemName: body.feeItemName },
{ id: 2, customerId: '101', amount: 17.31, feeItemName: body.feeItemName },
], body)?.id, 2)
assert.equal(findOpenSettlement([
{ settlementId: 1, outstandingAmount: 0, status: 'PAID' },
{ settlementId: 2, outstandingAmount: 9, status: 'ISSUED' },
])?.settlementId, 2)
})
test('HTTP 模块覆盖直接已付投影、AP 排除、幂等冲突、冻结和第二客户隔离', () => {
for (const token of [
'BUSINESS_DIRECT_PAID', 'customer-accounts/', 'cost-entries', 'fee-pool', 'payable-settlements',
'收款同键同参重放且异参冲突', 'AP 付款同键同参重放且异参冲突',
'拒绝编辑、调整和作废且零漂移', '第二客户不能读取或确认他人账单',
'foreign-receipt', 'foreign-payable', 'merchant-qualification-fixture.mjs',
]) assert.ok(source.includes(token), token)
assert.match(source, /fixture\.marker !== context\.marker/)
assert.match(source, /禁止借历史财务单据/)
assert.doesNotMatch(source, /INSERT\s+INTO|UPDATE\s+fin_|DELETE\s+FROM/i)
})
test('敏感凭据不写入输出且临时客户上下文完整恢复', () => {
assert.match(source, /originalUsername/)
assert.match(source, /originalPassword/)
assert.match(source, /delete process\.env\.MERCHANT_TEST_PASSWORD/)
assert.doesNotMatch(source, /console\.log\([^)]*password/i)
assert.doesNotMatch(source, /MYSQL_PWD|Authorization:\s*`Bearer[^$]/)
})
@@ -0,0 +1,322 @@
const MODULE = 'finance-direct-paid-isolation'
const SCENARIO = 'finance-direct-paid-idempotency-freeze-isolation'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const FINANCE = '/api/platform/finance/v1'
const CLIENT = '/api/client/finance/v1'
const OVERSEAS_FEES = '/api/platform/v1/last-leg/finance-fee-sources'
const id = (value) => value == null ? '' : String(value)
const data = (response) => response?.body?.data ?? response?.body
const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : []
const ok = (response) => response?.http === 200 && String(response?.body?.code ?? '200') === '200'
const rejected = (response) => !ok(response)
const statementId = (value) => id(value?.statementId ?? value?.reconciliationStatementId ?? value?.id)
const settlementId = (value) => id(value?.settlementId ?? value?.id)
const sourceId = (value) => id(value?.sourceFeeId ?? value?.feeSourceId ?? value?.id)
const costId = (value) => id(value?.costId ?? value?.id)
const version = (value) => Number(value?.operationVersion ?? value?.version ?? value?.summary?.version ?? 0)
const outstanding = (value) => Number(value?.outstandingAmount ?? value?.summary?.outstandingAmount ?? 0)
const today = () => new Date().toISOString().slice(0, 10)
function key(context, operation) {
return `${context.marker}-finance-direct-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function write(context, method, path, body, operation, token = context.token, overseas = false) {
const headers = { 'Idempotency-Key': key(context, operation) }
return overseas
? context.requestOverseas(method, path, body, headers, token)
: context.request(method, path, body, token, headers)
}
function diagnostic(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${id(response?.body?.code) || 'missing'}, requestId=${response?.requestId ?? 'missing'}, traceId=${response?.traceId ?? 'missing'}`
}
function blocker(context, name, reason, response = null, category = 'finance-direct-paid-prerequisite') {
context.addStep(MODULE, name, response, false, `${reason}; ${diagnostic(response)}`, OPTIONS)
context.addCoverageGap?.(MODULE, SCENARIO, name, reason, category, {
marker: context.marker, requestId: response?.requestId ?? null, traceId: response?.traceId ?? null,
})
}
function finish(context, start) {
const steps = context.steps.slice(start)
context.scenarios.push({ name: SCENARIO, module: MODULE,
success: steps.every((step) => step.passed),
deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length,
failed: steps.filter((step) => !step.passed).length })
}
export function buildDirectPaidBody(marker, fixture, supplier, warehouse) {
const businessNo = `WHS-${String(marker).replace(/[^A-Za-z0-9]/g, '').slice(-24)}`
return {
customerId: id(fixture.customerId), customerName: fixture.customerName || null,
sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_SERVICE', businessId: null,
businessNo, orderId: null, inboundId: null, inboundCode: null,
containerId: null, containerBookingId: null, containerNo: null,
warehouseId: id(warehouse.id ?? warehouse.warehouseId),
feeItemCode: 'WAREHOUSE_SERVICE_DIRECT_PAID', feeItemName: `${marker} 海外仓服务已付费用`,
direction: 'PAYABLE', supplierId: id(supplier.id ?? supplier.supplierId),
supplierName: supplier.name ?? supplier.supplierName, supplierCategory: null,
settlementMode: 'DIRECT_PAID', paidAt: new Date().toISOString(), paymentMethod: 'BANK_TRANSFER',
paymentReferenceNo: `${marker}-DIRECT-PAID`, paymentVoucherFileIds: [],
businessDate: today(), currency: 'CNY', amount: 17.31,
}
}
export function findDirectPaidCost(items, body) {
return rows(items).find((item) => Number(item?.amount ?? item?.totalAmount ?? item?.costAmount ?? item?.payableAmount) === Number(body.amount)
&& id(item?.customerId ?? item?.tenantId) === id(body.customerId)
&& (item?.feeItemName === body.feeItemName
|| id(item?.costNo ?? item?.settlementNo).startsWith('DIRECT-')))
}
export function findOpenSettlement(items) {
return rows(items).find((item) => settlementId(item)
&& !['VOIDED', 'PAID'].includes(id(item?.status))
&& !['VOIDED', 'PAID'].includes(id(item?.paymentStatus))) ?? null
}
async function ensureFixture(context) {
const fixture = context.createdResources?.financeFullFixtures
if (!fixture || fixture.marker !== context.marker || !fixture.customerId || !fixture.voucherFileId) return null
return fixture
}
async function loadActiveSupplierAndWarehouse(context, fixture) {
const supplier = fixture.supplier || rows(fixture.suppliers)[0]
const warehouseResponse = await context.request('GET',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS')
const warehousePage = context.apiSuccess(MODULE, '查询海外自营仓用于无入仓码已付费用', warehouseResponse,
diagnostic(warehouseResponse), OPTIONS)
const warehouse = rows(warehousePage).find((item) => id(item?.id ?? item?.warehouseId))
if (!id(supplier?.id ?? supplier?.supplierId) || !(supplier?.name ?? supplier?.supplierName) || !warehouse) {
return { supplier, warehouse, response: warehouseResponse }
}
return { supplier, warehouse, response: warehouseResponse }
}
async function publishDirectPaid(context, fixture, supplier, warehouse) {
const body = buildDirectPaidBody(context.marker, fixture, supplier, warehouse)
const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
const created = context.apiSuccess(MODULE, '创建无入仓码海外仓 DIRECT_PAID 来源', createdResponse,
diagnostic(createdResponse), OPTIONS)
const targetId = sourceId(created?.sourceFeeId ? created : { feeSourceId: created?.sourceFeeId })
|| id(created?.sourceFeeId)
context.assert(MODULE, 'DIRECT_PAID 来源仅绑定客户、仓库与供应商', createdResponse,
Boolean(targetId) && created?.settlementMode === 'DIRECT_PAID' && created?.direction === 'PAYABLE'
&& !created?.orderId && !created?.inboundId && !created?.inboundCode && !created?.containerId,
`sourceFeeId=${targetId || 'missing'}, settlementMode=${created?.settlementMode || 'missing'}`, OPTIONS)
if (!targetId) return null
const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
const replay = data(replayResponse)
context.assert(MODULE, '海外费用创建同键同参重放同一来源', replayResponse,
ok(replayResponse) && sourceId(replay?.sourceFeeId ? replay : { feeSourceId: replay?.sourceFeeId }) === targetId,
`first=${targetId}, replay=${id(replay?.sourceFeeId) || 'missing'}`, OPTIONS)
const conflictResponse = await write(context, 'POST', OVERSEAS_FEES,
{ ...body, amount: body.amount + 1 }, 'overseas-create', context.overseasToken, true)
context.assert(MODULE, '海外费用创建同键异参冲突', conflictResponse, rejected(conflictResponse),
diagnostic(conflictResponse), OPTIONS)
const submittedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/submit`,
{ idempotencyKey: key(context, 'overseas-submit') })
context.apiSuccess(MODULE, '提交 DIRECT_PAID 来源审核', submittedResponse, diagnostic(submittedResponse), OPTIONS)
const reviewedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/review`,
{ decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') })
context.apiSuccess(MODULE, '审核发布 DIRECT_PAID 来源', reviewedResponse, diagnostic(reviewedResponse), OPTIONS)
return { sourceFeeId: targetId, body }
}
async function verifyDirectPaidProjection(context, fixture, published) {
let source = null
let costs = []
let summary = null
for (let attempt = 0; attempt < 40; attempt += 1) {
const [sourcesResponse, costsResponse, summaryResponse] = await Promise.all([
context.request('GET', `${FINANCE}/fee-sources?page=1&pageSize=200&customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`),
context.request('GET', `${FINANCE}/cost-entries?customerId=${encodeURIComponent(fixture.customerId)}`),
context.request('GET', `${FINANCE}/customer-accounts/${encodeURIComponent(fixture.customerId)}/summary`),
])
source = rows(data(sourcesResponse)).find((item) => id(item?.sourceFeeId) === published.sourceFeeId
&& item?.sourceSystem !== 'FIRSTLEG')
costs = rows(data(costsResponse))
summary = data(summaryResponse)
if (source && findDirectPaidCost(costs, published.body)) break
await new Promise((resolve) => setTimeout(resolve, 500))
}
const cost = findDirectPaidCost(costs, published.body)
context.assert(MODULE, 'DIRECT_PAID 生成 BUSINESS_DIRECT_PAID 成本并进入客户总成本和已付成本', null,
Boolean(source && cost) && Number(summary?.totalCost) >= published.body.amount
&& Number(summary?.paidCost) >= published.body.amount,
`source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}`, OPTIONS)
const apPoolResponse = await context.request('GET',
`${FINANCE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`)
const apListResponse = await context.request('GET',
`${FINANCE}/payable-settlements?customerId=${encodeURIComponent(fixture.customerId)}`)
const inApPool = rows(data(apPoolResponse)).some((item) => id(item?.sourceFeeId) === published.sourceFeeId)
const inAp = rows(data(apListResponse)).some((item) => rows(item?.lines).some((line) => id(line?.sourceFeeId) === sourceId(source)))
context.assert(MODULE, 'DIRECT_PAID 不进入 AP 费用池或应付结算单', apPoolResponse,
!inApPool && !inAp, `inApPool=${inApPool}, inAp=${inAp}`, OPTIONS)
return { source, cost, summary }
}
async function verifyPaymentIdempotency(context, fixture) {
const ar = findOpenSettlement(fixture.receivableSettlements)
const ap = findOpenSettlement(fixture.payableSettlements)
if (!ar || !ap) {
blocker(context, '取得未结清 AR/AP 用于收付款幂等',
`AR=${settlementId(ar) || 'missing'}, AP=${settlementId(ap) || 'missing'};需在 finance-settlement-full 消费前执行本模块`, null,
'open-finance-settlement-unavailable')
return
}
const arDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ar))}`)
const arDetail = context.apiSuccess(MODULE, '读取未结清 AR 详情用于收款幂等', arDetailResponse,
diagnostic(arDetailResponse), OPTIONS)
const receiptAmount = Math.min(0.01, outstanding(arDetail))
const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: receiptAmount,
currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)],
remark: context.marker, allocations: [{ billId: statementId(ar), amount: receiptAmount }] }
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay')
const receiptReplay = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay')
const receiptConflict = await write(context, 'POST', `${FINANCE}/receipts`,
{ ...receiptBody, remark: `${context.marker}-different` }, 'receipt-replay')
context.assert(MODULE, '收款同键同参重放且异参冲突', receiptReplay,
ok(receipt) && ok(receiptReplay)
&& id(data(receipt)?.receiptId ?? data(receipt)?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id)
&& rejected(receiptConflict),
`receipt=${id(data(receipt)?.receiptId ?? data(receipt)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS)
const apDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}`)
const apDetail = data(apDetailResponse)
const paymentAmount = Math.min(0.01, outstanding(apDetail))
const paymentBody = { transactionDate: today(), amount: paymentAmount, currency: 'CNY', method: 'BANK_TRANSFER',
referenceNo: `${context.marker}-AP-IDEMPOTENT`, voucherFileIds: [id(fixture.voucherFileId)],
expectedVersion: version(apDetail), remark: context.marker }
const path = `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}/transactions`
const payment = await write(context, 'POST', path, paymentBody, 'ap-replay')
const paymentReplay = await write(context, 'POST', path, paymentBody, 'ap-replay')
const paymentConflict = await write(context, 'POST', path,
{ ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, 'ap-replay')
context.assert(MODULE, 'AP 付款同键同参重放且异参冲突', paymentReplay,
ok(payment) && ok(paymentReplay) && rejected(paymentConflict)
&& version(data(paymentReplay)) === version(data(payment)),
`settlementId=${settlementId(ap)}, version=${version(data(paymentReplay))}; conflict=${diagnostic(paymentConflict)}`, OPTIONS)
}
async function verifyFrozenSource(context, fixture) {
const mapped = rows(fixture.statements).flatMap((item) => rows(item?.feeSourceIds)).map(id).filter(Boolean)
const targetId = mapped[0] || rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).filter(Boolean)[0]
if (!targetId) {
blocker(context, '取得已进入有效单据的来源', 'finance reconciliation/settlement fixture 未提供 feeSourceId', null,
'frozen-fee-source-unavailable')
return
}
const beforeResponse = await context.request('GET', `${FINANCE}/fee-sources/${encodeURIComponent(targetId)}`)
const before = data(beforeResponse)
const expectedVersion = version(before)
const edit = await write(context, 'PUT', `${FINANCE}/fee-sources/${targetId}`,
{ amount: Number(before?.amount || 1) + 1, reason: context.marker, expectedVersion }, 'frozen-edit')
const adjust = await write(context, 'POST', `${FINANCE}/fee-sources/${targetId}/adjust`,
{ amount: Number(before?.amount || 1) + 2, reason: context.marker, expectedVersion }, 'frozen-adjust')
const voided = await write(context, 'POST', `${FINANCE}/fee-sources/${targetId}/void`,
{ reason: context.marker, expectedVersion }, 'frozen-void')
const afterResponse = await context.request('GET', `${FINANCE}/fee-sources/${encodeURIComponent(targetId)}`)
const after = data(afterResponse)
context.assert(MODULE, '已进入有效账单或结算的来源拒绝编辑、调整和作废且零漂移', afterResponse,
[edit, adjust, voided].every(rejected) && version(after) === expectedVersion
&& Number(after?.amount) === Number(before?.amount) && after?.sourceStatus === before?.sourceStatus,
`source=${targetId}, beforeVersion=${expectedVersion}, afterVersion=${version(after)}; edit=${diagnostic(edit)}; adjust=${diagnostic(adjust)}; void=${diagnostic(voided)}`, OPTIONS)
}
async function createSecondCustomer(context) {
const originalMarker = context.marker
const originalContext = context.context?.merchantQualificationFixture
const originalResource = context.createdResources?.merchantQualificationFixture
const originalUsername = process.env.MERCHANT_TEST_USERNAME
const originalPassword = process.env.MERCHANT_TEST_PASSWORD
context.marker = `${originalMarker}-FIN-ISO`
try {
const module = await import(new URL('./merchant-qualification-fixture.mjs', import.meta.url))
await module.run(context)
const fixture = context.context?.merchantQualificationFixture
if (!fixture?.tenantId || !fixture?.username || !fixture?.password || fixture.marker !== context.marker) return null
const loginResponse = await context.request('POST', '/api/auth/login',
{ username: fixture.username, password: fixture.password }, '')
const login = context.apiSuccess(MODULE, '第二隔离客户登录', loginResponse, diagnostic(loginResponse), OPTIONS)
return { customerId: id(fixture.tenantId), token: login?.accessToken }
} finally {
context.marker = originalMarker
if (originalContext) context.context.merchantQualificationFixture = originalContext
else delete context.context.merchantQualificationFixture
if (originalResource) context.createdResources.merchantQualificationFixture = originalResource
else delete context.createdResources.merchantQualificationFixture
if (originalUsername == null) delete process.env.MERCHANT_TEST_USERNAME
else process.env.MERCHANT_TEST_USERNAME = originalUsername
if (originalPassword == null) delete process.env.MERCHANT_TEST_PASSWORD
else process.env.MERCHANT_TEST_PASSWORD = originalPassword
}
}
async function verifyCustomerIsolation(context, fixture) {
const second = await createSecondCustomer(context)
const target = rows(fixture.statements).find((item) => statementId(item))
const apSource = rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).find(Boolean)
if (!second?.customerId || !second?.token || !target || !apSource) {
blocker(context, '建立第二隔离客户与跨客户单据 Fixture',
`customer=${second?.customerId || 'missing'}, token=${Boolean(second?.token)}, statement=${statementId(target) || 'missing'}, payableSource=${apSource || 'missing'}`,
null, 'second-customer-fixture-unavailable')
return
}
const targetId = statementId(target)
const read = await context.request('GET', `${CLIENT}/bills/${encodeURIComponent(targetId)}`, undefined, second.token)
const confirm = await context.request('POST', `${CLIENT}/statements/${encodeURIComponent(targetId)}/confirm`,
{ expectedVersion: Number(target.version ?? 0) }, second.token,
{ 'Idempotency-Key': key(context, 'foreign-confirm') })
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, {
customerId: second.customerId, receivedDate: today(), amount: 0.01, currency: 'CNY',
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], remark: context.marker,
allocations: [{ billId: targetId, amount: 0.01 }],
}, 'foreign-receipt')
const payment = await write(context, 'POST', `${FINANCE}/payable-settlements`, {
customerId: second.customerId, supplierId: id(fixture.supplier?.id ?? fixture.supplier?.supplierId),
feeSourceIds: [apSource],
dueDate: today(), currency: 'CNY',
}, 'foreign-payable')
context.assert(MODULE, '第二客户不能读取或确认他人账单且平台不能跨客户分配收付款', confirm,
[read, confirm, receipt, payment].every(rejected),
`read=${diagnostic(read)}; confirm=${diagnostic(confirm)}; receipt=${diagnostic(receipt)}; payment=${diagnostic(payment)}`, OPTIONS)
}
export async function run(context) {
const start = context.steps.length
try {
const fixture = await ensureFixture(context)
if (!fixture) {
blocker(context, '取得本轮 Finance 全链 Fixture', '缺少同 marker financeFullFixtures,禁止借历史财务单据')
return
}
const input = await loadActiveSupplierAndWarehouse(context, fixture)
if (!id(input.supplier?.id ?? input.supplier?.supplierId)
|| !(input.supplier?.name ?? input.supplier?.supplierName) || !input.warehouse) {
blocker(context, '取得 DIRECT_PAID 供应商和海外仓',
`supplier=${id(input.supplier?.id ?? input.supplier?.supplierId) || 'missing'}, warehouse=${id(input.warehouse?.id ?? input.warehouse?.warehouseId) || 'missing'}`,
input.response, 'direct-paid-master-data-unavailable')
return
}
const published = await publishDirectPaid(context, fixture, input.supplier, input.warehouse)
if (!published) return
await verifyDirectPaidProjection(context, fixture, published)
await verifyPaymentIdempotency(context, fixture)
await verifyFrozenSource(context, fixture)
await verifyCustomerIsolation(context, fixture)
context.createdResources.financeDirectPaidIsolation = {
marker: context.marker, customerId: id(fixture.customerId), sourceFeeId: published.sourceFeeId,
businessNo: published.body.businessNo,
}
} finally {
finish(context, start)
}
}
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
candidateLoadingPlanIds,
ownershipAllocations,
withoutInbound,
} from './firstleg-container-fee-allocation.mjs'
const source = readFileSync(new URL('./firstleg-container-fee-allocation.mjs', import.meta.url), 'utf8')
test('candidate plans only come from resources created by the current acceptance run', () => {
assert.deepEqual(candidateLoadingPlanIds({
phase3ContainerLoading: { loadingPlanId: '101' },
coloadGroupAcceptance: { fullPlanId: '102', planId: '103' },
manualLoadingComplete: { loadingPlanId: '101' },
financeFullFixtures: { feeSources: [{ loadingPlanId: '104' }] },
}), ['101', '102', '103', '104'])
assert.doesNotMatch(source, /pageSize=\d+.*smart-loading|containers\?page|历史柜/)
})
test('allocation builder selects one real order per distinct customer and preserves exact cents', () => {
const allocations = ownershipAllocations([
{ accountId: '11', orderId: '21', orderNo: 'FL-21', inboundId: '31', inboundCode: 'IN-31' },
{ accountId: '11', orderId: '22', orderNo: 'FL-22', inboundId: '32', inboundCode: 'IN-32' },
{ accountId: '12', orderId: '23', orderNo: 'FL-23', inboundId: '33', inboundCode: 'IN-33' },
{ accountId: '13', orderId: '24', orderNo: 'FL-24', inboundId: '34', inboundCode: 'IN-34' },
], 100)
assert.equal(allocations.length, 3)
assert.equal(new Set(allocations.map((item) => item.customerId)).size, 3)
assert.equal(allocations.reduce((sum, item) => sum + Number(item.amount), 0), 100)
assert.deepEqual(withoutInbound(allocations[0]), {
customerId: '11', orderId: '21', orderNo: 'FL-21', amount: '33.33',
})
})
test('module freezes all required real HTTP invariants and provisions a formal cross-customer fixture', async () => {
for (const token of [
'/allocation-groups', '分摊金额合计不一致被拒绝', '分摊客户必填', '分摊订单必填',
'伪造非柜内订单归属被拒绝', '伪造非柜内入仓码被拒绝', '不填写入仓码原子创建跨客户分摊组',
'单行审核不能破坏分摊组一致性', '整组审核原子成功', '缺少组成员的调整被拒绝',
'整组调整原子成功', '整组作废原子成功', '已作废冻结分摊组拒绝继续调整',
'非柜内客户不能读取柜次费用', 'requestId=', 'traceId=', 'deploymentBlocker: true',
]) assert.ok(source.includes(token), token)
for (const token of [
'createMerchant(context)', 'createCustomerWaitSplit', 'provisionCrossCustomerPlan',
'originalUsername', 'originalPassword', 'delete process.env.MERCHANT_TEST_USERNAME',
'delete process.env.MERCHANT_TEST_PASSWORD',
]) assert.ok(source.includes(token), token)
assert.doesNotMatch(source, /\bfetch\s*\(|\/internal\/|mysql|SELECT\s/i)
})
@@ -0,0 +1,326 @@
const MODULE = 'firstleg-container-fee-allocation'
const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true }
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function code(response) { return String(response?.body?.code ?? response?.http ?? '') }
function evidence(response, extra = '') {
return [`HTTP=${response?.http ?? 'missing'}`, `code=${code(response) || 'missing'}`,
`requestId=${response?.requestId || response?.body?.requestId || 'missing'}`,
`traceId=${response?.traceId || response?.body?.traceId || 'missing'}`, extra].filter(Boolean).join(', ')
}
function operationKey(context, suffix) { return `${context.marker}-container-fee-${suffix}`.slice(0, 128) }
function post(context, path, body, suffix) {
return context.request('POST', path, body, context.token, { 'Idempotency-Key': operationKey(context, suffix) })
}
function blocker(context, name, detail) {
context.addStep(MODULE, name, null, false, detail, OPTIONS)
}
function expectBusinessFailure(context, name, response, expectedCodes = ['A0400', 'A0404', 'A0409', 'A0422']) {
context.addStep(MODULE, name, response,
response?.http >= 400 || (response?.http === 200 && expectedCodes.includes(code(response))),
evidence(response, `message=${response?.body?.message || 'missing'}`), OPTIONS)
}
function money(value) { return Number(Number(value || 0).toFixed(2)) }
export function candidateLoadingPlanIds(createdResources = {}) {
const candidates = [
createdResources.phase3ContainerLoading?.loadingPlanId,
createdResources.coloadGroupAcceptance?.fullPlanId,
createdResources.coloadGroupAcceptance?.planId,
createdResources.manualLoadingComplete?.loadingPlanId,
createdResources.phase3ContainerDocuments?.loadingPlanId,
...rows(createdResources.financeFullFixtures?.feeSources).map((item) => item?.loadingPlanId),
].map(id).filter((value) => /^[1-9]\d*$/.test(value))
return [...new Set(candidates)]
}
export function ownershipAllocations(planItems = [], totalAmount = 120) {
const unique = new Map()
for (const item of rows(planItems)) {
const customerId = id(item?.accountId ?? item?.customerId ?? item?.tenantId)
const orderId = id(item?.orderId)
const orderNo = String(item?.orderNo || '').trim()
const inboundId = id(item?.inboundId)
const inboundCode = String(item?.inboundCode || '').trim()
if (!customerId || !orderId || !orderNo || !inboundId || !inboundCode) continue
const key = `${customerId}|${orderId}|${inboundId}`
if (!unique.has(key)) unique.set(key, { customerId, orderId, orderNo, inboundId, inboundCode })
}
const byCustomer = new Map()
for (const allocation of unique.values()) if (!byCustomer.has(allocation.customerId)) byCustomer.set(allocation.customerId, allocation)
const selected = [...byCustomer.values()]
if (selected.length < 2) return []
const totalCents = Math.round(Number(totalAmount) * 100)
const base = Math.floor(totalCents / selected.length)
return selected.map((allocation, index) => ({ ...allocation,
amount: ((base + (index === selected.length - 1 ? totalCents - base * selected.length : 0)) / 100).toFixed(2) }))
}
export function withoutInbound(allocation) {
const { inboundId: _inboundId, inboundCode: _inboundCode, ...required } = allocation
return required
}
function groupRows(allRows, groupId) {
return allRows.filter((item) => id(item?.allocationGroupId) === id(groupId))
}
function signature(group) {
return group.map((item) => [id(item?.id), id(item?.tenantId), item?.reviewStatus,
item?.sourceStatus, Number(item?.sourceVersion), money(item?.amount)].join(':')).sort().join('|')
}
async function readOwnedRows(context, loadingPlanId, customerIds, label) {
const result = []
for (const customerId of customerIds) {
const response = await context.request('GET',
`/api/platform/v1/loading-plans/${encodeURIComponent(loadingPlanId)}/fee-details?accountId=${encodeURIComponent(customerId)}`)
const data = context.apiSuccess(MODULE, `${label}:客户 ${customerId} 回读费用`, response,
evidence(response, `loadingPlanId=${loadingPlanId}, customerId=${customerId}`), OPTIONS)
const owned = rows(data)
context.assert(MODULE, `${label}:客户费用隔离`, response,
owned.every((item) => id(item?.tenantId) === customerId),
`customerId=${customerId}, returnedTenants=${[...new Set(owned.map((item) => id(item?.tenantId)))].join('|') || 'none'}`, OPTIONS)
result.push(...owned)
}
return result
}
async function findCrossCustomerPlan(context) {
for (const loadingPlanId of candidateLoadingPlanIds(context.createdResources)) {
const response = await context.request('GET',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}`)
const plan = context.apiSuccess(MODULE, '回读本轮柜次费用分摊候选', response,
evidence(response, `loadingPlanId=${loadingPlanId}`), OPTIONS)
const allocations = ownershipAllocations(plan?.items, 120)
if (allocations.length >= 2) return { loadingPlanId, plan, allocations }
}
return null
}
async function createCustomerWaitSplit(context, suffix, fixedRoute = null) {
const runMarker = context.marker
context.marker = `${runMarker}-${suffix}`
if (fixedRoute) context.context.waitSplitRoute = fixedRoute
await createMerchant(context)
const orders = await import(new URL(`./orders.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url))
const measurementModule = await import(new URL(`./inbound-measurement.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url))
await orders.run(context)
await measurementModule.run(context)
const order = structuredClone(context.createdResources.orders || {})
const measurement = structuredClone(context.createdResources.inboundMeasurement || {})
context.assert(MODULE, `${suffix}客户产生真实WAIT_SPLIT入仓单`, null,
Boolean(order.tenantId && order.orderId && order.inboundId && order.inboundCode
&& measurement.processStatus === 'WAIT_SPLIT'),
`customerId=${order.tenantId || 'missing'}, inboundCode=${order.inboundCode || 'missing'}, status=${measurement.processStatus || 'missing'}`,
OPTIONS)
context.marker = runMarker
return { ...order, processStatus: measurement.processStatus }
}
async function provisionCrossCustomerPlan(context) {
const originalMarker = context.marker
const originalUsername = process.env.MERCHANT_TEST_USERNAME
const originalPassword = process.env.MERCHANT_TEST_PASSWORD
const originalRoute = context.context.waitSplitRoute
const originalMerchant = context.context.merchantQualificationFixture
try {
const first = await createCustomerWaitSplit(context, 'ALLOC-A')
const route = {
originWarehouseName: first.receivingWarehouseName,
channelCode: first.channelCode,
destinationType: first.destinationType,
destinationWarehouseId: first.destinationWarehouseId,
taxModeIndex: 0,
}
const second = await createCustomerWaitSplit(context, 'ALLOC-B', route)
if (id(first.tenantId) === id(second.tenantId)
|| id(first.receivingWarehouseId) !== id(second.receivingWarehouseId)
|| first.channelCode !== second.channelCode) return null
const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: id(first.receivingWarehouseId),
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
evidence(containerResponse), OPTIONS)
const bookingId = id(container?.id)
if (!bookingId) return null
const planResponse = await context.request('GET',
`/api/platform/v1/first-leg/smart-loading/plans/by-booking/${encodeURIComponent(bookingId)}`)
let plan = context.apiSuccess(MODULE, '读取跨客户费用分摊专用计划', planResponse,
evidence(planResponse), OPTIONS)
const loadingPlanId = id(plan?.id)
if (!loadingPlanId) return null
for (const [index, item] of [first, second].entries()) {
const assigned = await context.request('POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/inbound-assignments`, {
inboundId: id(item.inboundId),
packageCount: rows(item.skuLines).reduce((sum, line) => sum + Number(line.packageCount || 0), 0),
expectedVersion: Number(plan?.version),
operationId: operationKey(context, `fixture-assign-${index + 1}`),
})
plan = context.apiSuccess(MODULE, `原子分配第${index + 1}个客户入仓单`, assigned,
evidence(assigned, `inboundCode=${item.inboundCode}`), OPTIONS)
if (!plan) return null
}
const allocations = ownershipAllocations(plan.items, 120)
return allocations.length >= 2 ? { loadingPlanId, plan, allocations } : null
} finally {
context.marker = originalMarker
context.context.waitSplitRoute = originalRoute
context.context.merchantQualificationFixture = originalMerchant
if (originalUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME
else process.env.MERCHANT_TEST_USERNAME = originalUsername
if (originalPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD
else process.env.MERCHANT_TEST_PASSWORD = originalPassword
}
}
export async function run(context) {
const start = context.steps.length
const fixture = await findCrossCustomerPlan(context) || await provisionCrossCustomerPlan(context)
if (!fixture) {
blocker(context, '取得本轮跨客户真实柜次',
`marker=${context.marker}, candidateLoadingPlanIds=${candidateLoadingPlanIds(context.createdResources).join('|') || 'none'};必须由本轮前置创建至少包含两个客户、真实订单和入仓码的柜次,禁止借用历史数据`)
context.createdResources.firstlegContainerFeeAllocation = { marker: context.marker, ready: false }
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE, success: false,
deploymentBlocked: true, passed: context.steps.slice(start).filter((step) => step.passed).length,
failed: context.steps.slice(start).filter((step) => !step.passed).length })
return
}
const { loadingPlanId, allocations } = fixture
const customerIds = allocations.map((item) => item.customerId)
const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const feeItems = context.apiSuccess(MODULE, '查询柜次应收费用项目', feeItemsResponse,
evidence(feeItemsResponse), OPTIONS)
const feeItem = rows(feeItems).find((item) => item?.status === 'ACTIVE'
&& (item?.warehouseReceivable === true || item?.pickupReceivable === true))
if (!feeItem?.id) {
blocker(context, '取得可用应收费用项目', '没有 ACTIVE 且启用仓储/提货应收的费用项目,禁止猜测 feeItemId')
return
}
const base = `/api/platform/v1/loading-plans/${encodeURIComponent(loadingPlanId)}/fee-details`
const groupPath = `${base}/allocation-groups`
const fee = { feeItemId: id(feeItem.id), direction: 'RECEIVABLE',
businessDate: new Date().toISOString().slice(0, 10), amount: 120, currency: 'CNY',
remark: `${context.marker}-cross-customer-allocation` }
const initial = await readOwnedRows(context, loadingPlanId, customerIds, '创建前')
const invalidTotal = await post(context, groupPath,
{ fee, allocations: allocations.map((item, index) => ({ ...withoutInbound(item), amount: index === 0 ? '1.00' : item.amount })) }, 'invalid-total')
expectBusinessFailure(context, '分摊金额合计不一致被拒绝', invalidTotal, ['A0409'])
const missingCustomer = await post(context, groupPath,
{ fee, allocations: allocations.map((item, index) => index === 0 ? { ...withoutInbound(item), customerId: null } : withoutInbound(item)) }, 'missing-customer')
expectBusinessFailure(context, '分摊客户必填', missingCustomer, ['A0422'])
const missingOrder = await post(context, groupPath,
{ fee, allocations: allocations.map((item, index) => index === 0 ? { ...withoutInbound(item), orderId: null } : withoutInbound(item)) }, 'missing-order')
expectBusinessFailure(context, '分摊订单必填', missingOrder, ['A0422'])
const forgedOrder = allocations.map(withoutInbound)
forgedOrder[0] = { ...forgedOrder[0], orderId: '999999999999999999' }
const forgedOrderResponse = await post(context, groupPath, { fee, allocations: forgedOrder }, 'forged-order')
expectBusinessFailure(context, '伪造非柜内订单归属被拒绝', forgedOrderResponse, ['A0409'])
const forgedInbound = allocations.map((item) => ({ ...item }))
forgedInbound[0] = { ...forgedInbound[0], inboundCode: `${context.marker}-FORGED-INBOUND`.slice(0, 64) }
const forgedInboundResponse = await post(context, groupPath, { fee, allocations: forgedInbound }, 'forged-inbound')
expectBusinessFailure(context, '伪造非柜内入仓码被拒绝', forgedInboundResponse, ['A0409'])
const afterNegatives = await readOwnedRows(context, loadingPlanId, customerIds, '负向请求后')
context.assert(MODULE, '负向请求未产生部分分摊行', null,
signature(initial) === signature(afterNegatives),
`before=${signature(initial) || 'empty'}, after=${signature(afterNegatives) || 'empty'}`, OPTIONS)
const createResponse = await post(context, groupPath,
{ fee, allocations: allocations.map(withoutInbound) }, 'create')
const created = context.apiSuccess(MODULE, '不填写入仓码原子创建跨客户分摊组', createResponse,
evidence(createResponse, `loadingPlanId=${loadingPlanId}`), OPTIONS)
const createdRows = rows(created)
const groupId = id(createdRows[0]?.allocationGroupId)
context.assert(MODULE, '分摊组客户订单完整且未伪造入仓码', createResponse,
Boolean(groupId) && createdRows.length === allocations.length
&& new Set(createdRows.map((item) => id(item?.tenantId))).size >= 2
&& createdRows.every((item) => item?.allocationInboundId == null && !item?.allocationInboundCode)
&& money(createdRows.reduce((sum, item) => sum + Number(item?.amount || 0), 0)) === 120,
`groupId=${groupId || 'missing'}, rows=${createdRows.length}, customers=${new Set(createdRows.map((item) => id(item?.tenantId))).size}, total=${createdRows.reduce((sum, item) => sum + Number(item?.amount || 0), 0)}`, OPTIONS)
if (!groupId || createdRows.length < 2) return
const first = createdRows[0]
const singleApprove = await post(context,
`${base}/${encodeURIComponent(id(first.id))}/approve?accountId=${encodeURIComponent(id(first.tenantId))}`, null, 'single-approve')
expectBusinessFailure(context, '单行审核不能破坏分摊组一致性', singleApprove, ['A0409'])
const beforeApprove = await readOwnedRows(context, loadingPlanId, customerIds, '单行审核拒绝后')
context.assert(MODULE, '单行审核拒绝后整组仍为待审核', singleApprove,
groupRows(beforeApprove, groupId).length === createdRows.length
&& groupRows(beforeApprove, groupId).every((item) => item?.reviewStatus === 'PENDING'),
`group=${signature(groupRows(beforeApprove, groupId))}`, OPTIONS)
const approveResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/approve`, null, 'approve')
const approved = rows(context.apiSuccess(MODULE, '整组审核原子成功', approveResponse,
evidence(approveResponse, `groupId=${groupId}`), OPTIONS))
context.assert(MODULE, '整组审核后全部行已审核且版本一致', approveResponse,
approved.length === createdRows.length && approved.every((item) => item?.reviewStatus === 'APPROVED'
&& item?.sourceStatus === 'ACTIVE' && Number(item?.sourceVersion) === 1),
`group=${signature(approved)}`, OPTIONS)
const partialAdjustBody = { expectedSourceVersion: 1, businessDate: fee.businessDate, totalAmount: 120,
allocations: [{ feeId: id(approved[0]?.id), amount: 120 }] }
const partialAdjust = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`, partialAdjustBody, 'partial-adjust')
expectBusinessFailure(context, '缺少组成员的调整被拒绝', partialAdjust, ['A0409'])
const afterPartialAdjust = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '部分调整拒绝后'), groupId)
context.assert(MODULE, '部分调整失败后整组零漂移', partialAdjust,
signature(afterPartialAdjust) === signature(approved),
`approved=${signature(approved)}, after=${signature(afterPartialAdjust)}`, OPTIONS)
const adjustedAmounts = approved.map((item, index) => ({ feeId: id(item.id),
amount: index === 0 ? '70.00' : index === 1 ? '50.00' : '0.01' }))
if (adjustedAmounts.length > 2) {
const overflow = (adjustedAmounts.length - 2) * 0.01
adjustedAmounts[1].amount = (50 - overflow).toFixed(2)
}
const adjustResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`,
{ expectedSourceVersion: 1, businessDate: fee.businessDate, totalAmount: 120, allocations: adjustedAmounts }, 'adjust')
const adjusted = rows(context.apiSuccess(MODULE, '整组调整原子成功', adjustResponse,
evidence(adjustResponse, `groupId=${groupId}`), OPTIONS))
context.assert(MODULE, '整组调整后全部行统一升版并待复核', adjustResponse,
adjusted.length === approved.length && adjusted.every((item) => Number(item?.sourceVersion) === 2
&& item?.reviewStatus === 'PENDING' && item?.sourceStatus === 'ACTIVE')
&& money(adjusted.reduce((sum, item) => sum + Number(item?.amount || 0), 0)) === 120,
`group=${signature(adjusted)}`, OPTIONS)
const reapproveResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/approve`, null, 'reapprove')
const reapproved = rows(context.apiSuccess(MODULE, '调整后整组复核原子成功', reapproveResponse,
evidence(reapproveResponse, `groupId=${groupId}`), OPTIONS))
const voidResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/void`,
{ expectedSourceVersion: 2 }, 'void')
context.apiSuccess(MODULE, '整组作废原子成功', voidResponse, evidence(voidResponse, `groupId=${groupId}`), OPTIONS)
const voided = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '整组作废后'), groupId)
context.assert(MODULE, '整组作废后全部行冻结且无活动残留', voidResponse,
reapproved.length === createdRows.length && voided.length === createdRows.length
&& voided.every((item) => item?.sourceStatus === 'VOIDED' && Number(item?.sourceVersion) === 2),
`group=${signature(voided)}`, OPTIONS)
const frozenAdjust = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`,
{ expectedSourceVersion: 2, businessDate: fee.businessDate, totalAmount: 120, allocations: adjustedAmounts }, 'frozen-adjust')
expectBusinessFailure(context, '已作废冻结分摊组拒绝继续调整', frozenAdjust, ['A0409'])
const afterFrozen = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '冻结调整拒绝后'), groupId)
context.assert(MODULE, '冻结门禁拒绝后整组零漂移', frozenAdjust,
signature(afterFrozen) === signature(voided),
`voided=${signature(voided)}, after=${signature(afterFrozen)}`, OPTIONS)
const foreignResponse = await context.request('GET',
`${base}?accountId=999999999999999999`)
expectBusinessFailure(context, '非柜内客户不能读取柜次费用', foreignResponse, ['A0404'])
context.createdResources.firstlegContainerFeeAllocation = {
marker: context.marker, ready: true, loadingPlanId, groupId,
customerIds, feeIds: createdRows.map((item) => id(item.id)), finalStatus: 'VOIDED',
}
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE,
success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}
@@ -828,12 +828,16 @@ export async function run(context) {
if (refreshedInventory?.snapshotToken) {
effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken }
}
} else {
} else if (source?.requireInventoryMutation !== false) {
context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false,
`本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
`inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`,
'fixture')
} else {
context.addStep(MODULE, '非快照专项装柜链不重复制造 A0 库存变化', null, true,
'当前模块作为四期真实业务前置,仅验证正常装柜链;旧 snapshot token A0409 由独立 phase3-loading 专项覆盖',
{ scenario: OPTIONS.scenario })
}
const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload,
@@ -1502,6 +1506,7 @@ export async function run(context) {
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified,
inventoryMutationVerified, inventoryMutationCleanup,
inventoryMutationRequired: source?.requireInventoryMutation !== false,
skipBatchVerified, partialBatchVerified, dynamicIncrementVerified,
expectedBatchOperationIds, incrementalInboundId,
pdaTaskVersion: Number(stagedTask?.version),
@@ -124,13 +124,17 @@ export async function run(context) {
'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证',
'deployment-blocker')
}
if (fixture?.inventoryMutationVerified !== true
if (fixture?.inventoryMutationRequired !== false && fixture?.inventoryMutationVerified !== true
&& !context.coverageGaps.some((gap) => gap.name === 'PDA 真实库存变化后的旧快照冲突与刷新重试')) {
context.addStep(MODULE, 'PDA 真实库存变化后旧 token A0409 与刷新重试', null, false,
'本轮未取得 inventoryMutationVerified=true;无法安全制造库存变化时必须作为部署阻断失败', OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
'inventory-snapshot 已公开;本轮没有可通过平台代理安全变更的 A0 临时占用,不能用普通库位改写或内部 Warehouse 接口伪造库存变化',
'fixture')
} else if (fixture?.inventoryMutationRequired === false) {
context.addStep(MODULE, '依赖链不重复执行 A0 旧快照破坏性专项', null, true,
'正常库存快照、原子批次和幂等已验证;A0 换绑与旧 token A0409 仅在显式 phase3-loading 专项运行',
{ scenario: OPTIONS.scenario })
}
if (fixture?.dynamicIncrementVerified !== true) {
context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false,
@@ -105,7 +105,20 @@ async function loginMerchant(context, activeCustomer) {
async function createMerchantProcurement(context, activeCustomer, overseasToken) {
const path = '/api/merchant/v1/last-leg/overseas-procurements'
const payload = procurementPayload(context, activeCustomer)
const response = await context.requestOverseas('POST', path, payload, {}, overseasToken)
let activeToken = overseasToken
let response = await context.requestOverseas('POST', path, payload, {}, activeToken)
// A newly approved customer is projected to the overseas region asynchronously. The
// domestic login can therefore succeed a few seconds before the overseas gateway has
// received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact
// security-projection conflict and always issue a fresh short-lived overseas token.
for (let attempt = 1; attempt <= 12
&& response?.http === 403 && codeOf(response) === 'A0403'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
const refreshedToken = await loginMerchant(context, activeCustomer)
if (!refreshedToken) break
activeToken = refreshedToken
response = await context.requestOverseas('POST', path, payload, {}, activeToken)
}
const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
const value = summary(detail)
const resource = nonEmpty(value?.orderNo) && longId(value?.customerId)
@@ -119,7 +132,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken)
`orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}, version=${value?.version ?? 'missing'}; ${trace(response)}`, OPTIONS)
if (!valid) return null
const replayResponse = await context.requestOverseas('POST', path, payload, {}, overseasToken)
const replayResponse = await context.requestOverseas('POST', path, payload, {}, activeToken)
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
const replay = summary(replayDetail)
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
@@ -127,7 +140,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken)
`orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS)
const mismatch = await context.requestOverseas('POST', path,
{ ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, overseasToken)
{ ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, activeToken)
expectedConflict(context, '采购创建同幂等键异载荷返回冲突', mismatch)
return { resource, payload }
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { run } from './phase4-overseas-finance-cross-region.mjs'
const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8')
function response(data, code = '200', http = 200, region) {
return {
http,
body: { code, data: region ? { status: '运行中', region } : data },
requestId: `req-${code}-${http}`,
traceId: `trace-${code}-${http}`,
}
}
function fixture() {
const marker = 'ACC-P4-FIN-XREGION'
const calls = []
const steps = []
const commands = new Map()
const fees = new Map()
let sequence = 7000
const businessRows = {
'/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1001' },
'/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1001' },
'/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1001' },
}
const context = {
marker,
steps,
scenarios: [],
calls,
createdResources: {
merchantQualificationFixture: { tenantId: '1001' },
phase4OverseasBusiness: {
purchase: { orderNo: 'PO-1', customerId: '1001' },
truck: { orderNo: 'TK-1', customerId: '1001' },
looseboard: { orderNo: 'LB-1', customerId: '1001' },
},
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' },
},
async request(method, path, body) {
calls.push({ region: 'CN', method, path, body })
if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') {
return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] })
}
if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'CN')
if (path.startsWith('/api/platform/finance/v1/fee-sources?')) {
const query = new URLSearchParams(path.split('?')[1])
const customerId = query.get('customerId')
const keyword = query.get('keyword')
const rows = [...fees.values()].filter((fee) => fee.customerId === customerId
&& (fee.inboundCode || fee.businessNo) === keyword)
.filter((fee) => fee.reviewStatus === 'APPROVED')
.map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG',
sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId }))
return response(rows)
}
throw new Error(`unexpected CN request ${method} ${path}`)
},
async requestOverseas(method, path, body, headers = {}) {
calls.push({ region: 'OVERSEAS', method, path, body, headers })
if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'OVERSEAS')
for (const [base, row] of Object.entries(businessRows)) {
if (method === 'GET' && path.startsWith(`${base}?`)) return response({ records: [row] })
}
if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/inbound-plans?')) {
return response({ records: [{ planId: '4001', planNo: 'IP-1', customerId: '2001', inboundId: '4101', inboundCode: 'IN-1' }] })
}
if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/finance-fee-sources?')) return response([])
if (method === 'POST' && path === '/api/platform/v1/last-leg/finance-fee-sources') {
const key = headers['Idempotency-Key']
const hash = JSON.stringify(body)
const existing = commands.get(`CREATE:${key}`)
if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409')
const expectedCustomer = body.businessId === '4001' ? '2001'
: ['3001', '3002', '3003'].includes(String(body.businessId)) ? '1001' : body.customerId
if (body.businessId && body.customerId !== expectedCustomer) return response(null, 'A0400')
const fee = { ...structuredClone(body), sourceFeeId: String(++sequence), sourceVersion: 1,
reviewStatus: 'DRAFT', publicationStatus: 'UNPUBLISHED', sourceStatus: 'ACTIVE' }
commands.set(`CREATE:${key}`, { hash, fee })
fees.set(fee.sourceFeeId, fee)
return response(fee)
}
const action = path.match(/finance-fee-sources\/(\d+)\/(submit|review)$/)
if (method === 'POST' && action) {
const fee = fees.get(action[1])
const key = body.idempotencyKey
const scope = `${action[2].toUpperCase()}:${key}`
const hash = JSON.stringify(body)
const existing = commands.get(scope)
if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409')
if (action[2] === 'submit') fee.reviewStatus = 'PENDING'
else { fee.reviewStatus = 'APPROVED'; fee.publicationStatus = 'PUBLISHED' }
commands.set(scope, { hash, fee: structuredClone(fee) })
return response(fee)
}
throw new Error(`unexpected OVERSEAS request ${method} ${path}`)
},
apiSuccess(module, name, result, evidence, options) {
const passed = result.http === 200 && String(result.body?.code) === '200'
this.addStep(module, name, result, passed, evidence, options)
return passed ? result.body.data : null
},
assert(module, name, result, passed, evidence, options) {
this.addStep(module, name, result, Boolean(passed), evidence, options)
},
addStep(module, name, result, passed, evidence, options = {}) {
steps.push({ module, name, result, passed: Boolean(passed), evidence,
deploymentBlocker: Boolean(options.deploymentBlocker) })
},
}
return context
}
test('module uses only injected real HTTP clients and never fabricates database/event success', () => {
assert.match(source, /export async function run\(context\)/)
assert.doesNotMatch(source, /process\.env|https?:\/\/|fetch\(|mysql|SELECT\s|INSERT\s|UPDATE\s/i)
assert.doesNotMatch(source, /admin-test-env-acceptance|README\.md/)
for (const token of [
'OVERSEAS_INBOUND_FEE', 'OVERSEAS_PROCUREMENT_FEE', 'OVERSEAS_TRUCK_FEE',
'OVERSEAS_LOOSEBOARD_FEE', 'OVERSEAS_WAREHOUSE_FEE',
'/api/platform/v1/last-leg/finance-fee-sources', '/api/platform/finance/v1/fee-sources',
'/internal/event-bridge/status',
]) assert.ok(source.includes(token), token)
})
test('five fee types traverse Lastleg lifecycle and reach Finance through real HTTP', async () => {
const context = fixture()
await run(context)
const registry = context.createdResources.phase4OverseasFinanceCrossRegion
assert.equal(registry.sources.length, 5)
assert.deepEqual(new Set(registry.sources.map((item) => item.businessType)), new Set([
'OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE',
]))
const warehouseCreate = context.calls.find((call) => call.method === 'POST'
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources'
&& call.body?.businessType === 'WAREHOUSE_SERVICE')
assert.ok(warehouseCreate)
for (const field of ['businessId', 'orderId', 'inboundId', 'inboundCode', 'containerId', 'containerBookingId', 'containerNo']) {
assert.equal(warehouseCreate.body[field], null, field)
}
assert.equal(warehouseCreate.body.customerId, '1001')
assert.equal(warehouseCreate.body.warehouseId, '9001')
const creates = context.calls.filter((call) => call.method === 'POST'
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources')
assert.equal(creates.length, 12)
assert.ok(context.steps.find((step) => step.name === '费用创建同键异载荷冲突')?.passed)
assert.ok(context.steps.find((step) => step.name === '海外费用列表不会泄露其他客户来源')?.passed)
assert.ok(context.steps.find((step) => step.name === '其他客户不能关联本客户业务创建费用')?.passed)
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).length, 5)
assert.ok(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).every((step) => step.passed))
assert.ok(context.steps.find((step) => step.name === '国内 Event Bridge HTTP 能力状态可观察')?.passed)
assert.ok(context.steps.find((step) => step.name === '海外 Event Bridge HTTP 能力状态可观察')?.passed)
assert.deepEqual(context.steps.filter((step) => !step.passed), [])
assert.equal(context.scenarios[0].deploymentBlocked, false)
assert.equal(context.scenarios[0].success, true)
})
@@ -0,0 +1,262 @@
const MODULE = 'phase4-overseas-finance-cross-region'
const SCENARIO = 'phase4-overseas-finance-cross-region-http'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const LASTLEG_BASE = '/api/platform/v1/last-leg/finance-fee-sources'
const FINANCE_BASE = '/api/platform/finance/v1/fee-sources'
const id = (value) => value == null ? '' : String(value)
const codeOf = (response) => String(response?.body?.code ?? 'missing')
const success = (response) => response?.http === 200 && codeOf(response) === '200'
const conflict = (response) => response?.http === 409
|| (response?.http === 200 && ['A0409', '409', 'FIN-IDEM-409'].includes(codeOf(response)))
const rejected = (response) => [400, 403, 404, 409].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
const longId = (value) => /^[1-9]\d*$/.test(id(value))
const rows = (data) => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : []
const trace = (response, extra = '') => `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || response?.body?.requestId || 'missing'}, traceId=${response?.traceId || response?.body?.traceId || 'missing'}`
function blocker(context, name, reason, response = null) {
context.addStep(MODULE, name, response, false, `${reason}; ${trace(response)}`, OPTIONS)
}
async function overseas(context, method, name, path, body, headers = {}) {
const response = await context.requestOverseas(method, path, body, headers)
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
return { response, data }
}
async function domestic(context, method, name, path, body) {
const response = await context.request(method, path, body)
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
return { response, data }
}
async function poll(action, predicate, attempts = 30) {
let result = null
for (let index = 0; index < attempts; index += 1) {
result = await action()
if (predicate(result)) return { result, attempts: index + 1 }
if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { result, attempts }
}
async function resolveListedBusiness(context, base, resource, customerId, label) {
if (!resource?.orderNo || !customerId) {
blocker(context, `${label}费用业务前置`, `缺少本轮 ${label} orderNo/customerId,禁止借用历史业务`)
return null
}
const path = `${base}?page=1&size=200&customerId=${encodeURIComponent(customerId)}&orderNo=${encodeURIComponent(resource.orderNo)}`
const listed = await overseas(context, 'GET', `回查本轮${label}业务`, path)
const row = rows(listed.data).find((item) => id(item?.orderNo) === id(resource.orderNo)
&& id(item?.customerId) === id(customerId))
context.assert(MODULE, `${label}费用关联真实业务记录`, listed.response,
longId(row?.id) && Boolean(row?.orderNo),
`businessId=${id(row?.id) || 'missing'}, businessNo=${row?.orderNo || 'missing'}; ${trace(listed.response)}`, OPTIONS)
return longId(row?.id) ? { businessId: id(row.id), businessNo: row.orderNo, customerId } : null
}
async function resolveFixtures(context) {
const business = context.createdResources?.phase4OverseasBusiness
const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
|| business?.purchase?.customerId || business?.truck?.customerId || business?.looseboard?.customerId)
const procurement = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/overseas-procurements', business?.purchase, merchantCustomerId, '海外采购')
const truck = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/us-truck-orders', business?.truck, merchantCustomerId, '美国卡车')
const looseboard = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard, merchantCustomerId, '本土散板')
const pda = context.createdResources?.phase4OverseasPda
const inboundCustomerId = id(pda?.customerId)
let inbound = null
if (longId(inboundCustomerId)) {
const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划',
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
const plan = rows(listed.data).find((item) => longId(item?.planId || item?.id) && item?.planNo)
context.assert(MODULE, '海外入仓费用关联真实计划', listed.response,
Boolean(plan) && longId(plan?.planId || plan?.id),
`planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS)
if (plan) inbound = {
businessId: id(plan.planId || plan.id), businessNo: plan.planNo, customerId: inboundCustomerId,
inboundId: longId(plan.inboundId) ? id(plan.inboundId) : null,
inboundCode: plan.inboundCode || null,
}
} else blocker(context, '海外入仓费用业务前置', '缺少本轮海外 PDA 客户身份,无法安全选择真实入仓计划')
const warehouseId = id(pda?.overseasWarehouseId || context.createdResources?.phase4OverseasWarehouse?.warehouseId)
const warehouse = longId(merchantCustomerId) && longId(warehouseId)
? { businessId: null, businessNo: `${context.marker}-WAREHOUSE`, customerId: merchantCustomerId, warehouseId }
: null
if (!warehouse) blocker(context, '海外仓服务费业务前置', '缺少本轮客户或真实海外仓 ID,禁止伪造仓库服务费')
return { merchantCustomerId, inbound, procurement, truck, looseboard, warehouse }
}
function feePayload(context, definition, fixture, index) {
return {
customerId: fixture.customerId,
customerName: `${context.marker}-客户`,
sourceType: definition.sourceType,
businessType: definition.businessType,
businessId: fixture.businessId,
businessNo: fixture.businessNo,
orderId: null,
inboundId: fixture.inboundId || null,
inboundCode: fixture.inboundCode || null,
containerId: null,
containerBookingId: null,
containerNo: null,
warehouseId: fixture.warehouseId || null,
feeItemCode: `P4-${definition.businessType}`.slice(0, 64),
feeItemName: `${definition.label}验收费用`,
direction: 'RECEIVABLE',
supplierId: null,
supplierName: null,
supplierCategory: null,
settlementMode: null,
paidAt: null,
paymentMethod: null,
paymentReferenceNo: null,
paymentVoucherFileIds: [],
businessDate: new Date().toISOString().slice(0, 10),
currency: 'CNY',
amount: Number((101 + index + 0.25).toFixed(2)),
}
}
async function createApprove(context, definition, fixture, index) {
if (!fixture) return null
const payload = feePayload(context, definition, fixture, index)
const key = `${context.marker}-${definition.businessType}-fee-create`.slice(0, 128)
const created = await overseas(context, 'POST', `创建${definition.label}费用草稿`, LASTLEG_BASE,
payload, { 'Idempotency-Key': key })
const sourceFeeId = id(created.data?.sourceFeeId)
context.assert(MODULE, `${definition.label}费用草稿字段完整`, created.response,
longId(sourceFeeId) && created.data?.reviewStatus === 'DRAFT'
&& id(created.data?.customerId) === id(fixture.customerId)
&& created.data?.businessType === definition.businessType,
`sourceFeeId=${sourceFeeId || 'missing'}, status=${created.data?.reviewStatus || 'missing'}; ${trace(created.response)}`, OPTIONS)
if (!longId(sourceFeeId)) return null
const replay = await overseas(context, 'POST', `${definition.label}费用创建同参重放`, LASTLEG_BASE,
payload, { 'Idempotency-Key': key })
context.assert(MODULE, `${definition.label}创建重放复用费用且版本不漂移`, replay.response,
id(replay.data?.sourceFeeId) === sourceFeeId && Number(replay.data?.sourceVersion) === 1,
trace(replay.response), OPTIONS)
if (index === 0) {
const mismatch = await context.requestOverseas('POST', LASTLEG_BASE,
{ ...payload, amount: payload.amount + 1 }, { 'Idempotency-Key': key })
context.addStep(MODULE, '费用创建同键异载荷冲突', mismatch, conflict(mismatch), trace(mismatch), OPTIONS)
}
const submitKey = `${context.marker}-${definition.businessType}-fee-submit`.slice(0, 128)
const submitted = await overseas(context, 'POST', `提交${definition.label}费用`,
`${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey: submitKey })
context.assert(MODULE, `${definition.label}费用进入待审核`, submitted.response,
submitted.data?.reviewStatus === 'PENDING', trace(submitted.response), OPTIONS)
const reviewBody = { decision: 'APPROVE', reason: context.marker,
idempotencyKey: `${context.marker}-${definition.businessType}-fee-review`.slice(0, 128) }
const approved = await overseas(context, 'POST', `审核通过${definition.label}费用`,
`${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/review`, reviewBody)
context.assert(MODULE, `${definition.label}费用已发布并触发 Outbox`, approved.response,
approved.data?.reviewStatus === 'APPROVED' && approved.data?.publicationStatus === 'PUBLISHED',
trace(approved.response), OPTIONS)
const reviewReplay = await overseas(context, 'POST', `${definition.label}审核同参重放`,
`${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/review`, reviewBody)
context.assert(MODULE, `${definition.label}审核重放保持发布版本`, reviewReplay.response,
id(reviewReplay.data?.sourceFeeId) === sourceFeeId
&& Number(reviewReplay.data?.sourceVersion) === Number(approved.data?.sourceVersion),
trace(reviewReplay.response), OPTIONS)
return { sourceFeeId, customerId: fixture.customerId, definition, payload }
}
async function verifyCustomerIsolation(context, fixtures, source) {
if (!source || !fixtures.merchantCustomerId) return
const tenants = await domestic(context, 'GET', '查询第二个 ACTIVE 客户用于隔离反证',
'/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
const other = rows(tenants.data).find((item) => longId(item?.id) && id(item.id) !== id(source.customerId))
if (!other) {
blocker(context, '费用客户隔离反证前置', '没有第二个 ACTIVE 客户,无法验证跨客户查询与业务归属拒绝', tenants.response)
return
}
const wrongList = await overseas(context, 'GET', '其他客户查询海外费用来源',
`${LASTLEG_BASE}?customerId=${encodeURIComponent(id(other.id))}&businessNo=${encodeURIComponent(source.payload.businessNo)}`)
context.assert(MODULE, '海外费用列表不会泄露其他客户来源', wrongList.response,
!rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId),
`otherCustomerId=${id(other.id)}, leaked=${rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId)}; ${trace(wrongList.response)}`, OPTIONS)
if (source.payload.businessId) {
const wrongPayload = { ...source.payload, customerId: id(other.id), customerName: other.tenantName || other.name || 'other' }
const wrong = await context.requestOverseas('POST', LASTLEG_BASE, wrongPayload,
{ 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) })
context.addStep(MODULE, '其他客户不能关联本客户业务创建费用', wrong, rejected(wrong), trace(wrong), OPTIONS)
}
}
async function verifyFinanceDelivery(context, sources) {
for (const source of sources) {
const keyword = source.payload.inboundCode || source.payload.businessNo
const path = `${FINANCE_BASE}?page=1&pageSize=200&customerId=${encodeURIComponent(source.customerId)}&keyword=${encodeURIComponent(keyword)}`
const delivered = await poll(async () => {
const response = await context.request('GET', path)
const found = rows(response?.body?.data).find((item) => item?.sourceSystem === 'LASTLEG'
&& id(item?.sourceFeeId) === source.sourceFeeId)
return { response, found }
}, (value) => success(value?.response) && Boolean(value?.found))
context.assert(MODULE, `${source.definition.label}费用经跨区链进入 Finance`, delivered.result?.response,
Boolean(delivered.result?.found)
&& id(delivered.result.found.customerId) === id(source.customerId)
&& delivered.result.found.sourceType === source.definition.sourceType,
`pollRequests=${delivered.attempts}, sourceFeeId=${source.sourceFeeId}, financeFeeSourceId=${id(delivered.result?.found?.feeSourceId) || 'missing'}; ${trace(delivered.result?.response)}`, OPTIONS)
}
}
async function verifyBridgeCapabilities(context) {
const domestic = await context.request('GET', '/internal/event-bridge/status')
context.addStep(MODULE, '国内 Event Bridge HTTP 能力状态可观察', domestic,
(success(domestic) && String(domestic.body?.data?.region).toUpperCase() === 'CN') || domestic.http === 404,
`${domestic.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(domestic)}`, OPTIONS)
const overseas = await context.requestOverseas('GET', '/internal/event-bridge/status')
context.addStep(MODULE, '海外 Event Bridge HTTP 能力状态可观察', overseas,
(success(overseas) && String(overseas.body?.data?.region).toUpperCase() === 'OVERSEAS') || overseas.http === 404,
`${overseas.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(overseas)}`, OPTIONS)
}
export async function run(context) {
const start = context.steps.length
const fixtures = await resolveFixtures(context)
const definitions = [
{ label: '海外入仓', businessType: 'OVERSEAS_INBOUND', sourceType: 'OVERSEAS_INBOUND_FEE', fixture: fixtures.inbound },
{ label: '海外采购', businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', fixture: fixtures.procurement },
{ label: '美国卡车', businessType: 'US_TRUCK', sourceType: 'OVERSEAS_TRUCK_FEE', fixture: fixtures.truck },
{ label: '本土散板', businessType: 'LOCAL_LOOSEBOARD', sourceType: 'OVERSEAS_LOOSEBOARD_FEE', fixture: fixtures.looseboard },
{ label: '海外仓服务', businessType: 'WAREHOUSE_SERVICE', sourceType: 'OVERSEAS_WAREHOUSE_FEE', fixture: fixtures.warehouse },
]
const sources = []
for (const [index, definition] of definitions.entries()) {
const source = await createApprove(context, definition, definition.fixture, index)
if (source) sources.push(source)
}
context.assert(MODULE, '五类海外费用均创建、提交并审核', null, sources.length === definitions.length,
`created=${sources.length}, expected=${definitions.length}`, OPTIONS)
const warehouseSource = sources.find((item) => item.definition.businessType === 'WAREHOUSE_SERVICE')
context.assert(MODULE, '仓储服务费用仅关联客户和仓库', null,
Boolean(warehouseSource) && warehouseSource.payload.businessId == null
&& warehouseSource.payload.orderId == null && warehouseSource.payload.inboundId == null
&& warehouseSource.payload.inboundCode == null && warehouseSource.payload.containerId == null
&& warehouseSource.payload.containerBookingId == null && warehouseSource.payload.containerNo == null
&& longId(warehouseSource.payload.warehouseId),
`sourceFeeId=${warehouseSource?.sourceFeeId || 'missing'}, warehouseId=${warehouseSource?.payload?.warehouseId || 'missing'}`, OPTIONS)
await verifyCustomerIsolation(context, fixtures, sources.find((item) => item.payload.businessId))
await verifyFinanceDelivery(context, sources)
await verifyBridgeCapabilities(context)
context.createdResources.phase4OverseasFinanceCrossRegion = {
marker: context.marker,
sources: sources.map((item) => ({ sourceFeeId: item.sourceFeeId, customerId: item.customerId,
businessType: item.definition.businessType, businessNo: item.payload.businessNo })),
observability: 'BUSINESS_HTTP_VERIFIED_EVENT_PERSISTENCE_REQUIRES_AUTHORIZED_DB_EVIDENCE',
}
const steps = context.steps.slice(start)
context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed),
deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
function runContext() {
return new AcceptanceRun({ gateway: 'http://acceptance.invalid', overseasGateway: 'http://overseas.invalid' })
}
test('read-only HTTP requests retry transient network failures', async () => {
const originalFetch = globalThis.fetch
let attempts = 0
globalThis.fetch = async () => {
attempts += 1
if (attempts < 3) throw new TypeError('fetch failed')
return new Response(JSON.stringify({ code: '200', data: { ready: true } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
try {
const response = await runContext().request('GET', '/api/readiness')
assert.equal(attempts, 3)
assert.equal(response.http, 200)
assert.equal(response.body?.data?.ready, true)
} finally {
globalThis.fetch = originalFetch
}
})
test('mutating HTTP requests never retry an ambiguous network failure', async () => {
const originalFetch = globalThis.fetch
let attempts = 0
globalThis.fetch = async () => {
attempts += 1
throw new TypeError('fetch failed')
}
try {
const response = await runContext().request('POST', '/api/orders', { value: 1 })
assert.equal(attempts, 1)
assert.equal(response.http, 0)
assert.equal(response.raw, 'fetch failed')
} finally {
globalThis.fetch = originalFetch
}
})
+55 -1
View File
@@ -539,6 +539,60 @@ Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA
# 国内 PDA 当前以页面、API 和作业状态合同覆盖部分拣货及库位不足反馈。
Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同'
Assert-Contains $pdaAgents 'tests/pda-loading-api.test.mjs' '国内 PDA API 合同'
# 国内柜费分摊与海外费用跨区入池必须能从根索引直达,并锁住归属、金额、版本与直接已付隔离。
$financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md')
$eventBridgeAgents = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/AGENTS.md')
$commonEventAgents = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/AGENTS.md')
$adminFinanceAgents = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/finance/AGENTS.md')
$containerFeeOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/container-fees-v1.yaml')
$containerFeeController = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java')
$containerFeeService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java')
$overseasFinanceOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml')
$businessFeeEvent = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java')
$eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java')
$overseasDispatcher = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java')
$financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java')
$financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java')
Assert-Contains $rootAgents '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
Assert-Contains $rootAgents '海外业务费用来源与客户财务' '根海外费用快速路由'
Assert-Contains $adminAgents '财务与柜级公共费用分摊' 'Admin 柜费分摊快速路由'
Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由'
Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由'
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'
Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界'
Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI'
Assert-Contains $containerFeeOpenApi '各分摊金额必须大于0且合计等于费用总额' '柜费分摊金额守恒'
Assert-Contains $containerFeeOpenApi '单行接口拒绝处理分摊组' '柜费分摊整组操作契约'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口'
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验'
Assert-Contains $lastlegAgents '海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务' 'Lastleg 五类海外费用归属'
Assert-Contains $overseasFinanceOpenApi 'OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE' '海外五类费用 OpenAPI'
Assert-Contains $overseasFinanceOpenApi '审核通过与作废均事务性写入Outbox' '海外费用可靠发布契约'
Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口'
Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型'
Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本'
Assert-Contains $businessFeeEvent 'List<String> paymentVoucherFileIds' '海外费用事件付款证据快照'
Assert-Contains $businessFeeEvent 'String sourceHash' '海外费用事件来源哈希'
Assert-Contains $eventBridgeAgents '`BUSINESS_FEE_SOURCE_CHANGED` 定向投 Finance' 'Event Bridge Finance 定向路由'
Assert-Contains $eventBridgeAgents '/internal/finance/v1/business-fee-source-events' 'Event Bridge Finance 内部入口'
Assert-Matches $overseasDispatcher '(?s)EventTypes\.BUSINESS_FEE_SOURCE_CHANGED\.equals\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge 海外费用投 Finance'
Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界'
Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界'
Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等'
Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影'
Assert-Contains $financePhase1Service "f.settlement_mode='ON_ACCOUNT'" 'Finance AP 只纳入账期应付'
Assert-Contains $financePhase1Service "'FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID'" 'Finance 已付成本汇总'
Assert-Contains $scriptsAgents 'Finance 扩展 HTTP 模块' '脚本指引 Finance 扩展模块'
foreach ($financeModule in @('firstleg-container-fee-allocation', 'finance-direct-paid-isolation', 'phase4-overseas-finance-cross-region')) {
Assert-Contains $adminAcceptanceEntry "'$financeModule'" "Admin 总入口 $financeModule 注册"
Assert-Contains $adminAcceptanceModuleIndex "$financeModule.mjs" "Admin 模块索引 $financeModule"
}
# 国内 PDA 部分拣货必须展示真实可用库位事实,并在无库位时保持页面整洁。
Assert-Contains $pdaAgents 'tests/partial-layout.test.mjs' '国内 PDA 部分拣货布局合同'
Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 PDA 库位展示合同'
# Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。
[xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw
@@ -861,5 +915,5 @@ if ($failures.Count -gt 0) {
exit 1
}
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、脚本目录治理、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四/五期范围边界、四期柜级待处理计划筛选与范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外五类费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四/五期范围边界、四期柜级待处理计划筛选与范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
exit 0
@@ -74,8 +74,8 @@ test('test-environment loading chains create the bridge before opening PDA after
assert.match(containerLoading, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.doesNotMatch(containerLoading, /realContainerBookingId/)
const pushIndex = bridgeLoading.indexOf('分配同步建立 bridge 关联')
const absentIndex = bridgeLoading.indexOf('无真实柜号时 PDA 查询明确 A0404')
const pushIndex = bridgeLoading.indexOf('分配后自动建立预装关联且柜次计划可读')
const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404')
const releaseIndex = bridgeLoading.indexOf('三期订舱 RELEASED 冻结运输快照')
const linkIndex = bridgeLoading.indexOf('模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号')
const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
@@ -83,14 +83,14 @@ test('test-environment loading chains create the bridge before opening PDA after
assert.ok(pushIndex >= 0 && absentIndex > pushIndex
&& releaseIndex > absentIndex && linkIndex > releaseIndex && confirmIndex > linkIndex && pdaIndex > confirmIndex)
assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.match(bridgeLoading, /分配自动建立唯一预装关联且身份稳定/)
assert.match(bridgeLoading, /分配后自动建立预装关联且柜次计划可读/)
assert.match(bridgeLoading, /bookingConfirmed\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(bridgeLoading, /loading-plans\/\$\{loadingPlanId\}\/confirm/)
assert.match(bridgeLoading, /readiness\?\.readyForPda === true/)
assert.match(bridgeLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
const containerPushIndex = containerLoading.indexOf('分配同步建立 bridge')
const containerPushIndex = containerLoading.indexOf('分配后自动建立预装关联且计划可读')
const containerReleaseIndex = containerLoading.indexOf('订舱放舱为 RELEASED 且不预生成真实货柜')
const containerLinkIndex = containerLoading.indexOf('首次关联 RELEASED 订舱需求并补真实柜号')
const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
@@ -37,13 +37,17 @@ test('coordinator jobs present their own deployment environment as the default c
assert.match(coordinatorPipeline, /downstreamJobPrefixForJob\(env\.JOB_BASE_NAME\)/)
})
test('AUTO keeps its own baseline and retries only previously failed services', () => {
test('AUTO advances interrupted baselines and retries only their selected services', () => {
assert.match(coordinatorPipeline, /def parseAutoBuildState\(/)
assert.match(coordinatorPipeline, /def previousAutoBuildState\(/)
assert.match(coordinatorPipeline, /previousAutoBuildState\(currentBuild\.previousBuild, serviceModules\)/)
assert.match(coordinatorPipeline, /state\.startsWith\('PENDING'\)/)
assert.match(coordinatorPipeline, /failedServices\.addAll\(selected\.split\(','\)/)
assert.match(coordinatorPipeline, /git rev-parse HEAD\^/)
assert.match(coordinatorPipeline, /selected\.addAll\(retryServices\)/)
assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| PENDING/)
assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| RESULTS/)
assert.doesNotMatch(coordinatorPipeline, /GIT_PREVIOUS_SUCCESSFUL_COMMIT/)
assert.doesNotMatch(coordinatorPipeline, /commonChanged \? serviceModules/)
})