diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index 3c63429f0..cf46380b1 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -22,6 +22,12 @@ baiyue: relabel-photo-file: connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + customer-visit-file: + connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s} + procurement-file: + connect-timeout: ${LASTLEG_PROCUREMENT_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PROCUREMENT_FILE_READ_TIMEOUT:30s} warehouse-resource: connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index c25f0ff68..9c003b138 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -54,12 +54,14 @@ #### Lastleg 四个有界 RestClient -四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。 +六个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。 | 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 | |---|---|---|---| | `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | | `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpCustomerVisitFileGateway` | `CustomerVisitService → CustomerVisitFileGateway → /internal/file/v1/business-files/**` | 附件上传没有幂等键,禁止自动重试;元数据读取可重复 | 连接 3 秒、读取 30 秒 | +| `HttpProcurementFileGateway` | `OverseasProcurementService → ProcurementFileGateway → /internal/file/v1/business-files/**` | 采购凭证和签收文件上传没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | | `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | | `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java index 751f48f4b..2ad342942 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java @@ -22,13 +22,14 @@ import java.util.Map; @Service @RequiredArgsConstructor public class DestuffingPalletCostService { + static final String READY_BOOKINGS_SQL = "SELECT o.source_container_booking_id FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id LEFT JOIN ll_destuff_pallet_cost c ON c.source_container_booking_id=o.source_container_booking_id WHERE o.source_container_booking_id IS NOT NULL AND c.id IS NULL GROUP BY o.source_container_booking_id HAVING COUNT(*)=SUM(CASE WHEN t.status='COMPLETED' THEN 1 ELSE 0 END) ORDER BY MAX(t.completed_at) DESC LIMIT 50"; private final JdbcTemplate jdbc; private final PalletPhysicalGateway pallets; private final OverseasFeeSourceMapper feeSources; private final OverseasFeeOutboxService feeOutbox; public List readyBookingIds() { - return jdbc.queryForList("SELECT o.source_container_booking_id FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id LEFT JOIN ll_destuff_pallet_cost c ON c.source_container_booking_id=o.source_container_booking_id WHERE o.source_container_booking_id IS NOT NULL AND c.id IS NULL GROUP BY o.source_container_booking_id HAVING COUNT(*)=SUM(CASE WHEN t.status='COMPLETED' THEN 1 ELSE 0 END) ORDER BY MIN(t.completed_at) LIMIT 50", String.class); + return jdbc.queryForList(READY_BOOKINGS_SQL, String.class); } @Transactional diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java index 69773f9a5..b054a93f7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java @@ -18,13 +18,15 @@ import org.springframework.web.server.ResponseStatusException; @Component @RequiredArgsConstructor public class HttpProcurementFileGateway implements ProcurementFileGateway { - private final RestClient.Builder builder; - private final LastlegRestClientFactory factory; - private final LastlegRestClientProperties properties; + private final RestClient.Builder restClientBuilder; + private final LastlegRestClientFactory restClientFactory; + private final LastlegRestClientProperties restClientProperties; @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String token; private RestClient client; - @PostConstruct void initialize() { client = factory.build(builder, baseUrl, properties.getProcurementFile()); } + @PostConstruct void initialize() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getProcurementFile()); + } @Override public UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file) { diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 79683848c..023b0346d 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -56,6 +56,9 @@ baiyue: customer-visit-file: connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s} + procurement-file: + connect-timeout: ${LASTLEG_PROCUREMENT_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PROCUREMENT_FILE_READ_TIMEOUT:30s} warehouse-resource: connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java index 6c35754d0..11eceedb6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java @@ -7,6 +7,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class DestuffingPalletCostServiceTest { + @Test void newestCompletedBookingsAreNotStarvedByOldUnbillableRows() { + assertThat(DestuffingPalletCostService.READY_BOOKINGS_SQL) + .contains("ORDER BY MAX(t.completed_at) DESC LIMIT 50") + .doesNotContain("ORDER BY MIN(t.completed_at)"); + } @Test void preservesTotalAndLetsFinalCustomerAbsorbRoundingRemainder() { List result = DestuffingPalletCostService.allocate(new BigDecimal("10.00"), List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1"))); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 2b94fce57..29ac96ba3 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', '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-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', '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', @@ -82,6 +82,7 @@ const MODULE_DEPENDENCIES = new Map([ ['firstleg-container-fee-allocation', []], ['finance-direct-paid-isolation', ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']], ['phase4-overseas-finance-cross-region', ['phase4-overseas-business', 'phase4-overseas-pda-operations']], + ['phase4-overseas-wallet-and-pallet-cost', ['phase4-overseas-business', 'phase4-overseas-pda-operations']], ]) function stableTopologicalOrder(modules) { diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 12f825541..6f967a634 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -82,6 +82,7 @@ export async function run(context) { | 消息中心管理 | `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`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 | +| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 | | 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | | 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、整托移库、托盘拆装换绑和换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | | 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed | @@ -106,7 +107,7 @@ export async function run(context) { FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。 ```powershell -node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes +node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes,phase4-overseas-wallet-and-pallet-cost ``` 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index cf0d110f6..b4d30a2b0 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -24,6 +24,8 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () assert.match(fixture, /MERCHANT_TEST_USERNAME/) assert.match(fixture, /MERCHANT_TEST_PASSWORD/) assert.match(fixture, /context\.options\?\.customerName/) + assert.match(fixture, /tenantName/) + assert.match(fixture, /expectedProcurementPrefix: customerName \? null : 'QLYSXDKH'/) assert.match(acceptance, /administrator-phone-availability\?phone=/) assert.match(acceptance, /attempt < 100/) assert.doesNotMatch(acceptance, /phoneSequence % 10/) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index a21a9053c..168dac189 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -29,9 +29,10 @@ async function waitForDimensionInitialization(context, tenantId) { export async function run(context) { const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') const customerName = String(context.options?.customerName || '').trim() + const tenantName = customerName || `全量验收下单客户-${suffix}` const phone = await context.findAvailablePhone(MODULE) const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', { - tenantName: customerName || `全量验收下单客户-${suffix}`, + tenantName, legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`, legalRepresentative: '全量验收法人', contactName: '全量验收管理员', @@ -102,6 +103,12 @@ export async function run(context) { process.env.MERCHANT_TEST_USERNAME = username process.env.MERCHANT_TEST_PASSWORD = password - context.context.merchantQualificationFixture = { tenantId, username, password, marker: context.marker } - context.createdResources.merchantQualificationFixture = { tenantId, username, marker: context.marker } + context.context.merchantQualificationFixture = { + tenantId, tenantName, username, password, marker: context.marker, + expectedProcurementPrefix: customerName ? null : 'QLYSXDKH', + } + context.createdResources.merchantQualificationFixture = { + tenantId, tenantName, username, marker: context.marker, + expectedProcurementPrefix: customerName ? null : 'QLYSXDKH', + } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index fb746f63c..4a9c87246 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -173,6 +173,7 @@ test('PDA receiving lifecycle is ordered from candidate claim through photos, mo '/current/receipts/bulk', '/current/receipts/carton', "'FBA模式硬拒绝逐箱收货'", "'OVERSEAS模式硬拒绝批量收货'", 'PHOTO_ROLES.every', "finalStatus === 'COMPLETED'", 'completedContainers.push', + 'completedContainerFacts.push', 'expectedPalletCost', 'unitPrice', ]) assert.ok(source.includes(token), token) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 844fecbb7..440fdc203 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -45,7 +45,8 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho '/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review', ]) assert.ok(source.includes(endpoint), endpoint) for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', - 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'APPROVE', 'COMPLETED']) { + 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'APPROVE', 'COMPLETED', + 'chargeAmount: 0.01', 'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) { assert.ok(source.includes(marker), marker) } for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 28696796e..7044ab273 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -40,6 +40,36 @@ async function request(context, token, method, name, path, body = undefined) { return { response, data: success(response) ? response.body?.data : null } } +async function poll(action, predicate, attempts = 60) { + 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 fundRelabelWallet(context, customerId) { + const accountResponse = await context.request('GET', + `/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`) + const account = success(accountResponse) + ? records(accountResponse.body?.data).find((item) => id(item?.customerId) === customerId) : null + context.addStep(MODULE, 'P5读取本轮客户钱包版本', accountResponse, + Boolean(account) && Number.isInteger(Number(account.accountVersion)), trace(accountResponse), OPTIONS) + if (!account) return false + const recharge = await context.request('POST', + `/api/platform/finance/v1/accounts/${encodeURIComponent(customerId)}/adjustments`, { + type: 'RECHARGE', amount: 1, occurredDate: new Date().toISOString().slice(0, 10), + paymentMethod: 'BANK_TRANSFER', referenceNo: `${context.marker}-P5`, remark: context.marker, + expectedAccountVersion: Number(account.accountVersion), + }, context.token, { 'Idempotency-Key': key(context, 'RELABEL-WALLET-FUND') }) + context.addStep(MODULE, 'P5为收费换标准备本轮隔离实际余额', recharge, + success(recharge) && Number(recharge.body?.data?.actualBalanceAfter) >= 1, + `actualBalanceAfter=${recharge.body?.data?.actualBalanceAfter ?? 'missing'}; ${trace(recharge)}`, OPTIONS) + return success(recharge) +} + async function expectConflict(context, token, method, name, path, body) { const response = await context.requestOverseas(method, path, body, {}, token) context.addStep(MODULE, name, response, conflict(response), trace(response), OPTIONS) @@ -292,13 +322,14 @@ async function verifyRelabel(context, token, fixture) { blocker(context, 'P5本轮库存换标申请', '缺少本轮完成库存的客户、SKU或海外仓事实') return false } + if (!await fundRelabelWallet(context, customerId)) return false const relabelBase = '/api/platform/v1/last-leg/relabel-applications' const pdaBase = '/api/pda/v1/last-leg/relabel-tasks' const newSku = `${inventory.skuCode}-NEW-${String(context.marker).slice(-8)}`.slice(0, 128) const createBody = { customerOrderNo: String(customerOrderNo).slice(0, 128), overseasWarehouseId: id(fixture.overseasWarehouseId), labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), - contactName: 'PDA换标验收', contactPhone: '13800000006', remark: context.marker, + contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker, idempotencyKey: key(context, 'RELABEL-CREATE'), items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品', totalPackageCount: 3, notifiedPackageCount: 3, shipmentNo: `SHIP-${context.marker}`.slice(0, 128), @@ -316,6 +347,12 @@ async function verifyRelabel(context, token, fixture) { blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse) return false } + const funded = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), + (response) => success(response) && response.body?.data?.application?.walletStatus === 'FROZEN') + context.addStep(MODULE, 'P5换标费用冻结后开放PDA作业', funded.result, + funded.result?.body?.data?.application?.walletStatus === 'FROZEN', + `pollRequests=${funded.attempts}, walletStatus=${funded.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(funded.result)}`, OPTIONS) + if (funded.result?.body?.data?.application?.walletStatus !== 'FROZEN') return false const candidates = await request(context, token, 'GET', 'P5读取当前仓换标候选', `${pdaBase}/candidates`) context.assert(MODULE, 'P5候选包含本轮真实申请', candidates.response, Array.isArray(candidates.data) && candidates.data.some((item) => id(item?.relabelNo) === relabelNo), @@ -404,7 +441,12 @@ async function verifyRelabel(context, token, fixture) { context.assert(MODULE, 'P5最终任务和申请均COMPLETED', approveReplay.response, approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED' && approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS) - return success(approved.response) + const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`), + (response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED') + context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result, + settled.result?.body?.data?.application?.walletStatus === 'SETTLED', + `pollRequests=${settled.attempts}, walletStatus=${settled.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(settled.result)}`, OPTIONS) + return success(approved.response) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED' } export async function run(context) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 241efb2b2..cb6b1c6b8 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -177,14 +177,15 @@ async function createPallet(context, inbound, index) { if (!longId(locationId)) return null context.createdResources.phase4OverseasPda.locationIds.push(locationId) const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128) + const unitPrice = Number((1.01 + index).toFixed(2)) const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { - palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', locationId, + palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice, status: 'IDLE', locationId, remark: `${context.marker}-receiving-retained`, fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null, }) context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS) const palletId = id(pallet?.body?.data) - return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, retained: true } : null + return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, unitPrice, retained: true } : null } async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) { @@ -656,6 +657,12 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { + context.createdResources.phase4OverseasPda.completedContainerFacts.push({ + containerNo, + containerBookingId: id(preferredProducer?.containerBookingId), + expectedPalletCost: [...pallets.values()].reduce((sum, item) => sum + Number(item.unitPrice || 0), 0), + palletIds: [...pallets.values()].map((item) => item.palletId), + }) const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, @@ -673,6 +680,7 @@ export async function run(context) { context.createdResources.phase4OverseasPda = { marker: context.marker, containerNo: null, customerId: null, orderId: null, palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], + completedContainerFacts: [], overseasWarehouseId: null, overseasWarehouseName: null, cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', } diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs new file mode 100644 index 000000000..fab42cd5f --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { beijingDate, matchesProcurementNo } from './phase4-overseas-wallet-and-pallet-cost.mjs' + +const source = readFileSync(new URL('./phase4-overseas-wallet-and-pallet-cost.mjs', import.meta.url), 'utf8') +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url), 'utf8') + +test('采购单号按北京时间日期执行精确合同', () => { + const instant = new Date('2026-09-05T16:30:00.000Z') + assert.equal(beijingDate(instant), '20260906') + assert.equal(matchesProcurementNo('QLYSXDKH202609060001', 'QLYSXDKH', instant), true) + assert.equal(matchesProcurementNo('QLYSXDKH202609050001', 'QLYSXDKH', instant), false) + assert.equal(matchesProcurementNo('QLYSXDKH20260906001', 'QLYSXDKH', instant), false) +}) + +test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => { + for (const token of [ + '/accounts/${encodeURIComponent(customerId)}/credit-limit', + '/accounts/${encodeURIComponent(customerId)}/adjustments', + "const CLIENT_FINANCE = '/api/client/finance/v1'", + "['VOUCHER', 'RECEIPT']", + "walletStatus === 'FROZEN'", + "walletStatus === 'SETTLED'", + "'RELEASED'", + "terminalStatus === 'COMPLETED'", + "'CANCELLED'", + 'relabelPayload(context, warehouseId, suffix, 0.01)', + ]) assert.ok(source.includes(token), token) + assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/) + assert.doesNotMatch(source, /internal\/finance|jdbc|mysql|process\.env|https?:\/\//i) +}) + +test('托盘汇总和拆柜成本验证总数、分摊及跨区金额守恒', () => { + for (const token of [ + '/api/platform/v1/pallets/summary', + '/api/platform/v1/warehouse-admin/pallets/summary', + '/pallets/summary?page=1&size=20', + 'CONTAINER_DESTUFFING', + 'DESTUFF_PALLET', + 'expectedPalletCost', + 'sourceFeeId', + 'pollRequests=', + ]) assert.ok(source.includes(token), token) + assert.match(source, /Number\(d\.totalCount\) === Number\(d\.idleCount\) \+ Number\(d\.occupiedCount\)/) + assert.match(source, /allocated === money\(fact\.expectedPalletCost\)/) + assert.match(source, /delivered\.reduce[\s\S]*fact\.expectedPalletCost/) + assert.match(pda, /unitPrice = Number\(\(1\.01 \+ index\)\.toFixed\(2\)\)/) + assert.match(pda, /completedContainerFacts\.push/) +}) + +test('模块进入默认全量并绑定真实商户和海外PDA生产链', () => { + assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-wallet-and-pallet-cost'/) + assert.match(entry, /\['phase4-overseas-wallet-and-pallet-cost', \['phase4-overseas-business', 'phase4-overseas-pda-operations'\]\]/) + assert.match(source, /OPTIONS = \{ scenario: SCENARIO, deploymentBlocker: true \}/) + assert.match(source, /completedFacts\.length > 0/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs new file mode 100644 index 000000000..62313932d --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -0,0 +1,320 @@ +const MODULE = 'phase4-overseas-wallet-and-pallet-cost' +const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const PROCUREMENT = '/api/platform/v1/last-leg/overseas-procurements' +const RELABEL = '/api/platform/v1/last-leg/relabel-applications' +const FINANCE = '/api/platform/finance/v1' +const CLIENT_FINANCE = '/api/client/finance/v1' +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + +const id = (value) => value == null ? '' : String(value) +const money = (value) => Number(Number(value || 0).toFixed(2)) +const success = (response) => response?.http === 200 && String(response?.body?.code) === '200' +const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] +const trace = (response) => `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +const query = (path, params) => { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value)) + return `${path}?${search}` +} + +export function beijingDate(now = new Date()) { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + }).formatToParts(now).map((part) => [part.type, part.value])) + return `${parts.year}${parts.month}${parts.day}` +} + +export function matchesProcurementNo(orderNo, prefix, now = new Date()) { + return typeof prefix === 'string' && prefix.length > 0 + && new RegExp(`^${prefix}${beijingDate(now)}\\d{4}$`).test(String(orderNo || '')) +} + +async function poll(action, predicate, attempts = 60, intervalMs = 1_000) { + 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, intervalMs)) + } + return { result, attempts } +} + +function assert(context, name, response, passed, evidence = '') { + context.assert(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS) +} + +async function merchantLogin(context, fixture) { + const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') + const auth = success(response) ? response.body.data : null + assert(context, '客户账号同时取得国内和海外短时Token', response, + Boolean(auth?.accessToken) && Boolean(auth?.overseasAccessToken), + `customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.overseasAccessToken)}`) + return auth +} + +async function platformAccount(context, customerId) { + const response = await context.request('GET', query(`${FINANCE}/accounts`, { page: 1, pageSize: 20, customerId })) + const account = success(response) ? rows(response.body.data).find((item) => id(item.customerId) === id(customerId)) : null + return { response, account } +} + +async function prepareWallet(context, customerId) { + const before = await platformAccount(context, customerId) + assert(context, '平台客户账户返回余额和授信字段', before.response, + before.account && ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit', 'accountVersion'] + .every((field) => Number.isFinite(Number(before.account[field]))), `customerId=${customerId}`) + if (!before.account) return null + + const creditLimit = 2.00 + const credit = await context.request('PUT', `${FINANCE}/accounts/${encodeURIComponent(customerId)}/credit-limit`, + { creditLimit, remark: context.marker, expectedAccountVersion: Number(before.account.accountVersion) }, context.token, + { 'Idempotency-Key': `${context.marker}-wallet-credit-limit`.slice(0, 128) }) + assert(context, '授信额度可通过正式接口初始化', credit, + success(credit) && money(credit.body.data?.creditLimitAfter) === creditLimit, + `creditLimit=${credit.body?.data?.creditLimitAfter ?? 'missing'}`) + if (!success(credit)) return null + + const credited = await platformAccount(context, customerId) + assert(context, '授信初始化后账户版本和可用授信已更新', credited.response, + credited.account && money(credited.account.creditLimit) === creditLimit + && Number(credited.account.accountVersion) > Number(before.account.accountVersion), + `version=${credited.account?.accountVersion ?? 'missing'}, availableCredit=${credited.account?.availableCredit ?? 'missing'}`) + if (!credited.account) return null + + const rechargeAmount = 10.00 + const recharge = await context.request('POST', `${FINANCE}/accounts/${encodeURIComponent(customerId)}/adjustments`, { + type: 'RECHARGE', amount: rechargeAmount, occurredDate: new Date().toISOString().slice(0, 10), + paymentMethod: 'BANK_TRANSFER', referenceNo: `${context.marker}-AUTO`, remark: context.marker, + expectedAccountVersion: Number(credited.account.accountVersion), + }, context.token, { 'Idempotency-Key': `${context.marker}-wallet-recharge`.slice(0, 128) }) + assert(context, '平台人工充值立即进入实际余额', recharge, + success(recharge) && money(recharge.body.data?.actualBalanceAfter) === money(Number(before.account.actualBalance) + rechargeAmount), + `before=${before.account.actualBalance}, after=${recharge.body?.data?.actualBalanceAfter ?? 'missing'}`) + return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null +} + +async function procurementFlow(context, fixture, overseasToken) { + const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements' + const payload = { + customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`, + productSpecification: 'full acceptance wallet', productUrl: null, purchaseQuantity: 2, + budgetUnitPrice: 1, currency: 'USD', receivingAddress: 'Los Angeles', + contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker, + idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255), + } + const created = await context.requestOverseas('POST', merchantPath, payload, {}, overseasToken) + const detail = created?.body?.data + const orderNo = detail?.summary?.orderNo + assert(context, '客户创建钱包采购单', created, + success(created) && id(detail?.summary?.customerId) === id(fixture.tenantId) && detail?.summary?.status === 'PENDING_REVIEW', + `orderNo=${orderNo || 'missing'}, status=${detail?.summary?.status || 'missing'}`) + if (!success(created) || !orderNo) return null + assert(context, '采购单号按客户拼音首字母加北京时间日期和四位序号生成', created, + fixture.expectedProcurementPrefix ? matchesProcurementNo(orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(orderNo), + `orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`) + + const customerId = id(fixture.tenantId) + const approved = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), { + version: Number(detail.summary.version), decision: 'APPROVE', + idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker, + }) + assert(context, '平台审核通过钱包采购单', approved, + success(approved) && approved.body.data?.summary?.status === 'APPROVED', + `status=${approved.body?.data?.summary?.status || 'missing'}`) + if (!success(approved)) return null + + const files = [] + for (const category of ['VOUCHER', 'RECEIPT']) { + const uploaded = await context.requestOverseasMultipart('POST', + query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }), + { content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` }) + assert(context, `采购单上传${category}文件`, uploaded, + success(uploaded) && id(uploaded.body.data?.fileId) !== '' && uploaded.body.data?.category === category, + `fileId=${id(uploaded.body?.data?.fileId) || 'missing'}`) + if (success(uploaded)) files.push(uploaded.body.data) + } + if (files.length !== 2) return null + + const fee = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), { + feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01, + voucherFileId: Number(files[0].fileId), remark: context.marker, + }) + const feeNo = fee?.body?.data?.feeNo + assert(context, '采购费用创建并开始钱包冻结', fee, + success(fee) && Boolean(feeNo) && ['PENDING', 'FROZEN'].includes(fee.body.data?.walletStatus), + `feeNo=${feeNo || 'missing'}, walletStatus=${fee.body?.data?.walletStatus || 'missing'}`) + if (!success(fee) || !feeNo) return null + + const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId }) + const frozen = await poll(async () => context.requestOverseas('GET', detailPath), + (response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN') + const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo) + assert(context, '采购费用跨区冻结到账', frozen.result, + frozenFee?.walletStatus === 'FROZEN', `feeNo=${feeNo}, pollRequests=${frozen.attempts}, walletStatus=${frozenFee?.walletStatus || 'missing'}`) + if (frozenFee?.walletStatus !== 'FROZEN') return null + + const current = frozen.result.body.data.summary + const settledOrder = await context.requestOverseas('PATCH', detailPath, { + version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity), + }) + assert(context, '采购全量入库推进待出库并触发结算', settledOrder, + success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND', + `status=${settledOrder.body?.data?.summary?.status || 'missing'}`) + const settled = await poll(async () => context.requestOverseas('GET', detailPath), + (response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED') + const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo) + assert(context, '采购费用跨区结算到账', settled.result, + settledFee?.walletStatus === 'SETTLED', `feeNo=${feeNo}, pollRequests=${settled.attempts}, walletStatus=${settledFee?.walletStatus || 'missing'}`) + return { orderNo, feeNo, freezePollRequests: frozen.attempts, settlePollRequests: settled.attempts } +} + +function relabelPayload(context, warehouseId, suffix, chargeAmount) { + const value = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + return { + customerOrderNo: `${context.marker}-${suffix}`.slice(0, 128), overseasWarehouseId: id(warehouseId), + labelDeadline: new Date(Date.now() + 86_400_000).toISOString().slice(0, 19), + contactName: '全量验收联系人', contactPhone: '+1-213-555-0199', chargeAmount, + remark: context.marker, idempotencyKey: `${context.marker}-${suffix}`.slice(0, 255), + items: [{ skuCode: `OLD-${value}-${suffix}`.slice(0, 128), productNameZh: `${context.marker} 换标商品`, + totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `SHIP-${value}-${suffix}`.slice(0, 128), + newSkuCode: `NEW-${value}-${suffix}`.slice(0, 128), labelRequirement: context.marker }], + } +} + +async function relabelFlow(context, overseasToken, warehouseId, suffix, terminalStatus) { + const payload = relabelPayload(context, warehouseId, suffix, 0.01) + const created = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, {}, overseasToken) + const relabelNo = created?.body?.data?.application?.relabelNo + assert(context, `${suffix}换标单创建并携带收费金额`, created, + success(created) && Boolean(relabelNo) && money(created.body.data?.application?.chargeAmount) === 0.01, + `relabelNo=${relabelNo || 'missing'}, charge=${created.body?.data?.application?.chargeAmount ?? 'missing'}`) + if (!success(created) || !relabelNo) return null + + const detailPath = `${RELABEL}/${encodeURIComponent(relabelNo)}` + const frozen = await poll(async () => context.requestOverseas('GET', detailPath), + (response) => success(response) && response.body.data?.application?.walletStatus === 'FROZEN') + assert(context, `${suffix}换标费用跨区冻结到账`, frozen.result, + frozen.result?.body?.data?.application?.walletStatus === 'FROZEN', + `relabelNo=${relabelNo}, pollRequests=${frozen.attempts}, walletStatus=${frozen.result?.body?.data?.application?.walletStatus || 'missing'}`) + if (frozen.result?.body?.data?.application?.walletStatus !== 'FROZEN') return null + + if (terminalStatus === 'COMPLETED') { + const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker }) + assert(context, '换标单冻结后才允许进入处理中', processing, + success(processing) && processing.body.data?.application?.status === 'PROCESSING') + } + const terminal = await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker }) + assert(context, `${suffix}换标单进入${terminalStatus}`, terminal, + success(terminal) && terminal.body.data?.application?.status === terminalStatus) + const walletTerminal = terminalStatus === 'COMPLETED' ? 'SETTLED' : 'RELEASED' + const wallet = await poll(async () => context.requestOverseas('GET', detailPath), + (response) => success(response) && response.body.data?.application?.walletStatus === walletTerminal) + assert(context, `${suffix}换标费用跨区${walletTerminal}`, wallet.result, + wallet.result?.body?.data?.application?.walletStatus === walletTerminal, + `relabelNo=${relabelNo}, pollRequests=${wallet.attempts}, walletStatus=${wallet.result?.body?.data?.application?.walletStatus || 'missing'}`) + return { relabelNo, terminalStatus, walletStatus: walletTerminal, freezePollRequests: frozen.attempts, terminalPollRequests: wallet.attempts } +} + +async function verifyClientWallet(context, token, fixture, wallet, procurement, relabels) { + const account = await context.request('GET', `${CLIENT_FINANCE}/account`, undefined, token) + const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - 0.02) + assert(context, '客户工作台余额与冻结结算结果一致', account, + success(account) && money(account.body.data?.actualBalance) === expectedActual + && money(account.body.data?.frozenBalance) === money(wallet.before.frozenBalance) + && money(account.body.data?.creditUsed) === money(wallet.before.creditUsed), + `expectedActual=${expectedActual}, actual=${account.body?.data?.actualBalance ?? 'missing'}, frozen=${account.body?.data?.frozenBalance ?? 'missing'}, creditUsed=${account.body?.data?.creditUsed ?? 'missing'}`) + const ledger = await context.request('GET', `${CLIENT_FINANCE}/account/ledger?page=1&size=100`, undefined, token) + const entries = rows(ledger?.body?.data) + const businessNos = [procurement.feeNo, ...relabels.map((item) => item.relabelNo)] + assert(context, '客户账本包含充值、冻结、结算和释放明细', ledger, + success(ledger) && entries.some((item) => item.changeType === 'RECHARGE') + && businessNos.every((businessNo) => entries.some((item) => item.businessNo === businessNo)) + && ['FREEZE', 'SETTLE', 'RELEASE'].every((type) => entries.some((item) => item.changeType === type)), + `entries=${entries.length}, businessNos=${businessNos.join(',')}`) + return account?.body?.data +} + +function summaryValid(value, fields) { + return fields.every((field) => Number.isInteger(Number(value?.[field])) && Number(value[field]) >= 0) +} + +async function verifyPalletSummaries(context, warehouseId) { + const domestic = await context.request('GET', '/api/platform/v1/pallets/summary?tenantId=0&page=1&size=20') + const d = domestic?.body?.data + assert(context, '国内托盘汇总含闲置和占用数量', domestic, + success(domestic) && summaryValid(d, ['totalCount', 'idleCount', 'occupiedCount']) + && Number(d.totalCount) === Number(d.idleCount) + Number(d.occupiedCount), + `total=${d?.totalCount}, idle=${d?.idleCount}, occupied=${d?.occupiedCount}`) + const overseas = await context.requestOverseas('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20') + const o = overseas?.body?.data + assert(context, '海外托盘汇总含闲置、占用和报废数量', overseas, + success(overseas) && summaryValid(o, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount']) + && Number(o.totalCount) === Number(o.idleCount) + Number(o.occupiedCount) + Number(o.scrappedCount), + `total=${o?.totalCount}, idle=${o?.idleCount}, occupied=${o?.occupiedCount}, scrapped=${o?.scrappedCount}`) + const warehouse = await context.requestOverseas('GET', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`) + assert(context, '海外仓级托盘汇总可用', warehouse, + success(warehouse) && summaryValid(warehouse.body.data, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount'])) +} + +async function verifyDestuffCosts(context, completedFacts) { + const evidence = [] + for (const fact of completedFacts) { + const businessNo = `DESTUFF-${fact.containerBookingId}` + const lastleg = await poll(async () => context.requestOverseas('GET', query('/api/platform/v1/last-leg/finance-fee-sources', { + businessType: 'CONTAINER_DESTUFFING', businessNo, + })), (response) => success(response) && rows(response.body.data).length > 0, 75) + const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : [] + const allocated = money(sources.reduce((sum, item) => sum + Number(item.amount || 0), 0)) + assert(context, '拆柜打托费用按客户体积分摊且金额守恒', lastleg.result, + sources.length > 0 && sources.every((item) => item.sourceType === 'OVERSEAS_CONTAINER_FEE' + && item.feeItemCode === 'DESTUFF_PALLET' && Number(item.amount) > 0) + && allocated === money(fact.expectedPalletCost), + `containerBookingId=${fact.containerBookingId}, sources=${sources.length}, allocated=${allocated}, expected=${money(fact.expectedPalletCost)}, pollRequests=${lastleg.attempts}`) + const finance = await poll(async () => context.request('GET', query(`${FINANCE}/fee-sources`, { + page: 1, pageSize: 200, keyword: businessNo, + })), (response) => success(response) && sources.length > 0 + && sources.every((source) => rows(response.body.data).some((item) => id(item.sourceFeeId) === id(source.sourceFeeId))), 60) + const delivered = success(finance.result) ? rows(finance.result.body.data) + .filter((item) => sources.some((source) => id(source.sourceFeeId) === id(item.sourceFeeId))) : [] + assert(context, '拆柜打托费用跨区进入Finance且金额守恒', finance.result, + delivered.length === sources.length + && money(delivered.reduce((sum, item) => sum + Number(item.amount || 0), 0)) === money(fact.expectedPalletCost), + `containerBookingId=${fact.containerBookingId}, delivered=${delivered.length}, expected=${sources.length}, pollRequests=${finance.attempts}`) + evidence.push({ containerBookingId: fact.containerBookingId, expectedPalletCost: money(fact.expectedPalletCost), + allocations: sources.length, lastlegPollRequests: lastleg.attempts, financePollRequests: finance.attempts }) + } + return evidence +} + +export async function run(context) { + const start = context.steps.length + const fixture = context.context?.merchantQualificationFixture + const completedFacts = context.createdResources?.phase4OverseasPda?.completedContainerFacts || [] + const warehouseId = context.createdResources?.phase4OverseasPda?.overseasWarehouseId + || context.context?.phase4OverseasWarehouse?.warehouseId + context.assert(MODULE, '钱包闭环必须使用本轮已审核客户和PDA实收柜', null, + Boolean(fixture?.tenantId && fixture?.username && fixture?.password && warehouseId && completedFacts.length > 0), + `fixture=${Boolean(fixture)}, warehouseId=${warehouseId || 'missing'}, completedContainers=${completedFacts.length}`, OPTIONS) + if (!fixture?.tenantId || !warehouseId || completedFacts.length === 0) return + + const auth = await merchantLogin(context, fixture) + const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null + const procurement = wallet ? await procurementFlow(context, fixture, auth.overseasAccessToken) : null + const settledRelabel = procurement ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'SETTLE', 'COMPLETED') : null + const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'RELEASE', 'CANCELLED') : null + const relabels = [settledRelabel, releasedRelabel].filter(Boolean) + const finalAccount = procurement && relabels.length === 2 + ? await verifyClientWallet(context, auth.accessToken, fixture, wallet, procurement, relabels) : null + await verifyPalletSummaries(context, warehouseId) + const destuffCosts = await verifyDestuffCosts(context, completedFacts) + context.createdResources.phase4OverseasWalletAndPalletCost = { + marker: context.marker, customerId: id(fixture.tenantId), procurement, relabels, + finalAccount, completedContainerCosts: destuffCosts, + } + 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 }) +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index b785266cd..600eb6c4c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -13,7 +13,7 @@ function response(data, code = '200') { return { http: 200, body: { code, messag function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseType: 'OVERSEAS', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { - const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null + const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null const context = { context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, async request(method, requestPath, body) { @@ -35,15 +35,6 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() } - if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) { - if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409') - if (!createdRelabel) createdRelabel = { ...body, relabelNo: 'FBR-TEST', status: 'PENDING_PROCESSING' } - return response({ application: createdRelabel, items: body.items, history: [] }) - } - if (method === 'PUT' && requestPath.endsWith('/relabel-applications/FBR-TEST/status')) { - createdRelabel = { ...createdRelabel, status: body.status } - return response({ application: createdRelabel, items: createdRelabel.items, history: [] }) - } if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }])) if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] }) if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }])) @@ -80,7 +71,7 @@ test('模块只依赖注入请求,不读取环境或自行联网', () => { assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) }) -test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { +test('既有仓库场景只写本轮库位与托盘生命周期', () => { const context = contextFixture() return run(context).then(() => { assert.ok(context.calls.length >= 12) @@ -93,8 +84,8 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { const writes = context.calls.filter((call) => call.method !== 'GET') assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets'))) - assert.ok(writes.some((call) => call.path.includes('/relabel-applications'))) - assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications'))) + assert.equal(writes.some((call) => call.path.includes('/relabel-applications')), false) + assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/'))) for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { const step = context.steps.find((item) => item.name === name) assert.ok(step?.passed, name) @@ -123,10 +114,10 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => { assert.equal(params.has('page'), false, call.path) assert.equal(params.has('size'), false, call.path) } - assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001', relabelNo: 'FBR-TEST' }) + assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001' }) assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED', - locationId: '72002', locationTerminalStatus: 'DELETED', relabelNo: 'FBR-TEST', relabelTerminalStatus: 'COMPLETED', + locationId: '72002', locationTerminalStatus: 'DELETED', }) }) @@ -158,8 +149,7 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', assert.match(source, new RegExp(`longId\\(plan\\.${field}\\)`)) } assert.match(source, /id\(pallet\.customerId\) === '0'/) - assert.match(source, /创建本轮换标单/) - assert.match(source, /换标单同幂等键不同请求返回A0409/) + assert.doesNotMatch(source, /创建本轮换标单|verifyRelabelLifecycle/) assert.match(source, /创建本轮公共库位/) assert.match(source, /公共托盘旧版本编辑返回冲突/) assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index f8652e492..bd11e6a32 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -194,11 +194,9 @@ export async function run(context) { try { await verifyLocationLifecycle(context, warehouseId) await verifyPalletLifecycle(context, warehouseId) - await verifyRelabelLifecycle(context, customerId, warehouseId) context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId, - relabelNo: context.createdResources.phase4OverseasWarehouse.relabelNo, } } finally { if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId) @@ -250,68 +248,6 @@ async function verifyLocationLifecycle(context, warehouseId) { } } -async function verifyRelabelLifecycle(context, customerId, warehouseId) { - const marker = String(context.marker || 'PHASE4') - const idempotencyKey = `${marker}:RELABEL`.slice(0, 255) - const skuSuffix = marker.replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() || 'PHASE4' - const payload = { - customerOrderNo: `${marker}-RELABEL`, overseasWarehouseId: warehouseId, - labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), - contactName: 'Phase4 QA', contactPhone: '+1-213-555-0199', remark: marker, idempotencyKey, - items: [{ skuCode: `OLD-${skuSuffix}`.slice(0, 128), productNameZh: `${marker} 换标商品`, - totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: `SHIP-${skuSuffix}`.slice(0, 128), - newSkuCode: `NEW-${skuSuffix}`.slice(0, 128), labelRequirement: marker }], - } - let relabelNo = '' - let terminalStatus = '' - try { - const created = await overseasMutation(context, 'POST', '创建本轮换标单', - query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload) - relabelNo = id(created.data?.application?.relabelNo) - context.createdResources.phase4OverseasWarehouse.relabelNo = relabelNo || null - context.assert(MODULE, '换标单创建事实正确', created.response, - nonBlank(relabelNo) && created.data?.application?.customerOrderNo === payload.customerOrderNo - && created.data?.application?.status === 'PENDING_PROCESSING' - && Array.isArray(created.data?.items) && created.data.items.length === 1, - `relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) - if (!relabelNo) return - - const replay = await overseasMutation(context, 'POST', '换标单幂等精确重放', - query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload) - context.assert(MODULE, '换标单精确重放返回同一单号', replay.response, - replay.data?.application?.relabelNo === relabelNo, - `expected=${relabelNo}, actual=${replay.data?.application?.relabelNo || 'missing'}; ${trace(replay.response)}`, OPTIONS) - - const conflict = await context.requestOverseas('POST', - query('/api/platform/v1/last-leg/relabel-applications', { customerId }), { ...payload, remark: `${marker}-DIFFERENT` }) - context.addStep(MODULE, '换标单同幂等键不同请求返回A0409', conflict, businessConflict(conflict), trace(conflict), OPTIONS) - - const processing = await overseasMutation(context, 'PUT', '换标单进入处理中', - `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, - { status: 'PROCESSING', remark: marker }) - context.assert(MODULE, '换标单状态推进为PROCESSING', processing.response, - processing.data?.application?.status === 'PROCESSING', - `status=${processing.data?.application?.status || 'missing'}; ${trace(processing.response)}`, OPTIONS) - const completed = await overseasMutation(context, 'PUT', '完成本轮换标单', - `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, - { status: 'COMPLETED', remark: `${marker}-completed` }) - terminalStatus = completed.data?.application?.status || '' - context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = terminalStatus || null - context.assert(MODULE, '本轮换标单已完成收口', completed.response, terminalStatus === 'COMPLETED', - `relabelNo=${relabelNo}, status=${terminalStatus || 'missing'}; ${trace(completed.response)}`, OPTIONS) - } finally { - if (relabelNo && terminalStatus !== 'COMPLETED') { - const cancelled = await context.requestOverseas('PUT', - `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, - { status: 'CANCELLED', remark: `${marker}-acceptance-cleanup` }) - const status = cancelled?.body?.data?.application?.status - context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = status || null - context.addStep(MODULE, '异常路径换标单取消收口', cancelled, - cancelled?.http === 200 && String(cancelled?.body?.code) === '200' && status === 'CANCELLED', trace(cancelled), OPTIONS) - } - } -} - async function createWarehouseFixture(context, customerId) { const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() const payload = { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index b4b3256a9..923f434ee 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -18,11 +18,21 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', + 'phase4-overseas-wallet-and-pallet-cost', ]) { assert.match(source, new RegExp(`CONFIGURED_MODULES\\s*=\\s*\\[[^\\]]*'${module}'`, 's')) } }) +test('wallet and destuff cost closure expands the fresh merchant and completed overseas PDA chain', () => { + const modules = parseArguments(['--modules', 'phase4-overseas-wallet-and-pallet-cost']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading-finalize', + 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-business']) { + assert.ok(modules.indexOf(prerequisite) >= 0 + && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-wallet-and-pallet-cost')) + } +}) + test('overseas inbound commands, client services and dispatch follow completed PDA receiving', () => { for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch']) { diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index e1b035650..e21ea482a 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -44,6 +44,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', 'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), + feign('finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java', + 'finance-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '钱包结果 Outbox 按 eventId 回传海外业务'), feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), @@ -156,6 +158,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '查询可重复;写操作按 business/reservation key 或状态收敛'), http('lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'IDEMPOTENCY_KEY', '托盘变更/补偿使用 operationKey 与请求哈希'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'WRITE', 'NONE', '采购凭证和签收文件上传无幂等键,禁止客户端自动重试'), http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 245f68850..446ca05c0 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -52,6 +52,7 @@ const expectedBackgroundEdges = [ 'file-service->iam-service', 'file-service->order-service', 'file-service->warehouse-service', + 'finance-service->event-bridge-service', 'finance-service->firstleg-service', 'firstleg-service->event-bridge-service', 'firstleg-service->finance-service', @@ -97,15 +98,15 @@ async function discoverFeignEdges() { return clients.sort((left, right) => left.path.localeCompare(right.path)) } -test('all 53 Feign clients have reviewed context, access and idempotency metadata', async () => { +test('all 54 Feign clients have reviewed context, access and idempotency metadata', async () => { const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 53) + assert.equal(reviewed.length, 54) assert.deepEqual(await discoverFeignEdges(), reviewed) }) -test('all 18 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { +test('all 19 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { @@ -117,7 +118,7 @@ test('all 18 raw HTTP transports have reviewed context, access and idempotency m } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 18) + assert.equal(reviewed.length, 19) assert.deepEqual(discovered.sort(), reviewed) }) @@ -147,10 +148,12 @@ test('IAM customer dimension Feign is reachable only from the background relay', ]) }) -test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { +test('all six Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], ['lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', 'getRelabelPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java', 'getCustomerVisitFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java', 'getProcurementFile'], ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', 'getWarehouseResource'], ['lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', 'getPalletPhysical'] ]) @@ -172,7 +175,7 @@ test('all four Lastleg RestClient gateways use explicit bounded transports', asy 'config/nacos/overseas/lastleg-service.yml' ]) { const source = await readFile(path.join(root, configFile), 'utf8') - for (const key of ['unload-photo-file', 'relabel-photo-file', 'warehouse-resource', 'pallet-physical']) { + for (const key of ['unload-photo-file', 'relabel-photo-file', 'customer-visit-file', 'procurement-file', 'warehouse-resource', 'pallet-physical']) { assert.match(source, new RegExp( `${key}:\\s*\\n\\s+connect-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}` + `\\s*\\n\\s+read-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}`))