优化
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||
|
||||
+8
@@ -47,6 +47,14 @@ public class ReceivingContainerController {
|
||||
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
|
||||
}
|
||||
|
||||
@GetMapping("/warehouse-profile")
|
||||
@Operation(summary = "Get the authenticated warehouse picker's current Chinese warehouse master name")
|
||||
public ApiResponse<ReceivingContainerResponses.WarehouseProfile> warehouseProfile(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
PdaWarehouseIdentity identity = identity(user);
|
||||
return ApiResponse.success(service.warehouseProfile(identity.overseasWarehouseId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{containerNo}")
|
||||
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ public final class ReceivingContainerResponses {
|
||||
List<String> destinationCodes, int plannedPalletCount,
|
||||
int totalPackages, int receivedPackages, boolean currentAccount) { }
|
||||
|
||||
public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { }
|
||||
|
||||
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
|
||||
String status, String operatorName, int version,
|
||||
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
|
||||
+6
@@ -87,6 +87,12 @@ public class ReceivingContainerService {
|
||||
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.WarehouseProfile warehouseProfile(long overseasWarehouseId) {
|
||||
WarehouseResourceGateway.WarehouseReference value = warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
|
||||
return new ReceivingContainerResponses.WarehouseProfile(
|
||||
Long.toString(value.warehouseId()), value.warehouseCode(), value.warehouseName());
|
||||
}
|
||||
|
||||
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||
requireSessionWarehouse(current, overseasWarehouseId);
|
||||
|
||||
@@ -9,6 +9,12 @@ paths:
|
||||
description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/warehouse-profile:
|
||||
get:
|
||||
summary: 查询当前PDA账号绑定的海外仓主档
|
||||
description: 权限 lastleg:pda:operate;仓库ID只取Token,名称与编码由共享仓库主档实时解析,不接收warehouseId参数。
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: 当前绑定仓库中文名称与编码, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWarehouseProfileApiResponse' } } } }, '403': { description: 账号未绑定有效海外仓 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}:
|
||||
get:
|
||||
summary: 查询待签收柜号详情
|
||||
@@ -691,6 +697,13 @@ components:
|
||||
totalPackages: { type: integer }
|
||||
receivedPackages: { type: integer }
|
||||
pendingPackages: { type: integer }
|
||||
ReceivingWarehouseProfile:
|
||||
type: object
|
||||
required: [warehouseId, warehouseCode, warehouseName]
|
||||
properties:
|
||||
warehouseId: { type: string, pattern: '^\d+$' }
|
||||
warehouseCode: { type: string }
|
||||
warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 }
|
||||
ReceivingCandidate:
|
||||
type: object
|
||||
required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount]
|
||||
@@ -777,6 +790,12 @@ components:
|
||||
- type: object
|
||||
required: [data]
|
||||
properties: { data: { type: array, items: { $ref: '#/components/schemas/ReceivingCandidate' } } }
|
||||
ReceivingWarehouseProfileApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
- type: object
|
||||
required: [data]
|
||||
properties: { data: { $ref: '#/components/schemas/ReceivingWarehouseProfile' } }
|
||||
ReceivingSessionApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
|
||||
+3
-2
@@ -124,6 +124,7 @@ class ReceivingContainerContractTest {
|
||||
String yaml = resource("openapi/overseas-business-v1.yaml");
|
||||
String prefix = "/api/pda/v1/last-leg/receiving-containers";
|
||||
assertTrue(yaml.contains(prefix + "/candidates:"));
|
||||
assertTrue(yaml.contains(prefix + "/warehouse-profile:"));
|
||||
assertTrue(yaml.contains(prefix + "/current/receipts/bulk:"));
|
||||
assertTrue(yaml.contains(prefix + "/current/skus/resolve:"));
|
||||
assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
|
||||
@@ -133,8 +134,8 @@ class ReceivingContainerContractTest {
|
||||
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
|
||||
List<String> successResponses = receivingPaths.lines().map(String::trim)
|
||||
.filter(line -> line.startsWith("responses: { '200':")).toList();
|
||||
assertEquals(16, successResponses.size());
|
||||
assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count());
|
||||
assertEquals(17, successResponses.size());
|
||||
assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count());
|
||||
assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count());
|
||||
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
|
||||
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
||||
|
||||
+11
@@ -45,6 +45,17 @@ class ReceivingContainerControllerTest {
|
||||
verify(service).candidates("42", 900L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void warehouseProfileUsesOnlyTheTokenBoundWarehouse() {
|
||||
when(service.warehouseProfile(900L)).thenReturn(
|
||||
new ReceivingContainerResponses.WarehouseProfile("900", "US-LAX-01", "洛杉矶一号仓"));
|
||||
|
||||
var response = controller.warehouseProfile(picker());
|
||||
|
||||
assertThat(response.data().warehouseName()).isEqualTo("洛杉矶一号仓");
|
||||
verify(service).warehouseProfile(900L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() {
|
||||
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB",
|
||||
|
||||
@@ -6,12 +6,19 @@ import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-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', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
||||
const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
|
||||
const MODULE_DEPENDENCIES = new Map([
|
||||
['suppliers', ['merchant-qualification-fixture']],
|
||||
['supplemental', ['merchant-qualification-fixture']],
|
||||
['orders', ['merchant-qualification-fixture']],
|
||||
['inbound-measurement', ['orders']],
|
||||
['manual-order-splitting-fixture', ['inbound-measurement']],
|
||||
['phase3-container-loading', ['inbound-measurement']],
|
||||
['phase3-loading', ['phase3-container-loading']],
|
||||
['phase3-container-overview', ['phase3-loading']],
|
||||
@@ -243,6 +250,12 @@ function usage() {
|
||||
--channel-code <code> 固定物流渠道编码
|
||||
--order-profile-mode <mode> 订单档案模式:fixed 或 randomized,默认fixed
|
||||
--wait-split-count <n> 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1
|
||||
--manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用
|
||||
--manual-split-plan-id <id> 人工操作产生的智能装柜计划 ID,仅只读核验模块使用
|
||||
--manual-preload-source-report <path> 人工推送预装前置或核验来源报告路径
|
||||
--manual-pda-source-report <path> 人工订舱关联、预装确认前置或核验来源报告路径
|
||||
--manual-pda-batch-source-report <path> PDA 人工领取、扫码批量提交前置或核验来源报告路径
|
||||
--manual-loading-complete-source-report <path> 五类证据与 PDA 最终完成前置或核验来源报告路径
|
||||
--output <path> JSON 报告路径,默认 reports/
|
||||
--help 显示帮助
|
||||
|
||||
@@ -267,9 +280,9 @@ export function parseArguments(argv) {
|
||||
values.set(name, next); index += 1
|
||||
}
|
||||
}
|
||||
const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean))
|
||||
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean))
|
||||
if (merchantUsername && merchantPassword) {
|
||||
const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
|
||||
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
|
||||
@@ -287,12 +300,21 @@ export function parseArguments(argv) {
|
||||
channelName: values.get('channel-name') || '',
|
||||
channelCode: values.get('channel-code') || '',
|
||||
orderProfileMode: values.get('order-profile-mode') || 'fixed',
|
||||
waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || 1),
|
||||
waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT
|
||||
|| (modules.includes('manual-order-splitting-fixture') ? 6 : 1)),
|
||||
manualSplitFixtureReport: values.get('manual-split-fixture-report') || '',
|
||||
manualSplitPlanId: values.get('manual-split-plan-id') || '',
|
||||
manualPreloadSourceReport: values.get('manual-preload-source-report') || '',
|
||||
manualPdaSourceReport: values.get('manual-pda-source-report') || '',
|
||||
manualPdaBatchSourceReport: values.get('manual-pda-batch-source-report') || '',
|
||||
manualLoadingCompleteSourceReport: values.get('manual-loading-complete-source-report') || '',
|
||||
output: values.get('output') || '',
|
||||
}
|
||||
}
|
||||
|
||||
export function validateExecutionOptions(options) {
|
||||
const unavailable = options.modules.filter((module) => !AVAILABLE_MODULES.has(module))
|
||||
if (unavailable.length > 0) throw new Error(`未注册的 HTTP 验收模块: ${unavailable.join(',')}`)
|
||||
for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) {
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`)
|
||||
}
|
||||
@@ -321,9 +343,39 @@ export function validateExecutionOptions(options) {
|
||||
throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码')
|
||||
}
|
||||
if (options.waitSplitCount > 1) {
|
||||
const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement'])
|
||||
const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
||||
'manual-order-splitting-fixture'])
|
||||
if (options.modules.some((module) => !fixtureModules.has(module))) {
|
||||
throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement')
|
||||
throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement、manual-order-splitting-fixture')
|
||||
}
|
||||
}
|
||||
if (options.modules.includes('manual-order-splitting-fixture') && options.waitSplitCount < 4) {
|
||||
throw new Error('manual-order-splitting-fixture要求wait-split-count至少为4')
|
||||
}
|
||||
if (options.modules.includes('manual-order-splitting-verify')) {
|
||||
if (!options.manualSplitFixtureReport) throw new Error('manual-order-splitting-verify缺少manual-split-fixture-report')
|
||||
if (!/^[1-9][0-9]*$/.test(String(options.manualSplitPlanId))) {
|
||||
throw new Error('manual-order-splitting-verify要求manual-split-plan-id为正整数')
|
||||
}
|
||||
}
|
||||
for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) {
|
||||
if (options.modules.includes(module) && !options.manualPreloadSourceReport) {
|
||||
throw new Error(`${module}缺少manual-preload-source-report`)
|
||||
}
|
||||
}
|
||||
for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) {
|
||||
if (options.modules.includes(module) && !options.manualPdaSourceReport) {
|
||||
throw new Error(`${module}缺少manual-pda-source-report`)
|
||||
}
|
||||
}
|
||||
for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) {
|
||||
if (options.modules.includes(module) && !options.manualPdaBatchSourceReport) {
|
||||
throw new Error(`${module}缺少manual-pda-batch-source-report`)
|
||||
}
|
||||
}
|
||||
for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) {
|
||||
if (options.modules.includes(module) && !options.manualLoadingCompleteSourceReport) {
|
||||
throw new Error(`${module}缺少manual-loading-complete-source-report`)
|
||||
}
|
||||
}
|
||||
return options
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
|
||||
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
|
||||
|
||||
人工分柜采用两个可选模块,不进入默认全量入口:`manual-order-splitting-fixture.mjs` 基于本轮至少四票 `WAIT_SPLIT` 数据生成部分分配、整票拼柜和未分配对照清单;人工在 Admin 完成计划分配后,`manual-order-splitting-verify.mjs` 读取准备报告和 `loadingPlanId`,只读核对柜次范围、计划箱数及候选余量。完整命令见同一指南的“人工分柜验收”章节。
|
||||
|
||||
人工分柜通过后可继续执行 `manual-preload-push-fixture.mjs`:它读取分柜核验报告并冻结推送前计划版本、明细及无号模拟柜状态;人工点击“推送预装”后,再由 `manual-preload-push-verify.mjs` 只读核对 `EDITING` 预装清单、PENDING bridge、版本递增、明细零漂移和 PDA `A0404`。两个模块同样不进入默认全量入口。
|
||||
|
||||
预装推送通过后,`manual-pda-ready-fixture.mjs` 冻结无号、未关联订舱、不可确认且无 PDA 的基线;人工依次完成订舱 DRAFT、RELEASED 放舱、原模拟柜关联真实柜号和整份计划确认,再由 `manual-pda-ready-verify.mjs` 只读核对 RELEASED、allocation、稳定柜次/计划身份、CONFIRMED readiness 和唯一 `UNASSIGNED` PDA task。两个模块不领取任务,也不进入默认全量入口。
|
||||
|
||||
PDA 待领取通过后,`manual-pda-batch-fixture.mjs` 冻结任务明细、稳定箱码和库存快照基线;目标 PDA 作业员人工领取、逐箱扫码并一次提交原子 batch 后,`manual-pda-batch-verify.mjs` 只读核对作业员绑定、全量 PICKED、`AWAITING_REVIEW`、一次 CLAIM、一次 BATCH、pending/detail 一致和入库级数量收敛。HTTP 不记录逐次扫码轨迹,真机扫码仍需人工证据。
|
||||
|
||||
PDA 待复核通过后,`manual-loading-complete-fixture.mjs` 冻结无证据、未完成、RELEASED 订舱和 LOADING 计划基线;人工现场上传五类证据并执行一次最终完成后,`manual-loading-complete-verify.mjs` 只读核对五类真实图片、唯一 COMPLETE_LOADING、任务/计划 COMPLETED、来源入库单和统一订单 LOADED、实装分片数量守恒及货柜总览终态。
|
||||
|
||||
例如 `--modules system,inbound-weight-ocr` 会依次加载:
|
||||
|
||||
- `system.mjs`
|
||||
@@ -51,6 +61,11 @@ export async function run(context) {
|
||||
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||
| 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` |
|
||||
| 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` |
|
||||
| 人工订舱关联、预装确认与 PDA 待领取核验 | `manual-pda-ready-fixture.mjs`、`manual-pda-ready-verify.mjs`(可选,不进入默认全量) | `manual-pda-ready-contract.test.mjs` |
|
||||
| PDA 人工领取、逐箱扫码、原子批量提交与待复核核验 | `manual-pda-batch-fixture.mjs`、`manual-pda-batch-verify.mjs`(可选,不进入默认全量) | `manual-pda-batch-contract.test.mjs` |
|
||||
| 五类装柜证据、PDA 最终完成与 LOADED 终态核验 | `manual-loading-complete-fixture.mjs`、`manual-loading-complete-verify.mjs`(可选,不进入默认全量) | `manual-loading-complete-contract.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||
| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` |
|
||||
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const fixture = readFileSync(new URL('./manual-loading-complete-fixture.mjs', import.meta.url), 'utf8')
|
||||
const verify = readFileSync(new URL('./manual-loading-complete-verify.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('manual loading-complete modules are optional and require a source report', () => {
|
||||
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-loading-complete-fixture'[^\]]*'manual-loading-complete-verify'/s)
|
||||
const defaults = parseArguments([]).modules
|
||||
assert.equal(defaults.includes('manual-loading-complete-fixture'), false)
|
||||
assert.equal(defaults.includes('manual-loading-complete-verify'), false)
|
||||
for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) {
|
||||
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
|
||||
'--password', 'password-value', '--modules', module,
|
||||
'--manual-loading-complete-source-report', 'source.json'])
|
||||
assert.deepEqual(options.modules, [module])
|
||||
assert.equal(validateExecutionOptions(options), options)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, manualLoadingCompleteSourceReport: '' }),
|
||||
/manual-loading-complete-source-report/)
|
||||
}
|
||||
})
|
||||
|
||||
test('completion preparation is read only and freezes evidence, task, plan and booking gates', () => {
|
||||
assert.match(fixture, /manualPdaBatchVerification/)
|
||||
assert.match(fixture, /PHOTO_STEP_1.*PHOTO_STEP_2.*PHOTO_STEP_3.*PHOTO_STEP_4.*SEAL/)
|
||||
assert.match(fixture, /activeEvidence\.length === 0/)
|
||||
assert.match(fixture, /finalCompletionStatus === 'IN_PROGRESS'/)
|
||||
assert.match(fixture, /oceanBooking\?\.status === 'RELEASED'/)
|
||||
assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
|
||||
test('completion verifier proves five images, one completion audit and LOADED projections without writes', () => {
|
||||
assert.match(verify, /task\?\.taskStatus === 'COMPLETED'/)
|
||||
assert.match(verify, /activeEvidence\.length === fixture\.requiredEvidenceTypes\.length/)
|
||||
assert.match(verify, /controlledImage\(download\)/)
|
||||
assert.match(verify, /completeOperations\.length === 1/)
|
||||
assert.match(verify, /plan\?\.status === 'COMPLETED'/)
|
||||
assert.match(verify, /processStatus === 'LOADED'/)
|
||||
assert.match(verify, /mainStatus === 'LOADED'/)
|
||||
assert.match(verify, /splitRole === 'LOADED'/)
|
||||
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-loading-complete-fixture'
|
||||
const OPTIONS = { scenario: 'manual-loading-complete-fixture', deploymentBlocker: true }
|
||||
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const REQUIRED_EVIDENCE_TYPES = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
|
||||
async function loadSource(context) {
|
||||
const report = JSON.parse(await readFile(resolve(context.options.manualLoadingCompleteSourceReport), 'utf8'))
|
||||
const source = report?.createdResources?.manualPdaBatchVerification
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const valid = sourceGateway === context.options.gateway && source?.stage === 'PDA_AWAITING_REVIEW'
|
||||
&& source?.pdaTaskId && source?.loadingPlanId && source?.containerBookingId
|
||||
context.assert(MODULE, '读取同环境 PDA 待复核核验报告', null, valid,
|
||||
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(source?.pdaTaskId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return valid ? source : null
|
||||
}
|
||||
|
||||
function taskItems(value) {
|
||||
return rows(value).map((item) => ({ id: id(item.id), tenantId: id(item.tenantId),
|
||||
inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId),
|
||||
plannedPackageCount: Number(item.plannedPackageCount), pickedPackageCount: Number(item.pickedPackageCount),
|
||||
itemStatus: item.itemStatus, version: Number(item.version) }))
|
||||
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
function sourceInboundSnapshots(items) {
|
||||
const values = new Map()
|
||||
for (const item of rows(items)) {
|
||||
const inboundId = id(item.inboundId)
|
||||
const current = values.get(inboundId) || { tenantId: id(item.tenantId), inboundId, pickedPackageCount: 0 }
|
||||
current.pickedPackageCount += Number(item.pickedPackageCount || 0)
|
||||
values.set(inboundId, current)
|
||||
}
|
||||
return [...values.values()]
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const source = await loadSource(context)
|
||||
if (!source) return
|
||||
const taskId = id(source.pdaTaskId)
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, '最终完成前读取 PDA 待复核任务', taskResponse, '', OPTIONS)
|
||||
const beforeItems = taskItems(task?.items)
|
||||
const evidenceOwner = rows(task?.items).find((item) => id(item.inboundId) === id(task?.inboundId))
|
||||
|| rows(task?.items)[0]
|
||||
context.assert(MODULE, '任务全部拣齐、待复核且证据归属入库单明确', taskResponse,
|
||||
id(task?.id) === taskId && task?.taskStatus === 'LOADING'
|
||||
&& task?.workflowStatus === 'AWAITING_REVIEW' && task?.assignment?.assigneeId
|
||||
&& beforeItems.length > 0 && beforeItems.every((item) => item.itemStatus === 'PICKED'
|
||||
&& item.pickedPackageCount === item.plannedPackageCount)
|
||||
&& evidenceOwner?.tenantId && evidenceOwner?.inboundId,
|
||||
`workflowStatus=${task?.workflowStatus || 'missing'}, items=${beforeItems.length}, evidenceTenantId=${id(evidenceOwner?.tenantId) || 'missing'}, evidenceInboundId=${id(evidenceOwner?.inboundId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
if (!evidenceOwner?.tenantId || !evidenceOwner?.inboundId) return
|
||||
|
||||
const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(evidenceOwner.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(evidenceOwner.tenantId)}`
|
||||
const evidenceResponse = await context.request('GET', evidencePath)
|
||||
const evidence = context.apiSuccess(MODULE, '上传五类证据前读取空基线', evidenceResponse, '', OPTIONS)
|
||||
const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID')
|
||||
context.assert(MODULE, '本轮最终完成前尚未上传 ACTIVE 装柜证据', evidenceResponse,
|
||||
activeEvidence.length === 0,
|
||||
`activeEvidence=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}`, OPTIONS)
|
||||
|
||||
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(source.loadingPlanId)}`)
|
||||
const plan = context.apiSuccess(MODULE, '最终完成前读取运行中装柜计划', planResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '计划保持 LOADING 且尚未最终完成', planResponse,
|
||||
id(plan?.id) === id(source.loadingPlanId) && plan?.status === 'LOADING'
|
||||
&& plan?.finalCompletionStatus === 'IN_PROGRESS',
|
||||
`status=${plan?.status || 'missing'}, finalCompletionStatus=${plan?.finalCompletionStatus || 'missing'}, version=${plan?.version ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`)
|
||||
const operations = context.apiSuccess(MODULE, '最终完成前读取任务审计', operationsResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '完成前只有一次 CLAIM/BATCH 且无 COMPLETE_LOADING', operationsResponse,
|
||||
rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1
|
||||
&& rows(operations).filter((item) => item.actionType === 'BATCH').length === 1
|
||||
&& !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
|
||||
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS)
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(source.containerBookingId)}`)
|
||||
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)
|
||||
const bookingResponse = container?.oceanBookingId
|
||||
? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`)
|
||||
: containerResponse
|
||||
const booking = context.apiSuccess(MODULE, '最终完成前回查订舱门禁', bookingResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '完成前真实柜号与 RELEASED 订舱仍有效', bookingResponse,
|
||||
id(container?.id) === id(source.containerBookingId) && container?.containerNo === source.containerNo
|
||||
&& booking?.oceanBooking?.status === 'RELEASED',
|
||||
`containerNo=${container?.containerNo || 'missing'}, oceanBookingId=${id(container?.oceanBookingId) || 'missing'}, bookingStatus=${booking?.oceanBooking?.status || 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
context.createdResources.manualLoadingCompleteFixture = {
|
||||
schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(),
|
||||
stage: 'MANUAL_EVIDENCE_AND_COMPLETE_READY', pdaTaskId: taskId,
|
||||
loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId),
|
||||
cabinetNo: source.cabinetNo, containerNo: source.containerNo, warehouseId: id(source.warehouseId),
|
||||
evidenceTenantId: id(evidenceOwner.tenantId), evidenceInboundId: id(evidenceOwner.inboundId),
|
||||
requiredEvidenceTypes: REQUIRED_EVIDENCE_TYPES, beforeTaskVersion: Number(task.version),
|
||||
beforePlanVersion: Number(plan?.version), beforeItems,
|
||||
sourceInbounds: sourceInboundSnapshots(task.items),
|
||||
instructions: [
|
||||
'在 PDA 当前任务中依次现场拍摄空仓、半仓、满仓、锁仓和带锁码照五类证据。',
|
||||
'五类分别对应 PHOTO_STEP_1、PHOTO_STEP_2、PHOTO_STEP_3、PHOTO_STEP_4、SEAL;SEAL 必须填写非空锁码。',
|
||||
'确认五类证据均为 ACTIVE 后,只点击一次最终完成;不要调用旧入库单兼容完成入口。',
|
||||
'完成成功后任务、计划和所有来源入库单应进入唯一终态,立即运行 manual-loading-complete-verify。',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-loading-complete-verify'
|
||||
const OPTIONS = { scenario: 'manual-loading-complete-readonly-verification', deploymentBlocker: true }
|
||||
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
|
||||
async function loadFixture(context) {
|
||||
const report = JSON.parse(await readFile(resolve(context.options.manualLoadingCompleteSourceReport), 'utf8'))
|
||||
const fixture = report?.createdResources?.manualLoadingCompleteFixture
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1
|
||||
&& fixture?.stage === 'MANUAL_EVIDENCE_AND_COMPLETE_READY' && fixture?.pdaTaskId
|
||||
&& Array.isArray(fixture?.requiredEvidenceTypes) && fixture.requiredEvidenceTypes.length === 5
|
||||
&& Array.isArray(fixture?.sourceInbounds) && fixture.sourceInbounds.length > 0
|
||||
&& fixture?.evidenceTenantId && fixture?.evidenceInboundId
|
||||
context.assert(MODULE, '读取同环境装柜最终完成准备报告', null, valid,
|
||||
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return valid ? fixture : null
|
||||
}
|
||||
|
||||
function itemIdentity(value) {
|
||||
return { id: id(value.id), tenantId: id(value.tenantId), inboundId: id(value.inboundId),
|
||||
inboundDetailId: id(value.inboundDetailId), plannedPackageCount: Number(value.plannedPackageCount),
|
||||
pickedPackageCount: Number(value.pickedPackageCount), itemStatus: value.itemStatus }
|
||||
}
|
||||
|
||||
function controlledImage(download) {
|
||||
const type = String(download?.contentType || '').toLowerCase()
|
||||
return download?.http === 200 && download?.bytes?.length > 0
|
||||
&& type.startsWith('image/') && !type.includes('json') && !type.includes('html')
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = await loadFixture(context)
|
||||
if (!fixture) return
|
||||
const taskId = id(fixture.pdaTaskId)
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, '读取 PDA 最终完成任务', taskResponse, '', OPTIONS)
|
||||
const afterItems = rows(task?.items).map(itemIdentity)
|
||||
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
|
||||
const beforeItems = fixture.beforeItems.map(itemIdentity)
|
||||
context.assert(MODULE, '最终完成后任务与 workflow 同为 COMPLETED 且明细身份不漂移', taskResponse,
|
||||
id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId)
|
||||
&& id(task?.containerBookingId) === id(fixture.containerBookingId)
|
||||
&& task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED'
|
||||
&& Number(task?.version) > Number(fixture.beforeTaskVersion)
|
||||
&& JSON.stringify(afterItems) === JSON.stringify(beforeItems),
|
||||
`taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(fixture.evidenceInboundId)}/loading/evidence?tenantId=${encodeURIComponent(fixture.evidenceTenantId)}`
|
||||
const evidenceResponse = await context.request('GET', evidencePath)
|
||||
const evidence = context.apiSuccess(MODULE, '读取 LOADED 后五类不可变证据', evidenceResponse, '', OPTIONS)
|
||||
const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID')
|
||||
const evidenceComplete = fixture.requiredEvidenceTypes.every((type) =>
|
||||
activeEvidence.filter((item) => item.evidenceType === type).length === 1)
|
||||
&& activeEvidence.length === fixture.requiredEvidenceTypes.length
|
||||
&& activeEvidence.some((item) => item.evidenceType === 'SEAL' && String(item.sealNo || '').trim())
|
||||
context.assert(MODULE, '五类 ACTIVE 证据各一份且 SEAL 锁码有效', evidenceResponse,
|
||||
evidenceComplete,
|
||||
`types=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}, sealNo=${activeEvidence.find((item) => item.evidenceType === 'SEAL')?.sealNo || 'missing'}`,
|
||||
OPTIONS)
|
||||
for (const item of activeEvidence) {
|
||||
const path = String(item.previewUrl || '')
|
||||
const controlled = path.startsWith('/api/') && !path.startsWith('//') && !path.includes('://')
|
||||
const download = controlled ? await context.requestBinary('GET', path) : null
|
||||
context.assert(MODULE, `${item.evidenceType} 证据可鉴权下载真实图片`, download,
|
||||
controlled && controlledImage(download),
|
||||
`evidenceId=${id(item.id)}, path=${path || 'missing'}, bytes=${download?.bytes?.length ?? 0}, contentType=${download?.contentType || 'missing'}`,
|
||||
OPTIONS)
|
||||
}
|
||||
|
||||
const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`)
|
||||
const operations = context.apiSuccess(MODULE, '读取最终完成不可变操作审计', operationsResponse, '', OPTIONS)
|
||||
const completeOperations = rows(operations).filter((item) => item.actionType === 'COMPLETE_LOADING')
|
||||
context.assert(MODULE, '最终主链恰有一次 COMPLETE_LOADING 且保留原 CLAIM/BATCH', operationsResponse,
|
||||
rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1
|
||||
&& rows(operations).filter((item) => item.actionType === 'BATCH').length === 1
|
||||
&& completeOperations.length === 1 && completeOperations[0]?.operationId
|
||||
&& completeOperations[0]?.operatorId && completeOperations[0]?.operatorName
|
||||
&& completeOperations[0]?.createdAt,
|
||||
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, completeCount=${completeOperations.length}`,
|
||||
OPTIONS)
|
||||
|
||||
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(fixture.loadingPlanId)}`)
|
||||
const plan = context.apiSuccess(MODULE, '读取最终完成装柜计划', planResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '原装柜计划进入 COMPLETED 且版本递增', planResponse,
|
||||
id(plan?.id) === id(fixture.loadingPlanId) && plan?.status === 'COMPLETED'
|
||||
&& Number(plan?.version) > Number(fixture.beforePlanVersion),
|
||||
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const orderFacts = []
|
||||
for (const source of fixture.sourceInbounds || []) {
|
||||
const inboundResponse = await context.request('GET',
|
||||
`/api/platform/inbounds/${encodeURIComponent(source.inboundId)}?tenantId=${encodeURIComponent(source.tenantId)}`)
|
||||
const inbound = context.apiSuccess(MODULE, `${source.inboundId} 回查 LOADED 入库终态`, inboundResponse, '', OPTIONS)
|
||||
const orderId = id(inbound?.summary?.orderId)
|
||||
context.assert(MODULE, `${source.inboundId} 入库状态和完成历史正确`, inboundResponse,
|
||||
inbound?.summary?.processStatus === 'LOADED' && orderId
|
||||
&& rows(inbound?.statusHistory).some((item) => item.action === 'COMPLETE_LOADING'
|
||||
&& item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'
|
||||
&& item.operatorName),
|
||||
`status=${inbound?.summary?.processStatus || 'missing'}, orderId=${orderId || 'missing'}, histories=${rows(inbound?.statusHistory).length}`,
|
||||
OPTIONS)
|
||||
if (!orderId) continue
|
||||
const orderResponse = await context.request('GET',
|
||||
`/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(source.tenantId)}`)
|
||||
const order = context.apiSuccess(MODULE, `${orderId} 回查统一订单终态`, orderResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${orderId} 统一订单详情投影为 LOADED`, orderResponse,
|
||||
id(order?.summary?.orderId) === orderId && order?.summary?.mainStatus === 'LOADED',
|
||||
`mainStatus=${order?.summary?.mainStatus || 'missing'}, orderNo=${order?.summary?.orderNo || 'missing'}`,
|
||||
OPTIONS)
|
||||
orderFacts.push({ tenantId: id(source.tenantId), inboundId: id(source.inboundId), orderId,
|
||||
orderNo: order?.summary?.orderNo || null })
|
||||
}
|
||||
|
||||
const tenantId = id(fixture.sourceInbounds?.[0]?.tenantId)
|
||||
const slicesResponse = await context.request('GET',
|
||||
`/api/platform/v1/orders/container-bookings/${encodeURIComponent(fixture.containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const slices = context.apiSuccess(MODULE, '回查柜次最终实装分片', slicesResponse, '', OPTIONS)
|
||||
const loadedSlices = rows(slices).filter((item) => item.splitRole === 'LOADED'
|
||||
&& id(item.containerBookingId) === id(fixture.containerBookingId)
|
||||
&& id(item.loadingPlanId) === id(fixture.loadingPlanId))
|
||||
const pickedPackages = fixture.sourceInbounds.reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0)
|
||||
context.assert(MODULE, '最终实装分片唯一且箱数等于 PDA 已拣总数', slicesResponse,
|
||||
loadedSlices.length > 0
|
||||
&& new Set(loadedSlices.map((item) => id(item.sourceOrderId))).size === loadedSlices.length
|
||||
&& loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0) === pickedPackages,
|
||||
`loadedSlices=${loadedSlices.length}, loadedPackages=${loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)}, pickedPackages=${pickedPackages}`,
|
||||
OPTIONS)
|
||||
|
||||
const overviewResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(fixture.containerBookingId)}/overview`)
|
||||
const overview = context.apiSuccess(MODULE, '回查货柜最终总览', overviewResponse, '', OPTIONS)
|
||||
const photoCounts = new Map(rows(overview?.pda?.photoCounts).map((item) => [item.evidenceType, Number(item.count)]))
|
||||
context.assert(MODULE, '货柜总览汇总 COMPLETED PDA 和五类 ACTIVE 证据', overviewResponse,
|
||||
id(overview?.loadingPlanId) === id(fixture.loadingPlanId)
|
||||
&& id(overview?.pda?.taskId) === taskId
|
||||
&& overview?.pda?.taskStatus === 'COMPLETED' && overview?.pda?.workflowStatus === 'COMPLETED'
|
||||
&& fixture.requiredEvidenceTypes.every((type) => Number(photoCounts.get(type)) >= 1)
|
||||
&& rows(overview?.pda?.photos).filter((item) => item.status === 'ACTIVE').length === 5,
|
||||
`taskStatus=${overview?.pda?.taskStatus || 'missing'}, workflowStatus=${overview?.pda?.workflowStatus || 'missing'}, photoCounts=${JSON.stringify(Object.fromEntries(photoCounts))}`,
|
||||
OPTIONS)
|
||||
|
||||
context.createdResources.manualLoadingCompleteVerification = {
|
||||
sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'LOADED',
|
||||
pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId),
|
||||
containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo,
|
||||
containerNo: fixture.containerNo, completeOperationId: completeOperations[0]?.operationId || null,
|
||||
evidenceInboundId: id(fixture.evidenceInboundId), evidenceIds: activeEvidence.map((item) => id(item.id)),
|
||||
loadedSliceCount: loadedSlices.length, loadedPackageCount: pickedPackages, orders: orderFacts,
|
||||
terminalNote: '国内装柜人工验收链已到 LOADED;后续报关、国内出仓和海外接柜属于后续节点。',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const fixture = readFileSync(new URL('./manual-order-splitting-fixture.mjs', import.meta.url), 'utf8')
|
||||
const verify = readFileSync(new URL('./manual-order-splitting-verify.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('manual splitting modules are optional registered entries with a WAIT_SPLIT producer dependency', () => {
|
||||
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-order-splitting-fixture'[^\]]*'manual-order-splitting-verify'/s)
|
||||
assert.match(entry, /\['manual-order-splitting-fixture', \['inbound-measurement'\]\]/)
|
||||
assert.match(entry, /--manual-split-fixture-report/)
|
||||
assert.match(entry, /--manual-split-plan-id/)
|
||||
assert.match(entry, /manual-order-splitting-fixture[^\n]+waitSplitCount < 4/)
|
||||
})
|
||||
|
||||
test('fixture defaults to six orders while verifier requires an explicit report and plan id', () => {
|
||||
const defaultModules = parseArguments([]).modules
|
||||
assert.equal(defaultModules.includes('manual-order-splitting-fixture'), false)
|
||||
assert.equal(defaultModules.includes('manual-order-splitting-verify'), false)
|
||||
const fixtureOptions = parseArguments([
|
||||
'--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value',
|
||||
'--modules', 'manual-order-splitting-fixture',
|
||||
])
|
||||
assert.deepEqual(fixtureOptions.modules, [
|
||||
'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'manual-order-splitting-fixture',
|
||||
])
|
||||
assert.equal(fixtureOptions.waitSplitCount, 6)
|
||||
assert.equal(validateExecutionOptions(fixtureOptions), fixtureOptions)
|
||||
|
||||
const verifyOptions = parseArguments([
|
||||
'--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value',
|
||||
'--modules', 'manual-order-splitting-verify', '--manual-split-fixture-report', 'fixture.json',
|
||||
'--manual-split-plan-id', '123',
|
||||
])
|
||||
assert.deepEqual(verifyOptions.modules, ['manual-order-splitting-verify'])
|
||||
assert.equal(validateExecutionOptions(verifyOptions), verifyOptions)
|
||||
assert.throws(() => validateExecutionOptions({ ...verifyOptions, manualSplitPlanId: '' }),
|
||||
/manual-split-plan-id/)
|
||||
assert.throws(() => validateExecutionOptions({ ...fixtureOptions, waitSplitCount: 3 }),
|
||||
/wait-split-count至少为4/)
|
||||
})
|
||||
|
||||
test('fixture prepares a partial, two full and one untouched scenario without performing split writes', () => {
|
||||
assert.match(fixture, /role: 'PARTIAL'/)
|
||||
assert.match(fixture, /role: `FULL_\$\{index \+ 1\}`/)
|
||||
assert.match(fixture, /role: 'UNASSIGNED_CONTROL'/)
|
||||
assert.match(fixture, /WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT/)
|
||||
assert.doesNotMatch(fixture, /context\.request\(['"]POST/)
|
||||
})
|
||||
|
||||
test('verifier is read only and checks plan, container, allocations and remaining candidates', () => {
|
||||
assert.match(verify, /context\.request\('GET', `\$\{SMART\}\/plans/)
|
||||
assert.match(verify, /context\.request\('GET', `\$\{CONTAINERS\}/)
|
||||
assert.match(verify, /inbound-candidates\?bookingId=/)
|
||||
assert.match(verify, /sourceGateway[\s\S]*sameGateway/)
|
||||
assert.match(verify, /候选列表未出现异渠道或异仓入库单/)
|
||||
assert.match(verify, /expectedAllocatedPackageCount/)
|
||||
assert.match(verify, /expectedRemainingPackageCount/)
|
||||
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
const MODULE = 'manual-order-splitting-fixture'
|
||||
const OPTIONS = { scenario: 'manual-order-splitting-fixture', deploymentBlocker: true }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function positiveInteger(value) { return Number.isInteger(Number(value)) && Number(value) > 0 }
|
||||
|
||||
function normalizedFixture(value) {
|
||||
return {
|
||||
sequence: Number(value.sequence), tenantId: id(value.tenantId), orderId: id(value.orderId),
|
||||
orderNo: value.orderNo || null, inboundId: id(value.inboundId), inboundCode: value.inboundCode || null,
|
||||
warehouseId: id(value.receivingWarehouseId), warehouseName: value.receivingWarehouseName || null,
|
||||
channelCode: value.channelCode || null, channelName: value.channelName || null,
|
||||
totalPackageCount: Number(value.totalPackageCount), destinationType: value.destinationType || null,
|
||||
fulfillmentType: value.fulfillmentType || null, containerMode: value.containerMode || null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixtures = (context.createdResources?.waitSplitFixtures || []).map(normalizedFixture)
|
||||
const valid = fixtures.length >= 4 && fixtures.every((item) => item.tenantId && item.orderId
|
||||
&& item.inboundId && item.warehouseId && item.channelCode && positiveInteger(item.totalPackageCount))
|
||||
context.assert(MODULE, '至少取得四票本轮 WAIT_SPLIT 人工分柜数据', null, valid,
|
||||
`count=${fixtures.length}, invalid=${fixtures.filter((item) => !item.tenantId || !item.orderId
|
||||
|| !item.inboundId || !item.warehouseId || !item.channelCode || !positiveInteger(item.totalPackageCount)).length}`,
|
||||
OPTIONS)
|
||||
if (!valid) return
|
||||
|
||||
const tenantIds = new Set(fixtures.map((item) => item.tenantId))
|
||||
const warehouseIds = new Set(fixtures.map((item) => item.warehouseId))
|
||||
const channelCodes = new Set(fixtures.map((item) => item.channelCode))
|
||||
const sameScope = tenantIds.size === 1 && warehouseIds.size === 1 && channelCodes.size === 1
|
||||
context.assert(MODULE, '人工分柜正向样本固定同一客户、起运仓和渠道', null, sameScope,
|
||||
`tenantIds=${[...tenantIds].join(',')}, warehouseIds=${[...warehouseIds].join(',')}, channelCodes=${[...channelCodes].join(',')}`,
|
||||
OPTIONS)
|
||||
if (!sameScope) return
|
||||
|
||||
const byPackages = [...fixtures].sort((left, right) => right.totalPackageCount - left.totalPackageCount
|
||||
|| left.sequence - right.sequence)
|
||||
const partial = byPackages.find((item) => item.totalPackageCount >= 2)
|
||||
const remaining = fixtures.filter((item) => item.inboundId !== partial?.inboundId)
|
||||
const full = remaining.slice(0, 2)
|
||||
const control = remaining[2]
|
||||
const scenarioReady = Boolean(partial && full.length === 2 && control)
|
||||
context.assert(MODULE, '人工分柜场景包含部分分配、两票整票拼柜和未分配对照', null, scenarioReady,
|
||||
`partial=${partial?.inboundCode || 'missing'}, full=${full.map((item) => item.inboundCode).join(',') || 'missing'}, control=${control?.inboundCode || 'missing'}`,
|
||||
OPTIONS)
|
||||
if (!scenarioReady) return
|
||||
|
||||
const assignments = [
|
||||
{ role: 'PARTIAL', ...partial, expectedAllocatedPackageCount: partial.totalPackageCount - 1,
|
||||
expectedRemainingPackageCount: 1 },
|
||||
...full.map((item, index) => ({ role: `FULL_${index + 1}`, ...item,
|
||||
expectedAllocatedPackageCount: item.totalPackageCount, expectedRemainingPackageCount: 0 })),
|
||||
]
|
||||
const manifest = {
|
||||
schemaVersion: 1, marker: context.marker, stage: 'WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT',
|
||||
tenantId: fixtures[0].tenantId, warehouseId: fixtures[0].warehouseId,
|
||||
warehouseName: fixtures[0].warehouseName, channelCode: fixtures[0].channelCode,
|
||||
channelName: fixtures[0].channelName,
|
||||
adminEntry: '/container-loading',
|
||||
instructions: [
|
||||
'在智能装柜页面创建一个同仓同渠道的内部模拟柜。',
|
||||
'创建智能装柜计划,并严格按 assignments 的 expectedAllocatedPackageCount 分配。',
|
||||
'保留 unassignedControl 不加入计划,用于核对候选剩余与误选保护。',
|
||||
'人工操作后记录 loadingPlanId,并运行 manual-order-splitting-verify 只读核验。',
|
||||
],
|
||||
assignments,
|
||||
unassignedControl: { role: 'UNASSIGNED_CONTROL', ...control,
|
||||
expectedAllocatedPackageCount: 0, expectedRemainingPackageCount: control.totalPackageCount },
|
||||
allFixtures: fixtures,
|
||||
}
|
||||
context.createdResources.manualOrderSplittingFixture = manifest
|
||||
context.assert(MODULE, '人工分柜清单数量守恒', null,
|
||||
assignments.every((item) => item.totalPackageCount
|
||||
=== item.expectedAllocatedPackageCount + item.expectedRemainingPackageCount),
|
||||
`assignments=${assignments.length}, plannedPackages=${assignments.reduce((sum, item) => sum + item.expectedAllocatedPackageCount, 0)}`,
|
||||
OPTIONS)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-order-splitting-verify'
|
||||
const OPTIONS = { scenario: 'manual-order-splitting-readonly-verification', deploymentBlocker: true }
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
|
||||
async function loadManifest(context) {
|
||||
const path = resolve(context.options.manualSplitFixtureReport)
|
||||
const report = JSON.parse(await readFile(path, 'utf8'))
|
||||
const manifest = report?.createdResources?.manualOrderSplittingFixture
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const sameGateway = sourceGateway && sourceGateway === context.options.gateway
|
||||
context.assert(MODULE, '读取人工分柜准备报告', null,
|
||||
manifest?.schemaVersion === 1 && manifest?.stage === 'WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT'
|
||||
&& Array.isArray(manifest.assignments) && manifest.assignments.length === 3 && sameGateway,
|
||||
`sourceMarker=${report?.marker || 'missing'}, schemaVersion=${manifest?.schemaVersion ?? 'missing'}, assignments=${manifest?.assignments?.length ?? 'missing'}, sameGateway=${Boolean(sameGateway)}`,
|
||||
OPTIONS)
|
||||
return sameGateway ? manifest : null
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const manifest = await loadManifest(context)
|
||||
if (!manifest?.assignments?.length) return
|
||||
|
||||
const planId = id(context.options.manualSplitPlanId)
|
||||
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
|
||||
const plan = context.apiSuccess(MODULE, '读取人工操作后的智能装柜计划', planResponse,
|
||||
`loadingPlanId=${planId}`, OPTIONS)
|
||||
if (!plan) return
|
||||
|
||||
const bookingId = id(plan.bookingId || plan.containerBookingId)
|
||||
const planItems = rows(plan.items)
|
||||
context.assert(MODULE, '智能装柜计划身份与状态可继续人工验收', planResponse,
|
||||
id(plan.id) === planId && bookingId && ['EDITING', 'LOADING'].includes(plan.status),
|
||||
`planId=${id(plan.id) || 'missing'}, bookingId=${bookingId || 'missing'}, status=${plan.status || 'missing'}, version=${plan.version ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
if (!bookingId) return
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(bookingId)}`)
|
||||
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse,
|
||||
id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId)
|
||||
&& container?.channelCode === manifest.channelCode,
|
||||
`containerId=${id(container?.id) || 'missing'}, warehouseId=${id(container?.warehouseId) || 'missing'}, channelCode=${container?.channelCode || 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const expectedByInbound = new Map(manifest.assignments.map((item) => [id(item.inboundId), item]))
|
||||
for (const expected of manifest.assignments) {
|
||||
const matches = planItems.filter((item) => id(item.inboundId) === id(expected.inboundId))
|
||||
const allocated = matches.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)
|
||||
context.assert(MODULE, `${expected.role} 按清单完成计划分配`, planResponse,
|
||||
matches.length >= 1 && allocated === Number(expected.expectedAllocatedPackageCount),
|
||||
`inboundCode=${expected.inboundCode || 'missing'}, expected=${expected.expectedAllocatedPackageCount}, actual=${allocated}, itemCount=${matches.length}`,
|
||||
OPTIONS)
|
||||
}
|
||||
|
||||
const foreignItems = planItems.filter((item) => !expectedByInbound.has(id(item.inboundId)))
|
||||
const controlItems = planItems.filter((item) => id(item.inboundId) === id(manifest.unassignedControl?.inboundId))
|
||||
context.assert(MODULE, '计划未混入清单外或未分配对照入库单', planResponse,
|
||||
foreignItems.length === 0 && controlItems.length === 0,
|
||||
`foreignInboundIds=${foreignItems.map((item) => id(item.inboundId)).join(',') || 'none'}, controlItemCount=${controlItems.length}`,
|
||||
OPTIONS)
|
||||
|
||||
const candidatesPath = `${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(manifest.warehouseId)}`
|
||||
const candidatesResponse = await context.request('GET', candidatesPath)
|
||||
const candidates = context.apiSuccess(MODULE, '回查人工分柜后的入仓码候选余量', candidatesResponse, '', OPTIONS)
|
||||
const candidateRows = rows(candidates)
|
||||
const candidateScopeValid = candidateRows.every((item) => (!item.channelCode
|
||||
|| item.channelCode === manifest.channelCode)
|
||||
&& (!item.warehouseId || id(item.warehouseId) === id(manifest.warehouseId)))
|
||||
context.assert(MODULE, '候选列表未出现异渠道或异仓入库单', candidatesResponse,
|
||||
candidateScopeValid,
|
||||
`candidateCount=${candidateRows.length}, channelCode=${manifest.channelCode}, warehouseId=${manifest.warehouseId}`,
|
||||
OPTIONS)
|
||||
for (const expected of [...manifest.assignments, manifest.unassignedControl]) {
|
||||
const candidate = candidateRows.find((item) => id(item.inboundId) === id(expected.inboundId))
|
||||
const actualRemaining = candidate ? Number(candidate.availablePackageCount) : 0
|
||||
context.assert(MODULE, `${expected.role} 分配后剩余箱数正确`, candidatesResponse,
|
||||
actualRemaining === Number(expected.expectedRemainingPackageCount),
|
||||
`inboundCode=${expected.inboundCode || 'missing'}, expectedRemaining=${expected.expectedRemainingPackageCount}, actualRemaining=${actualRemaining}`,
|
||||
OPTIONS)
|
||||
}
|
||||
|
||||
context.createdResources.manualOrderSplittingVerification = {
|
||||
sourceMarker: manifest.marker, verifiedAt: new Date().toISOString(), loadingPlanId: planId,
|
||||
containerBookingId: bookingId, tenantId: id(manifest.tenantId), warehouseId: id(manifest.warehouseId),
|
||||
channelCode: manifest.channelCode, planStatus: plan.status, planVersion: Number(plan.version),
|
||||
verifiedAssignments: manifest.assignments.map((item) => ({ role: item.role, orderId: id(item.orderId),
|
||||
inboundId: id(item.inboundId), expectedAllocatedPackageCount: Number(item.expectedAllocatedPackageCount) })),
|
||||
note: '本模块只读核验计划分柜;父子订单在 PDA 实装结算后另行核验。',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const fixture = readFileSync(new URL('./manual-pda-batch-fixture.mjs', import.meta.url), 'utf8')
|
||||
const verify = readFileSync(new URL('./manual-pda-batch-verify.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('manual PDA batch modules are optional and require a source report', () => {
|
||||
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-pda-batch-fixture'[^\]]*'manual-pda-batch-verify'/s)
|
||||
const defaults = parseArguments([]).modules
|
||||
assert.equal(defaults.includes('manual-pda-batch-fixture'), false)
|
||||
assert.equal(defaults.includes('manual-pda-batch-verify'), false)
|
||||
for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) {
|
||||
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
|
||||
'--password', 'password-value', '--modules', module, '--manual-pda-batch-source-report', 'source.json'])
|
||||
assert.deepEqual(options.modules, [module])
|
||||
assert.equal(validateExecutionOptions(options), options)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, manualPdaBatchSourceReport: '' }),
|
||||
/manual-pda-batch-source-report/)
|
||||
}
|
||||
})
|
||||
|
||||
test('PDA batch preparation is read only and preserves stable carton scan instructions', () => {
|
||||
assert.match(fixture, /manualPdaReadyVerification/)
|
||||
assert.match(fixture, /boxCodes\.length >= item\.plannedPackageCount/)
|
||||
assert.match(fixture, /scanBoxCodes: rows\(item\.boxCodes\)/)
|
||||
assert.match(fixture, /inventory-snapshot/)
|
||||
assert.match(fixture, /不存在 CLAIM、BATCH、REVIEW 或 COMPLETE_LOADING/)
|
||||
assert.match(fixture, /逐次扫码动作需保留真机人工证据/)
|
||||
assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
|
||||
test('post-batch verifier proves one claim, one batch and AWAITING_REVIEW without writes', () => {
|
||||
assert.match(verify, /workflowStatus === 'AWAITING_REVIEW'/)
|
||||
assert.match(verify, /itemStatus === 'PICKED'/)
|
||||
assert.match(verify, /claimOperations\.length === 1 && batchOperations\.length === 1/)
|
||||
assert.match(verify, /pendingItemCount\) === 0/)
|
||||
assert.match(verify, /expectedCount\) === Number\(projection\?\.pickedCount\)/)
|
||||
assert.match(verify, /HTTP 不记录逐次扫码轨迹/)
|
||||
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-pda-batch-fixture'
|
||||
const OPTIONS = { scenario: 'manual-pda-batch-fixture', deploymentBlocker: true }
|
||||
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
|
||||
async function loadSource(context) {
|
||||
const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8'))
|
||||
const source = report?.createdResources?.manualPdaReadyVerification
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const valid = sourceGateway === context.options.gateway && source?.stage === 'PDA_UNASSIGNED'
|
||||
&& source?.pdaTaskId && source?.loadingPlanId && source?.containerBookingId
|
||||
context.assert(MODULE, '读取同环境 PDA 待领取核验报告', null, valid,
|
||||
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(source?.pdaTaskId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return valid ? source : null
|
||||
}
|
||||
|
||||
function taskItemSnapshot(items) {
|
||||
return rows(items).map((item) => ({
|
||||
id: id(item.id), loadingPlanItemId: id(item.loadingPlanItemId), tenantId: id(item.tenantId),
|
||||
inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId),
|
||||
orderNo: item.orderNo || null, inboundCode: item.inboundCode || null, cargoName: item.cargoName || null,
|
||||
boxCodes: rows(item.boxCodes).map(String),
|
||||
scanBoxCodes: rows(item.boxCodes).map(String).slice(0, Number(item.plannedPackageCount)),
|
||||
plannedPackageCount: Number(item.plannedPackageCount),
|
||||
})).sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const source = await loadSource(context)
|
||||
if (!source) return
|
||||
const taskId = id(source.pdaTaskId)
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, 'PDA 人工作业前读取稳定任务详情', taskResponse, '', OPTIONS)
|
||||
const taskItems = taskItemSnapshot(task?.items)
|
||||
const scanBoxCodes = taskItems.flatMap((item) => item.scanBoxCodes)
|
||||
const cartonCodesReady = taskItems.length > 0 && taskItems.every((item) => item.id && item.inboundId
|
||||
&& item.plannedPackageCount > 0 && item.boxCodes.length >= item.plannedPackageCount
|
||||
&& item.scanBoxCodes.length === item.plannedPackageCount
|
||||
&& new Set(item.boxCodes).size === item.boxCodes.length)
|
||||
&& new Set(scanBoxCodes).size === scanBoxCodes.length
|
||||
context.assert(MODULE, '待领取任务下发稳定箱码且计划箱数可逐箱扫描', taskResponse,
|
||||
id(task?.id) === taskId && id(task?.loadingPlanId) === id(source.loadingPlanId)
|
||||
&& id(task?.containerBookingId) === id(source.containerBookingId)
|
||||
&& task?.taskStatus === 'LOADING' && task?.workflowStatus === 'UNASSIGNED'
|
||||
&& task?.assignment == null && cartonCodesReady,
|
||||
`workflowStatus=${task?.workflowStatus || 'missing'}, items=${taskItems.length}, plannedPackages=${taskItems.reduce((sum, item) => sum + item.plannedPackageCount, 0)}, boxCodes=${taskItems.reduce((sum, item) => sum + item.boxCodes.length, 0)}`,
|
||||
OPTIONS)
|
||||
if (!cartonCodesReady || task?.workflowStatus !== 'UNASSIGNED') return
|
||||
|
||||
const pendingResponse = await context.request('GET',
|
||||
`${PDA}/tasks/pending?keyword=${encodeURIComponent(source.containerNo)}`)
|
||||
const pending = context.apiSuccess(MODULE, '作业前读取当前操作员 PDA 待办', pendingResponse, '', OPTIONS)
|
||||
const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId)
|
||||
context.assert(MODULE, '未领取任务在 PDA 待办中唯一可见', pendingResponse,
|
||||
Boolean(pendingTask) && pendingTask.workflowStatus === 'UNASSIGNED'
|
||||
&& Number(pendingTask.pendingItemCount) === taskItems.length,
|
||||
`matches=${rows(pending).filter((item) => id(item.taskId) === taskId).length}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const inventoryResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${encodeURIComponent(taskId)}/inventory-snapshot?warehouseId=${encodeURIComponent(source.warehouseId)}`)
|
||||
const inventory = context.apiSuccess(MODULE, '作业前读取实时库存快照基线', inventoryResponse, '', OPTIONS)
|
||||
const inventoryReady = id(inventory?.taskId) === taskId
|
||||
&& id(inventory?.loadingPlanId) === id(source.loadingPlanId)
|
||||
&& id(inventory?.containerBookingId) === id(source.containerBookingId)
|
||||
&& Number(inventory?.taskVersion) === Number(task?.version)
|
||||
&& typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64
|
||||
&& rows(inventory?.inbounds).length > 0
|
||||
context.assert(MODULE, '库存快照与待领取任务版本和四元身份一致', inventoryResponse,
|
||||
inventoryReady,
|
||||
`taskVersion=${task?.version ?? 'missing'}, snapshotTaskVersion=${inventory?.taskVersion ?? 'missing'}, inboundCount=${rows(inventory?.inbounds).length}`,
|
||||
OPTIONS)
|
||||
|
||||
const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`)
|
||||
const operations = context.apiSuccess(MODULE, '作业前读取 PDA 操作历史', operationsResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '作业前不存在 CLAIM、BATCH、REVIEW 或 COMPLETE_LOADING', operationsResponse,
|
||||
rows(operations).every((item) => !['CLAIM', 'BATCH', 'REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
|
||||
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`,
|
||||
OPTIONS)
|
||||
|
||||
context.createdResources.manualPdaBatchFixture = {
|
||||
schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(),
|
||||
stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', pdaTaskId: taskId,
|
||||
loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId),
|
||||
cabinetNo: source.cabinetNo, containerNo: source.containerNo, tenantId: id(source.tenantId),
|
||||
warehouseId: id(source.warehouseId), beforeTaskVersion: Number(task.version), taskItems,
|
||||
inventoryBaseline: rows(inventory?.inbounds).map((item) => ({ inboundId: id(item.inboundId),
|
||||
availablePackageCount: Number(item.availablePackageCount), resourceCount: rows(item.resources).length })),
|
||||
instructions: [
|
||||
'使用目标 PDA 作业员进入待办并领取本任务,确认任务进入 IN_PROGRESS。',
|
||||
'按 taskItems.scanBoxCodes 在真机逐箱扫描;每个箱码只扫描一次,处理完全部计划箱数。',
|
||||
'点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch;不要使用逐明细兼容接口。',
|
||||
'提交成功停在 AWAITING_REVIEW,不上传装柜证据、不复核、不执行最终完成。',
|
||||
'HTTP 只能核验箱码下发和服务端数量,逐次扫码动作需保留真机人工证据。',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-pda-batch-verify'
|
||||
const OPTIONS = { scenario: 'manual-pda-batch-readonly-verification', deploymentBlocker: true }
|
||||
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
|
||||
async function loadFixture(context) {
|
||||
const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8'))
|
||||
const fixture = report?.createdResources?.manualPdaBatchFixture
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1
|
||||
&& fixture?.stage === 'MANUAL_PDA_CLAIM_AND_BATCH_READY' && fixture?.pdaTaskId
|
||||
&& Array.isArray(fixture?.taskItems) && fixture.taskItems.length > 0
|
||||
context.assert(MODULE, '读取同环境 PDA 人工作业准备报告', null, valid,
|
||||
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return valid ? fixture : null
|
||||
}
|
||||
|
||||
function stableItem(value) {
|
||||
return { id: id(value.id), loadingPlanItemId: id(value.loadingPlanItemId), tenantId: id(value.tenantId),
|
||||
inboundId: id(value.inboundId), inboundDetailId: id(value.inboundDetailId),
|
||||
boxCodes: rows(value.boxCodes).map(String), plannedPackageCount: Number(value.plannedPackageCount) }
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = await loadFixture(context)
|
||||
if (!fixture) return
|
||||
const taskId = id(fixture.pdaTaskId)
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, '读取人工领取和批量提交后的 PDA 任务', taskResponse, '', OPTIONS)
|
||||
const afterItems = rows(task?.items).map(stableItem)
|
||||
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
|
||||
const beforeStable = fixture.taskItems.map(stableItem)
|
||||
const allPicked = rows(task?.items).length > 0 && rows(task?.items).every((item) =>
|
||||
item.itemStatus === 'PICKED'
|
||||
&& Number(item.pickedPackageCount) === Number(item.plannedPackageCount))
|
||||
context.assert(MODULE, '领取后任务绑定作业员并以一次批量提交进入待复核', taskResponse,
|
||||
id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId)
|
||||
&& id(task?.containerBookingId) === id(fixture.containerBookingId)
|
||||
&& task?.cabinetNo === fixture.cabinetNo && task?.currentContainerNo === fixture.containerNo
|
||||
&& task?.taskStatus === 'LOADING' && task?.workflowStatus === 'AWAITING_REVIEW'
|
||||
&& task?.assignment?.assigneeId && task?.assignment?.assigneeName
|
||||
&& Number(task?.version) > Number(fixture.beforeTaskVersion)
|
||||
&& JSON.stringify(afterItems) === JSON.stringify(beforeStable) && allPicked,
|
||||
`taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, assigneeId=${id(task?.assignment?.assigneeId) || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
if (!task || task.workflowStatus !== 'AWAITING_REVIEW') return
|
||||
|
||||
const pendingResponse = await context.request('GET',
|
||||
`${PDA}/tasks/pending?keyword=${encodeURIComponent(fixture.containerNo)}`)
|
||||
const pending = context.apiSuccess(MODULE, '读取待复核 PDA pending 投影', pendingResponse, '', OPTIONS)
|
||||
const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId)
|
||||
context.assert(MODULE, 'pending 与详情一致显示 AWAITING_REVIEW 且无未处理明细', pendingResponse,
|
||||
Boolean(pendingTask) && pendingTask.workflowStatus === 'AWAITING_REVIEW'
|
||||
&& Number(pendingTask.itemCount) === rows(task.items).length
|
||||
&& Number(pendingTask.pendingItemCount) === 0,
|
||||
`workflowStatus=${pendingTask?.workflowStatus || 'missing'}, itemCount=${pendingTask?.itemCount ?? 'missing'}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`)
|
||||
const operations = context.apiSuccess(MODULE, '读取领取与原子批量提交审计', operationsResponse, '', OPTIONS)
|
||||
const claimOperations = rows(operations).filter((item) => item.actionType === 'CLAIM')
|
||||
const batchOperations = rows(operations).filter((item) => item.actionType === 'BATCH')
|
||||
context.assert(MODULE, '人工主链只保留一次 CLAIM 和一次 BATCH 且尚无复核完成记录', operationsResponse,
|
||||
claimOperations.length === 1 && batchOperations.length === 1
|
||||
&& [...claimOperations, ...batchOperations].every((item) => item.operationId
|
||||
&& item.operatorId && item.operatorName && item.createdAt)
|
||||
&& !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
|
||||
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, claimCount=${claimOperations.length}, batchCount=${batchOperations.length}`,
|
||||
OPTIONS)
|
||||
|
||||
const uniqueInbounds = [...new Map(rows(task.items).map((item) => [id(item.inboundId), item])).values()]
|
||||
for (const item of uniqueInbounds) {
|
||||
const projectionResponse = await context.request('GET',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/picking?tenantId=${encodeURIComponent(item.tenantId)}`)
|
||||
const projection = context.apiSuccess(MODULE, `回查 ${item.inboundCode || item.inboundId} 入库级拣货投影`, projectionResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${item.inboundCode || item.inboundId} 服务端汇总数量已拣齐`, projectionResponse,
|
||||
Number(projection?.expectedCount) > 0
|
||||
&& Number(projection?.expectedCount) === Number(projection?.pickedCount),
|
||||
`status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
const evidenceResponse = await context.request('GET',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(item.tenantId)}`)
|
||||
const evidence = context.apiSuccess(MODULE, `${item.inboundCode || item.inboundId} 待复核取证入口可读`, evidenceResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${item.inboundCode || item.inboundId} AWAITING_REVIEW 可进入取证`, evidenceResponse,
|
||||
Array.isArray(evidence?.evidences),
|
||||
`evidenceCount=${evidence?.evidences?.length ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
context.createdResources.manualPdaBatchVerification = {
|
||||
sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_AWAITING_REVIEW',
|
||||
pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId),
|
||||
containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo,
|
||||
containerNo: fixture.containerNo, warehouseId: id(fixture.warehouseId), taskVersion: Number(task.version),
|
||||
assigneeId: id(task.assignment?.assigneeId), assigneeName: task.assignment?.assigneeName || null,
|
||||
itemCount: rows(task.items).length,
|
||||
plannedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0),
|
||||
pickedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0),
|
||||
claimOperationId: claimOperations[0]?.operationId || null,
|
||||
batchOperationId: batchOperations[0]?.operationId || null,
|
||||
deviceEvidenceRequired: 'HTTP 不记录逐次扫码轨迹;箱码逐一扫描需保留真机人工验收证据。',
|
||||
nextStage: '上传五类 ACTIVE 装柜证据后,再执行 PDA 最终完成与 LOADED 核验。',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const fixture = readFileSync(new URL('./manual-pda-ready-fixture.mjs', import.meta.url), 'utf8')
|
||||
const verify = readFileSync(new URL('./manual-pda-ready-verify.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('manual PDA-ready modules are optional and require a source report', () => {
|
||||
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-pda-ready-fixture'[^\]]*'manual-pda-ready-verify'/s)
|
||||
const defaults = parseArguments([]).modules
|
||||
assert.equal(defaults.includes('manual-pda-ready-fixture'), false)
|
||||
assert.equal(defaults.includes('manual-pda-ready-verify'), false)
|
||||
for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) {
|
||||
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
|
||||
'--password', 'password-value', '--modules', module, '--manual-pda-source-report', 'source.json'])
|
||||
assert.deepEqual(options.modules, [module])
|
||||
assert.equal(validateExecutionOptions(options), options)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, manualPdaSourceReport: '' }),
|
||||
/manual-pda-source-report/)
|
||||
}
|
||||
})
|
||||
|
||||
test('PDA-ready preparation is read only and proves all three prerequisites are absent', () => {
|
||||
assert.match(fixture, /manualPreloadPushVerification/)
|
||||
assert.match(fixture, /container\?\.containerNo == null/)
|
||||
assert.match(fixture, /container\?\.oceanBookingId == null/)
|
||||
assert.match(fixture, /readiness\?\.canConfirm === false/)
|
||||
assert.match(fixture, /codeOf\(taskResponse\) === 'A0404'/)
|
||||
assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
|
||||
test('PDA-ready verifier checks release, stable link, confirmation and one unassigned task without writes', () => {
|
||||
assert.match(verify, /oceanBooking\?\.status === 'RELEASED'/)
|
||||
assert.match(verify, /positiveId\(allocation\?\.requirementId\)/)
|
||||
assert.match(verify, /confirmationStatus === 'CONFIRMED'/)
|
||||
assert.match(verify, /readyForPda === true/)
|
||||
assert.match(verify, /workflowStatus === 'UNASSIGNED'/)
|
||||
assert.match(verify, /processStatus === 'WAIT_SPLIT'/)
|
||||
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-pda-ready-fixture'
|
||||
const OPTIONS = { scenario: 'manual-pda-ready-fixture', deploymentBlocker: true }
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const FIRST_LEG = '/api/platform/v1/first-leg'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const PDA = `${FIRST_LEG}/pda-loading`
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function items(value) {
|
||||
return rows(value).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId),
|
||||
packageCount: Number(item.packageCount) }))
|
||||
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
async function loadSource(context) {
|
||||
const report = JSON.parse(await readFile(resolve(context.options.manualPdaSourceReport), 'utf8'))
|
||||
const source = report?.createdResources?.manualPreloadPushVerification
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const valid = sourceGateway === context.options.gateway && source?.stage === 'PRELOAD_EDITING'
|
||||
&& source?.loadingPlanId && source?.containerBookingId && source?.pdaTaskCreated === false
|
||||
&& Array.isArray(source?.expectedAssignments) && source.expectedAssignments.length > 0
|
||||
context.assert(MODULE, '读取同环境推送预装核验报告', null, valid,
|
||||
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(source?.loadingPlanId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return valid ? source : null
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const source = await loadSource(context)
|
||||
if (!source) return
|
||||
const planId = id(source.loadingPlanId)
|
||||
const containerId = id(source.containerBookingId)
|
||||
|
||||
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
|
||||
const plan = context.apiSuccess(MODULE, 'PDA 就绪操作前读取预装计划', planResponse, '', OPTIONS)
|
||||
const beforeItems = items(plan?.items)
|
||||
context.assert(MODULE, '操作前计划仍为无号已推送 EDITING', planResponse,
|
||||
id(plan?.id) === planId && plan?.status === 'EDITING' && plan?.containerNo == null
|
||||
&& Number(plan?.version) === Number(source.planVersion) && beforeItems.length > 0,
|
||||
`status=${plan?.status || 'missing'}, expectedVersion=${source.planVersion}, actualVersion=${plan?.version ?? 'missing'}, items=${beforeItems.length}`,
|
||||
OPTIONS)
|
||||
if (!plan || beforeItems.length === 0) return
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse,
|
||||
id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo
|
||||
&& container?.containerNo == null && container?.oceanBookingId == null
|
||||
&& Number.isInteger(Number(container?.version)),
|
||||
`cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}, version=${container?.version ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const detailResponse = await context.request('GET',
|
||||
`${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '操作前读取预装确认门禁', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '缺少真实柜号与 RELEASED 订舱时不能确认预装', detailResponse,
|
||||
detail?.summary?.status === 'EDITING' && detail?.summary?.readiness?.canConfirm === false
|
||||
&& detail?.summary?.readiness?.readyForPda === false
|
||||
&& detail?.summary?.readiness?.pdaTaskId == null,
|
||||
`canConfirm=${detail?.summary?.readiness?.canConfirm}, readyForPda=${detail?.summary?.readiness?.readyForPda}, blockers=${rows(detail?.summary?.readiness?.blockers).join(',')}`,
|
||||
OPTIONS)
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(source.warehouseId)}`)
|
||||
context.addStep(MODULE, '操作前 PDA 任务仍不存在', taskResponse,
|
||||
taskResponse.http === 200 && codeOf(taskResponse) === 'A0404',
|
||||
`HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}`, OPTIONS)
|
||||
|
||||
context.createdResources.manualPdaReadyFixture = {
|
||||
schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(),
|
||||
stage: 'MANUAL_BOOKING_LINK_CONFIRM_READY', loadingPlanId: planId, containerBookingId: containerId,
|
||||
cabinetNo: source.cabinetNo, tenantId: id(source.tenantId), warehouseId: id(source.warehouseId),
|
||||
channelCode: source.channelCode, beforePlanVersion: Number(plan.version),
|
||||
beforeContainerVersion: Number(container?.version), beforeItems,
|
||||
expectedAssignments: source.expectedAssignments || [],
|
||||
instructions: [
|
||||
'在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写完整运输资料。',
|
||||
'确认放舱,使订舱进入 RELEASED;放舱本身不得创建货柜。',
|
||||
'在原 containerBookingId 上选择该订舱未占用的柜型需求,录入唯一真实 containerNo 完成关联。',
|
||||
'回到预装详情,确认 canConfirm=true 后执行整份计划确认;不要领取 PDA 任务。',
|
||||
'记录 bookingNo、containerNo,并立即运行 manual-pda-ready-verify。',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-pda-ready-verify'
|
||||
const OPTIONS = { scenario: 'manual-pda-ready-readonly-verification', deploymentBlocker: true }
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const FIRST_LEG = '/api/platform/v1/first-leg'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const PDA = `${FIRST_LEG}/pda-loading`
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
function positiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) }
|
||||
function itemSnapshot(value) {
|
||||
return rows(value).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId),
|
||||
packageCount: Number(item.packageCount) }))
|
||||
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
async function loadFixture(context) {
|
||||
const report = JSON.parse(await readFile(resolve(context.options.manualPdaSourceReport), 'utf8'))
|
||||
const fixture = report?.createdResources?.manualPdaReadyFixture
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1
|
||||
&& fixture?.stage === 'MANUAL_BOOKING_LINK_CONFIRM_READY' && fixture?.loadingPlanId
|
||||
&& fixture?.containerBookingId && Array.isArray(fixture?.beforeItems)
|
||||
&& Array.isArray(fixture?.expectedAssignments) && fixture.expectedAssignments.length > 0
|
||||
context.assert(MODULE, '读取同环境 PDA 就绪准备报告', null, valid,
|
||||
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(fixture?.loadingPlanId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return valid ? fixture : null
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = await loadFixture(context)
|
||||
if (!fixture) return
|
||||
const planId = id(fixture.loadingPlanId)
|
||||
const containerId = id(fixture.containerBookingId)
|
||||
|
||||
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
|
||||
const plan = context.apiSuccess(MODULE, '读取预装确认后的原智能装柜计划', planResponse, '', OPTIONS)
|
||||
const afterItems = itemSnapshot(plan?.items)
|
||||
context.assert(MODULE, '订舱关联和预装确认后计划身份、状态及明细稳定', planResponse,
|
||||
id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId
|
||||
&& plan?.status === 'EDITING' && Number(plan?.version) > Number(fixture.beforePlanVersion)
|
||||
&& JSON.stringify(afterItems) === JSON.stringify(fixture.beforeItems),
|
||||
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`,
|
||||
OPTIONS)
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)
|
||||
const oceanBookingId = id(container?.oceanBookingId)
|
||||
const realContainerNo = String(container?.containerNo || '')
|
||||
context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse,
|
||||
id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo
|
||||
&& realContainerNo.length > 0 && positiveId(oceanBookingId)
|
||||
&& Number(container?.version) > Number(fixture.beforeContainerVersion)
|
||||
&& id(container?.warehouseId) === id(fixture.warehouseId) && container?.channelCode === fixture.channelCode,
|
||||
`containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${realContainerNo || 'missing'}, oceanBookingId=${oceanBookingId || 'missing'}`,
|
||||
OPTIONS)
|
||||
if (!positiveId(oceanBookingId) || !realContainerNo) return
|
||||
|
||||
const bookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`)
|
||||
const booking = context.apiSuccess(MODULE, '读取关联后的 RELEASED 订舱与 allocation', bookingResponse, '', OPTIONS)
|
||||
const allocation = rows(booking?.containers).find((item) => id(item.containerBookingId) === containerId
|
||||
&& item.containerNo === realContainerNo)
|
||||
context.assert(MODULE, '订舱已放舱且唯一 allocation 指向原柜次', bookingResponse,
|
||||
booking?.oceanBooking?.status === 'RELEASED' && positiveId(allocation?.requirementId)
|
||||
&& positiveId(allocation?.allocationId)
|
||||
&& rows(booking?.containers).filter((item) => id(item.containerBookingId) === containerId).length === 1,
|
||||
`status=${booking?.oceanBooking?.status || 'missing'}, bookingNo=${booking?.oceanBooking?.bookingNo || 'missing'}, requirementId=${id(allocation?.requirementId) || 'missing'}, allocationId=${id(allocation?.allocationId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const detailResponse = await context.request('GET',
|
||||
`${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '读取整份预装确认结果', detailResponse, '', OPTIONS)
|
||||
const taskId = id(detail?.summary?.readiness?.pdaTaskId)
|
||||
context.assert(MODULE, '预装确认状态为 CONFIRMED 并返回唯一 PDA taskId', detailResponse,
|
||||
id(detail?.summary?.planId) === planId && detail?.summary?.status === 'EDITING'
|
||||
&& detail?.summary?.readiness?.confirmationStatus === 'CONFIRMED'
|
||||
&& detail?.summary?.readiness?.canConfirm === false
|
||||
&& detail?.summary?.readiness?.readyForPda === true && positiveId(taskId),
|
||||
`confirmationStatus=${detail?.summary?.readiness?.confirmationStatus || 'missing'}, readyForPda=${detail?.summary?.readiness?.readyForPda}, taskId=${taskId || 'missing'}`,
|
||||
OPTIONS)
|
||||
if (!positiveId(taskId)) return
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, '读取唯一待领取 PDA 任务', taskResponse, '', OPTIONS)
|
||||
const plannedPackages = rows(task?.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0)
|
||||
const expectedPackages = rows(fixture.expectedAssignments)
|
||||
.reduce((sum, item) => sum + Number(item.expectedAllocatedPackageCount || 0), 0)
|
||||
context.assert(MODULE, 'PDA 任务四元身份、未领取状态和计划箱数正确', taskResponse,
|
||||
id(task?.id) === taskId && id(task?.loadingPlanId) === planId
|
||||
&& id(task?.containerBookingId) === containerId && task?.cabinetNo === fixture.cabinetNo
|
||||
&& task?.currentContainerNo === realContainerNo && task?.taskStatus === 'LOADING'
|
||||
&& task?.workflowStatus === 'UNASSIGNED' && task?.assignment == null
|
||||
&& rows(task?.items).length > 0 && plannedPackages === expectedPackages,
|
||||
`taskId=${id(task?.id) || 'missing'}, taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, plannedPackages=${plannedPackages}, expectedPackages=${expectedPackages}`,
|
||||
OPTIONS)
|
||||
|
||||
for (const assignment of fixture.expectedAssignments || []) {
|
||||
const inboundResponse = await context.request('GET',
|
||||
`/api/platform/inbounds/${encodeURIComponent(assignment.inboundId)}?tenantId=${encodeURIComponent(fixture.tenantId)}`)
|
||||
const inbound = context.apiSuccess(MODULE, `${assignment.role} claim 前回查入库状态`, inboundResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${assignment.role} 在 PDA 领取前仍为 WAIT_SPLIT`, inboundResponse,
|
||||
id(inbound?.summary?.id) === id(assignment.inboundId)
|
||||
&& inbound?.summary?.processStatus === 'WAIT_SPLIT',
|
||||
`inboundId=${id(inbound?.summary?.id) || 'missing'}, status=${inbound?.summary?.processStatus || 'missing'}`,
|
||||
OPTIONS)
|
||||
}
|
||||
|
||||
context.createdResources.manualPdaReadyVerification = {
|
||||
sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_UNASSIGNED',
|
||||
oceanBookingId, bookingNo: booking?.oceanBooking?.bookingNo || null,
|
||||
requirementId: id(allocation?.requirementId), allocationId: id(allocation?.allocationId),
|
||||
loadingPlanId: planId, containerBookingId: containerId, cabinetNo: fixture.cabinetNo,
|
||||
containerNo: realContainerNo, pdaTaskId: taskId, tenantId: id(fixture.tenantId),
|
||||
warehouseId: id(fixture.warehouseId), plannedPackageCount: plannedPackages,
|
||||
nextStage: 'PDA 人工领取任务后进入 LOADING,再执行扫码、批量提交和待复核验收。',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const fixture = readFileSync(new URL('./manual-preload-push-fixture.mjs', import.meta.url), 'utf8')
|
||||
const verify = readFileSync(new URL('./manual-preload-push-verify.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('manual preload modules are optional and require an explicit source report', () => {
|
||||
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-preload-push-fixture'[^\]]*'manual-preload-push-verify'/s)
|
||||
const defaults = parseArguments([]).modules
|
||||
assert.equal(defaults.includes('manual-preload-push-fixture'), false)
|
||||
assert.equal(defaults.includes('manual-preload-push-verify'), false)
|
||||
for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) {
|
||||
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
|
||||
'--password', 'password-value', '--modules', module, '--manual-preload-source-report', 'source.json'])
|
||||
assert.deepEqual(options.modules, [module])
|
||||
assert.equal(validateExecutionOptions(options), options)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, manualPreloadSourceReport: '' }),
|
||||
/manual-preload-source-report/)
|
||||
}
|
||||
})
|
||||
|
||||
test('preload preparation is read only and freezes the pre-push plan snapshot', () => {
|
||||
assert.match(fixture, /manualOrderSplittingVerification/)
|
||||
assert.match(fixture, /beforePlanVersion/)
|
||||
assert.match(fixture, /beforeItems/)
|
||||
assert.match(fixture, /未点击推送前不存在预装 bridge/)
|
||||
assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
|
||||
test('post-push verifier proves EDITING bridge visibility and PDA A0404 without writes', () => {
|
||||
assert.match(verify, /Number\(plan\?\.version\) > Number\(fixture\.beforePlanVersion\)/)
|
||||
assert.match(verify, /allocatedPackageCount/)
|
||||
assert.match(verify, /preloadStatus === 'PENDING'/)
|
||||
assert.match(verify, /readiness\?\.canConfirm === false/)
|
||||
assert.match(verify, /codeOf\(taskResponse\) === 'A0404'/)
|
||||
assert.match(verify, /container\?\.containerNo == null/)
|
||||
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-preload-push-fixture'
|
||||
const OPTIONS = { scenario: 'manual-preload-push-fixture', deploymentBlocker: true }
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
function itemSnapshot(items) {
|
||||
return rows(items).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId),
|
||||
packageCount: Number(item.packageCount) }))
|
||||
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
async function loadSource(context) {
|
||||
const report = JSON.parse(await readFile(resolve(context.options.manualPreloadSourceReport), 'utf8'))
|
||||
const verification = report?.createdResources?.manualOrderSplittingVerification
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const valid = sourceGateway === context.options.gateway && verification?.loadingPlanId
|
||||
&& verification?.containerBookingId && Array.isArray(verification?.verifiedAssignments)
|
||||
context.assert(MODULE, '读取同环境人工分柜核验报告', null, valid,
|
||||
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(verification?.loadingPlanId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return valid ? verification : null
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const source = await loadSource(context)
|
||||
if (!source) return
|
||||
const planId = id(source.loadingPlanId)
|
||||
const containerId = id(source.containerBookingId)
|
||||
|
||||
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
|
||||
const plan = context.apiSuccess(MODULE, '推送前读取智能装柜计划', planResponse, '', OPTIONS)
|
||||
const items = itemSnapshot(plan?.items)
|
||||
context.assert(MODULE, '计划仍处于未推送 EDITING 且分柜明细完整', planResponse,
|
||||
id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId
|
||||
&& plan?.status === 'EDITING' && Number.isInteger(Number(plan?.version)) && items.length > 0,
|
||||
`planId=${id(plan?.id) || 'missing'}, status=${plan?.status || 'missing'}, version=${plan?.version ?? 'missing'}, items=${items.length}`,
|
||||
OPTIONS)
|
||||
if (!plan || plan.status !== 'EDITING' || items.length === 0) return
|
||||
|
||||
const linksResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}/preloads`)
|
||||
const links = context.apiSuccess(MODULE, '推送前回查尚未建立预装 bridge', linksResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse,
|
||||
rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS)
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse,
|
||||
id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null
|
||||
&& id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode,
|
||||
`containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}`,
|
||||
OPTIONS)
|
||||
|
||||
context.createdResources.manualPreloadPushFixture = {
|
||||
schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(),
|
||||
stage: 'MANUAL_PUSH_PRELOAD_READY', loadingPlanId: planId, containerBookingId: containerId,
|
||||
cabinetNo: container?.cabinetNo || null, tenantId: id(source.tenantId), warehouseId: id(source.warehouseId),
|
||||
channelCode: source.channelCode, beforePlanVersion: Number(plan.version), beforeItems: items,
|
||||
expectedAssignments: source.verifiedAssignments,
|
||||
instructions: [
|
||||
'在智能装柜计划页面点击推送预装。',
|
||||
'本节点保持无真实集装箱号、无订舱关联,不执行计划确认。',
|
||||
'推送成功后不要继续录入柜号或创建 PDA 任务,立即运行 manual-preload-push-verify。',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MODULE = 'manual-preload-push-verify'
|
||||
const OPTIONS = { scenario: 'manual-preload-push-readonly-verification', deploymentBlocker: true }
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const FIRST_LEG = '/api/platform/v1/first-leg'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const PDA = `${FIRST_LEG}/pda-loading`
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function itemSnapshot(items) {
|
||||
return rows(items).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId),
|
||||
packageCount: Number(item.packageCount) }))
|
||||
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
async function loadFixture(context) {
|
||||
const report = JSON.parse(await readFile(resolve(context.options.manualPreloadSourceReport), 'utf8'))
|
||||
const fixture = report?.createdResources?.manualPreloadPushFixture
|
||||
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
|
||||
const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1
|
||||
&& fixture?.stage === 'MANUAL_PUSH_PRELOAD_READY' && fixture?.loadingPlanId
|
||||
&& fixture?.containerBookingId && Array.isArray(fixture?.beforeItems)
|
||||
context.assert(MODULE, '读取同环境推送预装准备报告', null, valid,
|
||||
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(fixture?.loadingPlanId) || 'missing'}`,
|
||||
OPTIONS)
|
||||
return valid ? fixture : null
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = await loadFixture(context)
|
||||
if (!fixture) return
|
||||
const planId = id(fixture.loadingPlanId)
|
||||
const containerId = id(fixture.containerBookingId)
|
||||
|
||||
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
|
||||
const plan = context.apiSuccess(MODULE, '读取人工推送后的智能装柜计划', planResponse, '', OPTIONS)
|
||||
const afterItems = itemSnapshot(plan?.items)
|
||||
context.assert(MODULE, '无号推送后计划保持 EDITING、版本递增且明细零漂移', planResponse,
|
||||
id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId
|
||||
&& plan?.status === 'EDITING' && plan?.containerNo == null
|
||||
&& Number(plan?.version) > Number(fixture.beforePlanVersion)
|
||||
&& JSON.stringify(afterItems) === JSON.stringify(fixture.beforeItems),
|
||||
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`,
|
||||
OPTIONS)
|
||||
if (!plan || plan.status !== 'EDITING') return
|
||||
|
||||
const linksResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}/preloads`)
|
||||
const links = context.apiSuccess(MODULE, '回查推送原子建立的预装 bridge', linksResponse, '', OPTIONS)
|
||||
const linkRows = rows(links)
|
||||
for (const expected of fixture.expectedAssignments || []) {
|
||||
const matched = linkRows.filter((link) => id(link.inboundId) === id(expected.inboundId))
|
||||
const allocated = matched.reduce((sum, link) => sum + Number(link.allocatedPackageCount || 0), 0)
|
||||
context.assert(MODULE, `${expected.role} 预装 bridge 数量与人工分柜一致`, linksResponse,
|
||||
matched.length >= 1 && allocated === Number(expected.expectedAllocatedPackageCount)
|
||||
&& matched.every((link) => id(link.loadingPlanId) === planId
|
||||
&& id(link.containerBookingId) === containerId && link.preloadStatus === 'PENDING'
|
||||
&& link.loadingPlanStatus === 'EDITING' && Number(link.loadingPlanVersion) === Number(plan.version)),
|
||||
`inboundId=${id(expected.inboundId)}, expected=${expected.expectedAllocatedPackageCount}, actual=${allocated}, links=${matched.length}`,
|
||||
OPTIONS)
|
||||
}
|
||||
const expectedInboundIds = new Set((fixture.expectedAssignments || []).map((item) => id(item.inboundId)))
|
||||
const unexpectedLinks = linkRows.filter((link) => !expectedInboundIds.has(id(link.inboundId)))
|
||||
context.assert(MODULE, '预装 bridge 未混入人工分柜清单外入库单', linksResponse,
|
||||
unexpectedLinks.length === 0 && linkRows.length >= expectedInboundIds.size,
|
||||
`linkCount=${linkRows.length}, unexpectedInboundIds=${unexpectedLinks.map((link) => id(link.inboundId)).join(',') || 'none'}`,
|
||||
OPTIONS)
|
||||
|
||||
const detailResponse = await context.request('GET',
|
||||
`${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '读取推送后正式预装清单详情', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '预装清单保持 EDITING 且无号阶段不能确认或创建 PDA', detailResponse,
|
||||
id(detail?.summary?.planId) === planId && detail?.summary?.status === 'EDITING'
|
||||
&& detail?.summary?.readiness?.canConfirm === false
|
||||
&& detail?.summary?.readiness?.readyForPda === false
|
||||
&& detail?.summary?.readiness?.pdaTaskId == null,
|
||||
`planId=${id(detail?.summary?.planId) || 'missing'}, status=${detail?.summary?.status || 'missing'}, canConfirm=${detail?.summary?.readiness?.canConfirm}, readyForPda=${detail?.summary?.readiness?.readyForPda}`,
|
||||
OPTIONS)
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
|
||||
context.addStep(MODULE, '无真实柜号推送后 PDA 查询仍为 A0404', taskResponse,
|
||||
taskResponse.http === 200 && codeOf(taskResponse) === 'A0404',
|
||||
`HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse,
|
||||
id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo
|
||||
&& container?.containerNo == null && container?.oceanBookingId == null,
|
||||
`containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}`,
|
||||
OPTIONS)
|
||||
|
||||
context.createdResources.manualPreloadPushVerification = {
|
||||
sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PRELOAD_EDITING',
|
||||
loadingPlanId: planId, containerBookingId: containerId, cabinetNo: fixture.cabinetNo,
|
||||
tenantId: id(fixture.tenantId), warehouseId: id(fixture.warehouseId), channelCode: fixture.channelCode,
|
||||
planVersion: Number(plan.version), preloadLinkCount: linkRows.length, pdaTaskCreated: false,
|
||||
expectedAssignments: fixture.expectedAssignments,
|
||||
nextStage: '关联 RELEASED 订舱与真实柜号后,人工执行整份预装计划确认。',
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
- 用于开发、联调或经明确授权的验收环境准备人工操作数据。
|
||||
- 生成结果固定为入库单 `processStatus=WAIT_SPLIT`、测量审核 `APPROVED`。
|
||||
- 单次支持 `1-100` 条;更大批量分多次执行并分别保存报告。
|
||||
- 不负责后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货。
|
||||
- 基础批量模式不自动执行后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货;需要人工分柜验收时使用本文后半部分的准备与只读核验入口。
|
||||
- 禁止用于生产环境。对任何共享环境执行前,必须在当前对话明确获得该环境的写数据授权。
|
||||
|
||||
## 固定项与随机项
|
||||
@@ -121,6 +121,286 @@ node scripts/admin-test-env-acceptance.mjs `
|
||||
|
||||
报告生成前会扫描密码、Token、文件二进制、base64 和 OCR 原文等敏感内容。报告仍属于本地验收证据,不应提交到源码仓库。
|
||||
|
||||
## 人工分柜验收
|
||||
|
||||
### 1. 生成分柜准备清单
|
||||
|
||||
人工分柜模块默认生成 6 票同客户、同起运仓、同渠道的 `WAIT_SPLIT` 数据,并在报告的 `createdResources.manualOrderSplittingFixture` 中给出:
|
||||
|
||||
- 一票 `PARTIAL`:只分配部分箱数,固定保留 1 箱;
|
||||
- 两票 `FULL_1/FULL_2`:整票加入同一个柜,验证多订单拼柜;
|
||||
- 一票 `UNASSIGNED_CONTROL`:不得加入计划,用于核对未分配对照和候选余量;
|
||||
- 全部订单、入库单、入仓码、箱数、客户、仓库和渠道信息。
|
||||
|
||||
复用既有客户时执行:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-order-splitting-fixture `
|
||||
--merchant-username $env:MERCHANT_TEST_USERNAME `
|
||||
--merchant-password $env:MERCHANT_TEST_PASSWORD `
|
||||
--origin-warehouse-name '<固定国内仓名称>' `
|
||||
--channel-name '<固定渠道名称>' `
|
||||
--channel-code '<固定渠道编码>' `
|
||||
--order-profile-mode fixed `
|
||||
--wait-split-count 6 `
|
||||
--output '<人工分柜准备报告绝对路径>.json'
|
||||
```
|
||||
|
||||
`manual-order-splitting-fixture` 会自动展开 `orders → inbound-measurement`。本入口通过正式 HTTP 接口写入测试数据,仍需当前对话明确授权目标环境。
|
||||
|
||||
### 2. Admin 人工操作
|
||||
|
||||
根据准备报告执行:
|
||||
|
||||
1. 打开智能装柜页面,创建与清单一致的起运仓、渠道模拟柜。
|
||||
2. 创建智能装柜计划。
|
||||
3. 严格按 `assignments[].expectedAllocatedPackageCount` 分配三票入仓码。
|
||||
4. 不要选择 `unassignedControl`。
|
||||
5. 保存计划但暂不推送预装,记录页面或接口返回的 `loadingPlanId`。
|
||||
|
||||
此阶段只是“计划分柜”。父子订单和最终实装数量要到 PDA 实装结算后形成,不能在本节点提前宣称已经完成订单拆分。
|
||||
|
||||
### 3. 人工操作后只读核验
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-order-splitting-verify `
|
||||
--manual-split-fixture-report '<人工分柜准备报告绝对路径>.json' `
|
||||
--manual-split-plan-id '<页面记录的 loadingPlanId>' `
|
||||
--output '<人工分柜核验报告绝对路径>.json'
|
||||
```
|
||||
|
||||
核验模块只发送 GET 请求,检查:
|
||||
|
||||
- 计划与柜次 ID 有效,计划仍处于可继续验收的 `EDITING/LOADING`;
|
||||
- 柜次仓库、渠道与准备清单一致;
|
||||
- `PARTIAL/FULL_1/FULL_2` 的计划箱数精确匹配;
|
||||
- 未混入清单外入库单,`UNASSIGNED_CONTROL` 未被误选;
|
||||
- 候选回查不出现异渠道或异仓入库单;
|
||||
- 每票分配后的候选剩余箱数满足数量守恒。
|
||||
|
||||
核验报告会写入 `createdResources.manualOrderSplittingVerification`,记录来源 marker、计划、柜次和已核验分配,不保存凭据。
|
||||
|
||||
## 人工推送预装验收
|
||||
|
||||
完成上一节人工分柜核验后,继续验证“点击推送预装,但尚无真实柜号、未关联订舱”的节点。
|
||||
|
||||
### 1. 生成推送前操作清单
|
||||
|
||||
使用上一节生成的“人工分柜核验报告”:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-preload-push-fixture `
|
||||
--manual-preload-source-report '<人工分柜核验报告绝对路径>.json' `
|
||||
--output '<推送预装前准备报告绝对路径>.json'
|
||||
```
|
||||
|
||||
该模块只发送 GET 请求,并确认:
|
||||
|
||||
- 智能装柜计划仍为 `EDITING`;
|
||||
- 人工分柜明细存在且版本有效;
|
||||
- 尚未建立预装 bridge;
|
||||
- 柜次仍为无真实 `containerNo`、无 `oceanBookingId` 的内部模拟柜。
|
||||
|
||||
成功后,报告中的 `createdResources.manualPreloadPushFixture` 会保存推送前计划版本、明细快照和操作说明。
|
||||
|
||||
### 2. Admin 人工推送
|
||||
|
||||
在同一智能装柜计划上点击“推送预装”。本节点只做这一项操作:
|
||||
|
||||
- 不录入真实集装箱号;
|
||||
- 不创建或关联订舱;
|
||||
- 不确认预装计划;
|
||||
- 不领取或创建 PDA 任务。
|
||||
|
||||
推送成功后立即执行下一步只读核验。
|
||||
|
||||
### 3. 推送后只读核验
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-preload-push-verify `
|
||||
--manual-preload-source-report '<推送预装前准备报告绝对路径>.json' `
|
||||
--output '<推送预装核验报告绝对路径>.json'
|
||||
```
|
||||
|
||||
核验内容包括:
|
||||
|
||||
- 计划仍为 `EDITING`,版本相对推送前递增,分柜明细没有漂移;
|
||||
- 每票入库单生成 `PENDING` 预装 bridge,箱数与人工分柜一致;
|
||||
- 正式预装清单详情可见;
|
||||
- 无真实柜号阶段 `canConfirm=false`、`readyForPda=false`;
|
||||
- PDA 任务查询返回 `A0404`;
|
||||
- 推送没有伪造真实柜号、订舱关联或改变稳定柜次身份。
|
||||
|
||||
通过后报告写入 `createdResources.manualPreloadPushVerification`,下一节点再处理订舱放舱、真实柜号关联和整份预装计划确认。
|
||||
|
||||
## 人工订舱关联、预装确认与 PDA 待领取验收
|
||||
|
||||
### 1. 冻结操作前基线
|
||||
|
||||
以上一节“推送预装核验报告”为来源:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-pda-ready-fixture `
|
||||
--manual-pda-source-report '<推送预装核验报告绝对路径>.json' `
|
||||
--output '<PDA就绪前准备报告绝对路径>.json'
|
||||
```
|
||||
|
||||
准备模块只发送 GET 请求,确认:
|
||||
|
||||
- 已推送计划仍为 `EDITING`,版本和分柜明细没有变化;
|
||||
- 原柜次仍无真实柜号、无订舱关联;
|
||||
- 当前 `canConfirm=false`、`readyForPda=false`;
|
||||
- PDA 查询仍返回 `A0404`。
|
||||
|
||||
报告的 `createdResources.manualPdaReadyFixture` 保存柜次、计划、版本和明细基线。
|
||||
|
||||
### 2. Admin 人工操作
|
||||
|
||||
严格按以下顺序操作:
|
||||
|
||||
1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐提单号、船名、航次、ETD、ETA、参与方和柜型需求。
|
||||
2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。
|
||||
3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。
|
||||
4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。
|
||||
5. 确认成功后不要领取 PDA 任务,记录订舱号和真实柜号并立即执行核验。
|
||||
|
||||
### 3. 只读核验 PDA 待领取状态
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-pda-ready-verify `
|
||||
--manual-pda-source-report '<PDA就绪前准备报告绝对路径>.json' `
|
||||
--output '<PDA待领取核验报告绝对路径>.json'
|
||||
```
|
||||
|
||||
核验内容包括:
|
||||
|
||||
- 订舱为 `RELEASED`,且 allocation 唯一指向原 `containerBookingId`;
|
||||
- 真实柜号在原柜次上补全,`cabinetNo/loadingPlanId/items` 不漂移;
|
||||
- 整份预装为 `CONFIRMED`、`readyForPda=true`,并返回唯一 `pdaTaskId`;
|
||||
- PDA task 四元身份正确,`taskStatus=LOADING`、`workflowStatus=UNASSIGNED`;
|
||||
- PDA 计划箱数与人工分柜总箱数一致;
|
||||
- 尚未领取任务时各来源入库单仍保持 `WAIT_SPLIT`。
|
||||
|
||||
通过后报告写入 `createdResources.manualPdaReadyVerification`。下一节点进入 PDA 人工领取、扫码、批量提交和待复核验收。
|
||||
|
||||
## PDA 人工领取、扫码和批量提交验收
|
||||
|
||||
### 1. 冻结 PDA 作业前基线
|
||||
|
||||
以上一节“PDA 待领取核验报告”为来源:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-pda-batch-fixture `
|
||||
--manual-pda-batch-source-report '<PDA待领取核验报告绝对路径>.json' `
|
||||
--output '<PDA作业前准备报告绝对路径>.json'
|
||||
```
|
||||
|
||||
准备模块只发送 GET 请求,确认并记录:
|
||||
|
||||
- 任务为 `UNASSIGNED`,尚未绑定作业员;
|
||||
- PDA 待办中只有同一稳定 `taskId`;
|
||||
- 每条任务明细包含稳定 `boxCodes`,数量足以覆盖计划箱数;
|
||||
- 库存快照的任务版本和四元身份一致;
|
||||
- 操作历史尚无 `CLAIM/BATCH/REVIEW/COMPLETE_LOADING`。
|
||||
|
||||
报告的 `createdResources.manualPdaBatchFixture.taskItems[].scanBoxCodes` 提供本轮应扫描箱码,`boxCodes` 保留服务端完整下发集合;库存 `snapshotToken` 不写入报告,PDA 提交前必须实时重新获取。
|
||||
|
||||
### 2. PDA 真机人工操作
|
||||
|
||||
1. 使用目标仓库作业员进入 PDA 待办并领取任务,确认页面进入作业中。
|
||||
2. 按准备报告对应任务明细,在真机逐箱扫描稳定箱码;同一箱码不能重复扫描。
|
||||
3. 所有明细均按计划箱数完成,本节点不使用 `PARTIAL` 或 `SKIP`。
|
||||
4. 点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch。
|
||||
5. 成功后停在 `AWAITING_REVIEW`,不要上传五类证据,不要复核或执行最终完成。
|
||||
|
||||
服务端 batch 只接收明细与数量,不保存逐次扫码轨迹。因此自动报告只能证明稳定箱码已下发和服务端数量已收敛,真机逐箱扫码过程必须保留人工证据。
|
||||
|
||||
### 3. 批量提交后只读核验
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-pda-batch-verify `
|
||||
--manual-pda-batch-source-report '<PDA作业前准备报告绝对路径>.json' `
|
||||
--output '<PDA待复核核验报告绝对路径>.json'
|
||||
```
|
||||
|
||||
核验内容包括:
|
||||
|
||||
- 任务已绑定真实作业员,版本递增;
|
||||
- 所有明细均为 `PICKED`,已拣箱数等于计划箱数;
|
||||
- 任务保持 `taskStatus=LOADING`,工作流进入 `AWAITING_REVIEW`;
|
||||
- pending 与 detail 均显示零条未处理明细;
|
||||
- 操作历史恰有一次 `CLAIM`、一次 `BATCH`,没有 `REVIEW/COMPLETE_LOADING`;
|
||||
- 各入库单服务端汇总 `expectedCount=pickedCount`;
|
||||
- 待复核取证 GET 已开放。
|
||||
|
||||
通过后报告写入 `createdResources.manualPdaBatchVerification`,并明确标记逐次扫码仍需真机证据。下一节点处理五类装柜证据和 PDA 最终完成。
|
||||
|
||||
## 五类装柜证据、PDA 最终完成与 LOADED 验收
|
||||
|
||||
### 1. 冻结最终完成前基线
|
||||
|
||||
以上一节“PDA 待复核核验报告”为来源:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-loading-complete-fixture `
|
||||
--manual-loading-complete-source-report '<PDA待复核核验报告绝对路径>.json' `
|
||||
--output '<装柜最终完成前准备报告绝对路径>.json'
|
||||
```
|
||||
|
||||
准备模块只发送 GET 请求,确认:
|
||||
|
||||
- PDA 任务为 `AWAITING_REVIEW`,所有明细已拣齐;
|
||||
- 装柜计划仍为 `LOADING/IN_PROGRESS`;
|
||||
- 任务审计只有一次 `CLAIM` 和一次 `BATCH`;
|
||||
- 当前证据归属入库单没有 ACTIVE 装柜证据;
|
||||
- 真实柜号和 `RELEASED` 订舱门禁仍有效。
|
||||
|
||||
报告的 `createdResources.manualLoadingCompleteFixture` 会固定证据归属、五类证据要求和最终完成前版本。
|
||||
|
||||
### 2. PDA 真机人工操作
|
||||
|
||||
在同一 PDA 任务内现场完成:
|
||||
|
||||
1. 拍摄空仓照 `PHOTO_STEP_1`。
|
||||
2. 拍摄半仓照 `PHOTO_STEP_2`。
|
||||
3. 拍摄满仓照 `PHOTO_STEP_3`。
|
||||
4. 拍摄锁仓照 `PHOTO_STEP_4`。
|
||||
5. 拍摄带锁码照 `SEAL`,并填写非空锁码。
|
||||
6. 确认五类均为 ACTIVE 后,只点击一次最终完成。
|
||||
|
||||
不要调用旧入库单级兼容完成入口;当前唯一正式动作是 PDA task 的最终完成。成功后不要继续修改柜号、计划或证据。
|
||||
|
||||
### 3. LOADED 终态只读核验
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules manual-loading-complete-verify `
|
||||
--manual-loading-complete-source-report '<装柜最终完成前准备报告绝对路径>.json' `
|
||||
--output '<LOADED终态核验报告绝对路径>.json'
|
||||
```
|
||||
|
||||
核验内容包括:
|
||||
|
||||
- 五类 ACTIVE 证据各一份,SEAL 锁码非空且每张图片可鉴权下载;
|
||||
- 任务 `taskStatus/workflowStatus` 均为 `COMPLETED`;
|
||||
- 操作历史恰有一次 `COMPLETE_LOADING`,并保留原 CLAIM/BATCH;
|
||||
- 原装柜计划进入 `COMPLETED`;
|
||||
- 所有来源入库单进入 `LOADED`,状态历史包含 `LOADING → LOADED`;
|
||||
- 对应统一订单详情投影为 `LOADED`;
|
||||
- 柜次实装分片唯一,实装箱数等于 PDA 已拣总箱数;
|
||||
- 货柜总览聚合 COMPLETED PDA 和五类 ACTIVE 证据。
|
||||
|
||||
通过后报告写入 `createdResources.manualLoadingCompleteVerification`。至此,本指南覆盖的国内人工验收链已经从订单造数连续到装柜 `LOADED`;后续报关、国内出仓和海外接柜另按对应节点扩展。
|
||||
|
||||
## 失败、重跑与批量拆分
|
||||
|
||||
- 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。
|
||||
@@ -148,7 +428,17 @@ node scripts/admin-test-env-acceptance.mjs `
|
||||
```powershell
|
||||
node --check scripts/admin-test-env-acceptance.mjs
|
||||
node --check scripts/admin-test-env-modules/orders.mjs
|
||||
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-order-splitting-verify.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-preload-push-fixture.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-preload-push-verify.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-pda-ready-verify.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-pda-batch-verify.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs
|
||||
node --check scripts/admin-test-env-modules/manual-loading-complete-verify.mjs
|
||||
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user