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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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
@@ -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` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
|
||||
|
||||
@@ -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`,车辆档案不含派送履约历史 |
|
||||
|
||||
@@ -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/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+163
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user