test: automate overseas wallet and pallet cost closure
This commit is contained in:
@@ -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 秒 |
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
|
|||||||
+6
-4
@@ -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}
|
||||||
|
|||||||
+5
@@ -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")));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
+58
@@ -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)\\}`))
|
||||||
|
|||||||
Reference in New Issue
Block a user