test: automate overseas wallet and pallet cost closure

This commit is contained in:
2026-09-06 11:00:06 +08:00
parent 64bf5aa0fe
commit 20b3074cb1
21 changed files with 506 additions and 103 deletions
@@ -22,6 +22,12 @@ baiyue:
relabel-photo-file: relabel-photo-file:
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} 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: warehouse-resource:
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
@@ -54,12 +54,14 @@
#### Lastleg 四个有界 RestClient #### 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 秒 | | `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 秒 | | `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 秒 | | `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 秒 | | `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
@@ -22,13 +22,14 @@ import java.util.Map;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class DestuffingPalletCostService { 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 JdbcTemplate jdbc;
private final PalletPhysicalGateway pallets; private final PalletPhysicalGateway pallets;
private final OverseasFeeSourceMapper feeSources; private final OverseasFeeSourceMapper feeSources;
private final OverseasFeeOutboxService feeOutbox; private final OverseasFeeOutboxService feeOutbox;
public List<String> readyBookingIds() { public List<String> 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 @Transactional
@@ -18,13 +18,15 @@ import org.springframework.web.server.ResponseStatusException;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class HttpProcurementFileGateway implements ProcurementFileGateway { public class HttpProcurementFileGateway implements ProcurementFileGateway {
private final RestClient.Builder builder; private final RestClient.Builder restClientBuilder;
private final LastlegRestClientFactory factory; private final LastlegRestClientFactory restClientFactory;
private final LastlegRestClientProperties properties; private final LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String token; @Value("${baiyue.security.internal-service-token:}") private String token;
private RestClient client; private RestClient client;
@PostConstruct void initialize() { client = factory.build(builder, baseUrl, properties.getProcurementFile()); } @PostConstruct void initialize() {
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getProcurementFile());
}
@Override @Override
public UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file) { public UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file) {
@@ -56,6 +56,9 @@ baiyue:
customer-visit-file: customer-visit-file:
connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s} 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: warehouse-resource:
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
@@ -7,6 +7,11 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DestuffingPalletCostServiceTest { 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() { @Test void preservesTotalAndLetsFinalCustomerAbsorbRoundingRemainder() {
List<BigDecimal> result = DestuffingPalletCostService.allocate(new BigDecimal("10.00"), List<BigDecimal> result = DestuffingPalletCostService.allocate(new BigDecimal("10.00"),
List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1"))); List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1")));
+2 -1
View File
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import process from 'node:process' 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', const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify',
@@ -82,6 +82,7 @@ const MODULE_DEPENDENCIES = new Map([
['firstleg-container-fee-allocation', []], ['firstleg-container-fee-allocation', []],
['finance-direct-paid-isolation', ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']], ['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-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) { function stableTopologicalOrder(modules) {
+2 -1
View File
@@ -82,6 +82,7 @@ export async function run(context) {
| 消息中心管理 | `notifications.mjs` | `notifications-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 真实前置、对账、结算与下载中心 | `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`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 | | 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` | | 海外仓 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` | | 海外 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 | | 海外入仓扩展、客户服务与排车 | `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 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。 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 ```powershell
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-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 <development-gateway> --overseas-gateway <development-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 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
@@ -24,6 +24,8 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', ()
assert.match(fixture, /MERCHANT_TEST_USERNAME/) assert.match(fixture, /MERCHANT_TEST_USERNAME/)
assert.match(fixture, /MERCHANT_TEST_PASSWORD/) assert.match(fixture, /MERCHANT_TEST_PASSWORD/)
assert.match(fixture, /context\.options\?\.customerName/) 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, /administrator-phone-availability\?phone=/)
assert.match(acceptance, /attempt < 100/) assert.match(acceptance, /attempt < 100/)
assert.doesNotMatch(acceptance, /phoneSequence % 10/) assert.doesNotMatch(acceptance, /phoneSequence % 10/)
@@ -29,9 +29,10 @@ async function waitForDimensionInitialization(context, tenantId) {
export async function run(context) { export async function run(context) {
const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0')
const customerName = String(context.options?.customerName || '').trim() const customerName = String(context.options?.customerName || '').trim()
const tenantName = customerName || `全量验收下单客户-${suffix}`
const phone = await context.findAvailablePhone(MODULE) const phone = await context.findAvailablePhone(MODULE)
const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', { const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', {
tenantName: customerName || `全量验收下单客户-${suffix}`, tenantName,
legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`, legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`,
legalRepresentative: '全量验收法人', legalRepresentative: '全量验收法人',
contactName: '全量验收管理员', contactName: '全量验收管理员',
@@ -102,6 +103,12 @@ export async function run(context) {
process.env.MERCHANT_TEST_USERNAME = username process.env.MERCHANT_TEST_USERNAME = username
process.env.MERCHANT_TEST_PASSWORD = password process.env.MERCHANT_TEST_PASSWORD = password
context.context.merchantQualificationFixture = { tenantId, username, password, marker: context.marker } context.context.merchantQualificationFixture = {
context.createdResources.merchantQualificationFixture = { tenantId, username, marker: context.marker } tenantId, tenantName, username, password, marker: context.marker,
expectedProcurementPrefix: customerName ? null : 'QLYSXDKH',
}
context.createdResources.merchantQualificationFixture = {
tenantId, tenantName, username, marker: context.marker,
expectedProcurementPrefix: customerName ? null : 'QLYSXDKH',
}
} }
@@ -173,6 +173,7 @@ test('PDA receiving lifecycle is ordered from candidate claim through photos, mo
'/current/receipts/bulk', '/current/receipts/carton', '/current/receipts/bulk', '/current/receipts/carton',
"'FBA模式硬拒绝逐箱收货'", "'OVERSEAS模式硬拒绝批量收货'", "'FBA模式硬拒绝逐箱收货'", "'OVERSEAS模式硬拒绝批量收货'",
'PHOTO_ROLES.every', "finalStatus === 'COMPLETED'", 'completedContainers.push', 'PHOTO_ROLES.every', "finalStatus === 'COMPLETED'", 'completedContainers.push',
'completedContainerFacts.push', 'expectedPalletCost', 'unitPrice',
]) assert.ok(source.includes(token), token) ]) assert.ok(source.includes(token), token)
}) })
@@ -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', '/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review',
]) assert.ok(source.includes(endpoint), endpoint) ]) assert.ok(source.includes(endpoint), endpoint)
for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', 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) assert.ok(source.includes(marker), marker)
} }
for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) { for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
@@ -40,6 +40,36 @@ async function request(context, token, method, name, path, body = undefined) {
return { response, data: success(response) ? response.body?.data : null } 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) { async function expectConflict(context, token, method, name, path, body) {
const response = await context.requestOverseas(method, path, body, {}, token) const response = await context.requestOverseas(method, path, body, {}, token)
context.addStep(MODULE, name, response, conflict(response), trace(response), OPTIONS) context.addStep(MODULE, name, response, conflict(response), trace(response), OPTIONS)
@@ -292,13 +322,14 @@ async function verifyRelabel(context, token, fixture) {
blocker(context, 'P5本轮库存换标申请', '缺少本轮完成库存的客户、SKU或海外仓事实') blocker(context, 'P5本轮库存换标申请', '缺少本轮完成库存的客户、SKU或海外仓事实')
return false return false
} }
if (!await fundRelabelWallet(context, customerId)) return false
const relabelBase = '/api/platform/v1/last-leg/relabel-applications' const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
const pdaBase = '/api/pda/v1/last-leg/relabel-tasks' const pdaBase = '/api/pda/v1/last-leg/relabel-tasks'
const newSku = `${inventory.skuCode}-NEW-${String(context.marker).slice(-8)}`.slice(0, 128) const newSku = `${inventory.skuCode}-NEW-${String(context.marker).slice(-8)}`.slice(0, 128)
const createBody = { const createBody = {
customerOrderNo: String(customerOrderNo).slice(0, 128), overseasWarehouseId: id(fixture.overseasWarehouseId), customerOrderNo: String(customerOrderNo).slice(0, 128), overseasWarehouseId: id(fixture.overseasWarehouseId),
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), 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'), idempotencyKey: key(context, 'RELABEL-CREATE'),
items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品', items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品',
totalPackageCount: 3, notifiedPackageCount: 3, shipmentNo: `SHIP-${context.marker}`.slice(0, 128), 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) blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse)
return false 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`) const candidates = await request(context, token, 'GET', 'P5读取当前仓换标候选', `${pdaBase}/candidates`)
context.assert(MODULE, 'P5候选包含本轮真实申请', candidates.response, context.assert(MODULE, 'P5候选包含本轮真实申请', candidates.response,
Array.isArray(candidates.data) && candidates.data.some((item) => id(item?.relabelNo) === relabelNo), 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, context.assert(MODULE, 'P5最终任务和申请均COMPLETED', approveReplay.response,
approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED' approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED'
&& approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS) && 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) { export async function run(context) {
@@ -177,14 +177,15 @@ async function createPallet(context, inbound, index) {
if (!longId(locationId)) return null if (!longId(locationId)) return null
context.createdResources.phase4OverseasPda.locationIds.push(locationId) context.createdResources.phase4OverseasPda.locationIds.push(locationId)
const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128) 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`, { 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`, remark: `${context.marker}-receiving-retained`,
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null, fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
}) })
context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS) context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS)
const palletId = id(pallet?.body?.data) 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) { 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.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { 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) const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
@@ -673,6 +680,7 @@ export async function run(context) {
context.createdResources.phase4OverseasPda = { context.createdResources.phase4OverseasPda = {
marker: context.marker, containerNo: null, customerId: null, orderId: null, marker: context.marker, containerNo: null, customerId: null, orderId: null,
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [],
completedContainerFacts: [],
overseasWarehouseId: null, overseasWarehouseName: null, overseasWarehouseId: null, overseasWarehouseName: null,
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
} }
@@ -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/)
})
@@ -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 })
}
@@ -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 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 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: '洛杉矶仓' }] } = {}) { 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 = { const context = {
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
async request(method, requestPath, body) { 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') if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() 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.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.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' }])) 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/) assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/)
}) })
test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { test('既有仓库场景只写本轮库位与托盘生命周期', () => {
const context = contextFixture() const context = contextFixture()
return run(context).then(() => { return run(context).then(() => {
assert.ok(context.calls.length >= 12) assert.ok(context.calls.length >= 12)
@@ -93,8 +84,8 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => {
const writes = context.calls.filter((call) => call.method !== 'GET') 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.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('/warehouse-admin/20001/pallets')))
assert.ok(writes.some((call) => 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/') || call.path.includes('/relabel-applications'))) assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/')))
for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) {
const step = context.steps.find((item) => item.name === name) const step = context.steps.find((item) => item.name === name)
assert.ok(step?.passed, name) assert.ok(step?.passed, name)
@@ -123,10 +114,10 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
assert.equal(params.has('page'), false, call.path) assert.equal(params.has('page'), false, call.path)
assert.equal(params.has('size'), 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, { assert.deepEqual(context.createdResources.phase4OverseasWarehouse, {
marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED', 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, new RegExp(`longId\\(plan\\.${field}\\)`))
} }
assert.match(source, /id\(pallet\.customerId\) === '0'/) assert.match(source, /id\(pallet\.customerId\) === '0'/)
assert.match(source, /创建本轮换标单/) assert.doesNotMatch(source, /创建本轮换标单|verifyRelabelLifecycle/)
assert.match(source, /换标单同幂等键不同请求返回A0409/)
assert.match(source, /创建本轮公共库位/) assert.match(source, /创建本轮公共库位/)
assert.match(source, /公共托盘旧版本编辑返回冲突/) assert.match(source, /公共托盘旧版本编辑返回冲突/)
assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/) assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/)
@@ -194,11 +194,9 @@ export async function run(context) {
try { try {
await verifyLocationLifecycle(context, warehouseId) await verifyLocationLifecycle(context, warehouseId)
await verifyPalletLifecycle(context, warehouseId) await verifyPalletLifecycle(context, warehouseId)
await verifyRelabelLifecycle(context, customerId, warehouseId)
context.context.phase4OverseasWarehouse = { context.context.phase4OverseasWarehouse = {
customerId, warehouseId, customerId, warehouseId,
palletId: context.createdResources.phase4OverseasWarehouse.palletId, palletId: context.createdResources.phase4OverseasWarehouse.palletId,
relabelNo: context.createdResources.phase4OverseasWarehouse.relabelNo,
} }
} finally { } finally {
if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId) 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) { async function createWarehouseFixture(context, customerId) {
const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase()
const payload = { const payload = {
@@ -18,11 +18,21 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance
'phase4-overseas-business', 'phase4-overseas-business',
'phase4-overseas-master-data', 'phase4-overseas-master-data',
'phase4-carrier-nodes', 'phase4-carrier-nodes',
'phase4-overseas-wallet-and-pallet-cost',
]) { ]) {
assert.match(source, new RegExp(`CONFIGURED_MODULES\\s*=\\s*\\[[^\\]]*'${module}'`, 's')) 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', () => { test('overseas inbound commands, client services and dispatch follow completed PDA receiving', () => {
for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services',
'phase4-overseas-dispatch']) { 'phase4-overseas-dispatch']) {
@@ -44,6 +44,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
'事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'),
feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java',
'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), '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', feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java',
'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), '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 或状态收敛'), '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', http('lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java',
'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'IDEMPOTENCY_KEY', '托盘变更/补偿使用 operationKey 与请求哈希'), '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', http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'),
http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java',
@@ -52,6 +52,7 @@ const expectedBackgroundEdges = [
'file-service->iam-service', 'file-service->iam-service',
'file-service->order-service', 'file-service->order-service',
'file-service->warehouse-service', 'file-service->warehouse-service',
'finance-service->event-bridge-service',
'finance-service->firstleg-service', 'finance-service->firstleg-service',
'firstleg-service->event-bridge-service', 'firstleg-service->event-bridge-service',
'firstleg-service->finance-service', 'firstleg-service->finance-service',
@@ -97,15 +98,15 @@ async function discoverFeignEdges() {
return clients.sort((left, right) => left.path.localeCompare(right.path)) 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') const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
.map(({ path, source, target }) => ({ path, source, target })) .map(({ path, source, target }) => ({ path, source, target }))
.sort((left, right) => left.path.localeCompare(right.path)) .sort((left, right) => left.path.localeCompare(right.path))
assert.equal(reviewed.length, 53) assert.equal(reviewed.length, 54)
assert.deepEqual(await discoverFeignEdges(), reviewed) 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 = [] const discovered = []
for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { 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'))) { 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') const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP')
.map(client => client.path).sort() .map(client => client.path).sort()
assert.equal(reviewed.length, 18) assert.equal(reviewed.length, 19)
assert.deepEqual(discovered.sort(), reviewed) 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([ 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/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/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/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', 'getWarehouseResource'],
['lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', 'getPalletPhysical'] ['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' 'config/nacos/overseas/lastleg-service.yml'
]) { ]) {
const source = await readFile(path.join(root, configFile), 'utf8') 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( assert.match(source, new RegExp(
`${key}:\\s*\\n\\s+connect-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}` `${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)\\}`)) + `\\s*\\n\\s+read-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}`))