Merge test into dev and resolve conflicts
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@
|
||||
| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 |
|
||||
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
|
||||
| 订舱与拖车服务中心链路 | `admin-test-env-modules/service-center-flow.mjs` | 复用本轮 `RELEASED` 订舱,创建订舱级拖车并验证附件、完整状态链、历史和稳定关联;由 Admin 总入口按依赖运行,会写目标环境 |
|
||||
| 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。PDA 卸柜后经正式码头拖车接口完成选车队、预约、提柜、还柜,船节点模块只读核对最终回写,不直接伪造最后两个节点 |
|
||||
| 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。货柜达到 `PICKUP_READY` 后先经正式码头拖车接口完成选车队、预约和提柜,PDA 卸柜后再还柜;船节点模块只读核对最终回写,不直接伪造最后两个节点。人工船节点的 `observedAt` 使用运行机本地墙钟时间,避免无时区 `LocalDateTime` 被 UTC 偏移倒序 |
|
||||
| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
|
||||
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`(含国内 PDA Node 全量测试)、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问开发或测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
|
||||
| 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
@@ -75,6 +75,7 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']],
|
||||
['phase4-overseas-pda', ['phase4-tail-order-controls']],
|
||||
['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
|
||||
['phase4-overseas-return-receipt', ['merchant-qualification-fixture']],
|
||||
['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-client-services', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']],
|
||||
@@ -294,6 +295,7 @@ function usage() {
|
||||
--wait-split-package-count <n> 单票指定总箱数,1-1000;省略时使用既有轮换
|
||||
--wait-split-sequence <n> 单票全局序号,正整数,默认1
|
||||
--wait-split-random-value <n> 单票随机选择值,正整数,默认使用全局序号
|
||||
--prepare-dropship-manual-count <true> 一件代发停在PDA清点前,不领取、不清点、不归托
|
||||
--verify-pallet-location-guide <true> 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY
|
||||
--route-mode <mode> 线路选择:fixed 或 random,默认fixed
|
||||
--manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用
|
||||
@@ -333,6 +335,7 @@ export function parseArguments(argv) {
|
||||
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
const requiresMarkerOwnedMerchantFixture = [
|
||||
'phase4-overseas-business',
|
||||
'phase4-overseas-return-receipt',
|
||||
'phase4-overseas-client-services',
|
||||
'phase5-overseas-fulfillment',
|
||||
'phase4-overseas-finance-cross-region',
|
||||
@@ -362,6 +365,7 @@ export function parseArguments(argv) {
|
||||
waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0),
|
||||
waitSplitSequence: Number(values.get('wait-split-sequence') || 1),
|
||||
waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1),
|
||||
prepareDropshipManualCount: values.get('prepare-dropship-manual-count') === 'true',
|
||||
verifyPalletLocationGuide: values.has('verify-pallet-location-guide'),
|
||||
routeMode: values.get('route-mode') || 'fixed',
|
||||
quiet: values.get('quiet') === 'true',
|
||||
@@ -421,6 +425,10 @@ export function validateExecutionOptions(options) {
|
||||
&& (!options.originWarehouseName || !options.channelName || !options.channelCode)) {
|
||||
throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码')
|
||||
}
|
||||
if (options.prepareDropshipManualCount
|
||||
&& (!options.modules.includes('orders') || !options.modules.includes('inbound-measurement'))) {
|
||||
throw new Error('prepare-dropship-manual-count要求同时执行orders和inbound-measurement模块')
|
||||
}
|
||||
if (options.waitSplitCount > 1) {
|
||||
const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
||||
'manual-order-splitting-fixture', 'coload-groups', 'firstleg-container-fee-allocation'])
|
||||
@@ -531,6 +539,7 @@ export class AcceptanceRun {
|
||||
this.modules = []
|
||||
this.scenarios = []
|
||||
this.createdResources = {}
|
||||
this.phase4ProducerAttempts = new Map()
|
||||
this.deploymentBlockers = []
|
||||
this.coverageGaps = []
|
||||
this.context = {}
|
||||
@@ -767,10 +776,12 @@ export class AcceptanceRun {
|
||||
captureWaitSplitFixture(sequence) {
|
||||
const order = this.createdResources.orders || {}
|
||||
const measurement = this.createdResources.inboundMeasurement || {}
|
||||
const expectedProcessStatus = this.options.prepareDropshipManualCount ? 'WAIT_PALLETIZE' : 'WAIT_SPLIT'
|
||||
const ready = Boolean(order.orderId && measurement.inboundId
|
||||
&& String(order.tenantId) === String(measurement.tenantId)
|
||||
&& measurement.processStatus === 'WAIT_SPLIT' && measurement.finalStatus === 'APPROVED')
|
||||
this.assert('wait-split-fixtures', `固定客户第 ${sequence} 单停在待分柜`, null, ready,
|
||||
&& measurement.processStatus === expectedProcessStatus && measurement.finalStatus === 'APPROVED')
|
||||
this.assert('wait-split-fixtures', this.options.prepareDropshipManualCount
|
||||
? `固定客户第 ${sequence} 单停在PDA清点前` : `固定客户第 ${sequence} 单停在待分柜`, null, ready,
|
||||
`tenantId=${measurement.tenantId || 'missing'}, orderId=${order.orderId || 'missing'}, inboundId=${measurement.inboundId || 'missing'}, processStatus=${measurement.processStatus || 'missing'}, reviewStatus=${measurement.finalStatus || 'missing'}`,
|
||||
{ scenario: 'fixed-customer-wait-split', deploymentBlocker: true })
|
||||
if (!ready) return false
|
||||
@@ -780,7 +791,8 @@ export class AcceptanceRun {
|
||||
orderNo: order.orderNo || null, inboundId: String(measurement.inboundId),
|
||||
inboundCode: order.inboundCode || null, processStatus: measurement.processStatus,
|
||||
reviewStatus: measurement.finalStatus, skuCount: order.skuLines?.length || null,
|
||||
totalPackageCount: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
|
||||
totalPackageCount: measurement.actualPackageCount
|
||||
|| (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
|
||||
palletCount: measurement.palletCount || null,
|
||||
actualGrossWeightKg: measurement.actualGrossWeightKg || null,
|
||||
actualNetWeightKg: measurement.actualNetWeightKg || null,
|
||||
@@ -1317,7 +1329,7 @@ export class AcceptanceRun {
|
||||
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
|
||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound',
|
||||
'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount',
|
||||
'phase3LoadingIncrementalDetailCount', 'orderProfile', 'measurementPalletOverride']
|
||||
'phase3LoadingIncrementalDetailCount', 'orderProfile']
|
||||
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
||||
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
|
||||
}]))
|
||||
@@ -1362,9 +1374,6 @@ export class AcceptanceRun {
|
||||
: { forced: false, disabled: [] }
|
||||
this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow
|
||||
try {
|
||||
if (requireSharedOverflow) {
|
||||
this.context.measurementPalletOverride = await this.createPhase3FreshMeasurementPallet(warehouseId)
|
||||
}
|
||||
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
||||
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
|
||||
} finally {
|
||||
@@ -1397,7 +1406,6 @@ export class AcceptanceRun {
|
||||
const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC`
|
||||
this.marker = incrementalMarker
|
||||
this.context.phase3LoadingRequireSharedOverflow = false
|
||||
delete this.context.measurementPalletOverride
|
||||
this.context.phase3LoadingIncrementalPackageCount = 2
|
||||
this.context.phase3LoadingIncrementalDetailCount = 4
|
||||
// orders.mjs derives the actual SKU/package shape from waitSplitVariant. Override that
|
||||
@@ -1470,7 +1478,9 @@ export class AcceptanceRun {
|
||||
|
||||
async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) {
|
||||
const matrixMarker = this.marker
|
||||
const variantMarker = `${matrixMarker}-P4-${label}`
|
||||
const attempt = (this.phase4ProducerAttempts.get(label) || 0) + 1
|
||||
this.phase4ProducerAttempts.set(label, attempt)
|
||||
const variantMarker = `${matrixMarker}-P4-${label}${attempt === 1 ? '' : `-R${attempt}`}`
|
||||
const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker']
|
||||
const createdKeys = [
|
||||
'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking',
|
||||
@@ -1575,7 +1585,7 @@ export class AcceptanceRun {
|
||||
const hasStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
|
||||
.some((item) => item?.destinationType === 'FBA'
|
||||
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
|
||||
&& String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
|
||||
&& /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
|
||||
if (!hasStandardFbaProducer()) {
|
||||
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true)
|
||||
}
|
||||
@@ -1583,7 +1593,7 @@ export class AcceptanceRun {
|
||||
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true)
|
||||
}
|
||||
const hasSecondaryProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
|
||||
.some((item) => String(item?.resourceKey || '').includes('-P4-SECONDARY:'))
|
||||
.some((item) => /-P4-SECONDARY(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
|
||||
if (!hasSecondaryProducer()) {
|
||||
await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false)
|
||||
}
|
||||
@@ -1592,10 +1602,10 @@ export class AcceptanceRun {
|
||||
const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||
const standardFbaReady = containers.find((item) => item?.destinationType === 'FBA'
|
||||
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
|
||||
&& String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
|
||||
&& /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
|
||||
const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS')
|
||||
const awaitingVariants = containers.filter((item) => ['SECONDARY']
|
||||
.some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`)))
|
||||
.some((label) => new RegExp(`-P4-${label}(?:-R\\d+)?:`).test(String(item?.resourceKey || ''))))
|
||||
const shippingMatrixReady = dropshipReady?.shippingReady === true
|
||||
&& Boolean(dropshipReady?.carrierScheduleQueryId)
|
||||
&& standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId)
|
||||
@@ -1609,37 +1619,6 @@ export class AcceptanceRun {
|
||||
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
|
||||
}
|
||||
|
||||
async createPhase3FreshMeasurementPallet(warehouseId) {
|
||||
const module = 'phase3-loading-fixture'
|
||||
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
||||
const payload = {
|
||||
warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25,
|
||||
color: 'ORANGE', remark: `${this.marker}-inventory-snapshot-a0`,
|
||||
}
|
||||
const createResponse = await this.request('POST', '/api/platform/v1/pallets?tenantId=0', payload)
|
||||
const palletId = String(this.apiSuccess(module, '创建本轮库存快照专用称重托盘', createResponse,
|
||||
`warehouseId=${warehouseId}`, options) || '')
|
||||
if (!palletId) return null
|
||||
|
||||
const detailResponse = await this.request('GET',
|
||||
`/api/platform/v1/pallets/${encodeURIComponent(palletId)}?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const pallet = this.apiSuccess(module, '回读本轮库存快照专用称重托盘', detailResponse,
|
||||
`palletId=${palletId}`, options)
|
||||
const valid = String(pallet?.id || '') === palletId
|
||||
&& String(pallet?.warehouseId || '') === String(warehouseId)
|
||||
&& Boolean(String(pallet?.palletCode || '').trim())
|
||||
this.assert(module, '本轮专用称重托盘尚未复用历史占用身份', detailResponse, valid,
|
||||
`palletId=${palletId}, palletCode=${pallet?.palletCode || 'missing'}`, options)
|
||||
if (!valid) return null
|
||||
|
||||
const fixture = {
|
||||
id: palletId, code: String(pallet.palletCode), warehouseId: String(warehouseId),
|
||||
marker: this.marker, retainedReason: '测量审核后成为正式库存占用,保留为开发验收证据',
|
||||
}
|
||||
this.createdResources.phase3LoadingFreshMeasurementPallet = fixture
|
||||
return fixture
|
||||
}
|
||||
|
||||
async listAvailableLocationsForA0(warehouseId, module, options) {
|
||||
const locations = []
|
||||
const seen = new Set()
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function run(context) {
|
||||
|
||||
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
|
||||
|
||||
一件代发的 `inbound-measurement` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。
|
||||
一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
|
||||
|
||||
该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,必须先将全部原箱码经海外 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。
|
||||
|
||||
@@ -91,7 +91,8 @@ export async function run(context) {
|
||||
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
|
||||
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
|
||||
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
|
||||
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、整托移库、托盘拆装换绑和换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
|
||||
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
|
||||
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
|
||||
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
|
||||
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
|
||||
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
@@ -100,17 +101,17 @@ export async function run(context) {
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
|
||||
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜后复用 `ACTIVE + TRUCKING` 主数据,覆盖选车队、预约提柜、实际提柜、实际还柜及四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
|
||||
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
|
||||
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
|
||||
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
|
||||
|
||||
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
||||
|
||||
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
|
||||
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
|
||||
|
||||
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
|
||||
|
||||
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
|
||||
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
|
||||
|
||||
```powershell
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
|
||||
@@ -140,7 +141,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s
|
||||
|
||||
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;订舱放舱也不创建真实货柜或 allocation。港口取得真实柜号后,四字段 link 直接补全原模拟柜并创建 allocation,原 `containerBookingId/cabinetNo/loadingPlan/items` 不变;未关联、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
|
||||
|
||||
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。完整国内货柜履约装柜 fixture 通过平台托盘 API 创建本轮专用称重托盘,再交给通用测量模块;当普通位不足时由 Warehouse 自然分配到 A0,随后仅使用公开 A0 换绑接口制造库存版本变化,不直连数据库或调用内部 Warehouse 接口。主国内货柜履约链强制完成该专项,海外仓业务生产矩阵的附加柜只负责跨区生产,不重复要求 A0;附加柜若自然落入 A0 仍执行同一验证。批量待分柜入口仍只复用 `TP-001`/`YW-TP-002`,不创建托盘。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
|
||||
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。完整国内货柜履约装柜 fixture 与批量待分柜入口都只随机复用初始化的 `TP-001`/`YW-TP-002`,任一缺失即报告部署阻断,不调用托盘新增接口;当普通位不足时由 Warehouse 自然分配到 A0,随后仅使用公开 A0 换绑接口制造库存版本变化,不直连数据库、创建库位或调用内部 Warehouse 接口。模拟柜的 `channelGroupCode` 统一由 `smart-loading-channel-group.mjs` 根据订单渠道映射。主国内货柜履约链强制完成该专项,海外仓业务生产矩阵的附加柜只负责跨区生产,不重复要求 A0;附加柜若自然落入 A0 仍执行同一验证。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
|
||||
|
||||
动态增量还会读取计划明细中的服务端 `totalPackageCount/remainingPackageCount`,断言
|
||||
`remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查
|
||||
|
||||
@@ -43,4 +43,6 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
|
||||
assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/)
|
||||
assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/)
|
||||
assert.doesNotMatch(source, /push-preload/)
|
||||
assert.match(source, /import \{ channelGroup \} from '\.\/smart-loading-channel-group\.mjs'/)
|
||||
assert.equal((source.match(/channelGroupCode:\s*channelGroup\(/g) || []).length, 6)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const MODULE = 'coload-groups'
|
||||
const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true }
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const FIRST_LEG = '/api/platform/v1/first-leg'
|
||||
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
@@ -10,19 +11,6 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
||||
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function channelGroup(channelCode) {
|
||||
const value = String(channelCode || '').toUpperCase()
|
||||
if (value.startsWith('EXX_')) return 'EXX'
|
||||
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
|
||||
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
|
||||
if (value.startsWith('HEDE_')) return 'HEDE'
|
||||
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
|
||||
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
|
||||
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
|
||||
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
|
||||
if (value === 'OA_NEW_YORK_TRUCK') return value
|
||||
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
|
||||
}
|
||||
function code(response) { return id(response?.body?.code) || 'missing' }
|
||||
function evidence(response, extra = '') {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}`
|
||||
@@ -105,7 +93,7 @@ async function createSecondGroup(context, third, fourth) {
|
||||
|
||||
async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
|
||||
const simulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
|
||||
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
|
||||
@@ -131,7 +119,7 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
|
||||
|
||||
async function createNormalPushedPlan(context, fixture, suffix) {
|
||||
const simulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
|
||||
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
|
||||
@@ -522,7 +510,7 @@ export async function run(context) {
|
||||
`status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS)
|
||||
|
||||
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
||||
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP',
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP',
|
||||
capacityCbm: 33, maxWeightKg: 28000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse,
|
||||
@@ -613,7 +601,7 @@ export async function run(context) {
|
||||
`targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS)
|
||||
|
||||
const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
||||
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜',
|
||||
@@ -659,7 +647,7 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
||||
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse,
|
||||
@@ -978,7 +966,7 @@ export async function run(context) {
|
||||
const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse,
|
||||
evidence(uiGroupResponse), OPTIONS)
|
||||
const uiSimulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelCode: fifth.channelCode, warehouseId: fifth.warehouseId, containerType: '40HC',
|
||||
channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
|
||||
|
||||
@@ -98,7 +98,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
||||
return ok({ accessToken: 'client-token' })
|
||||
}
|
||||
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') {
|
||||
return ok({ records: [{ customerId: '11', customerCode: 'TEN11', customerName: 'Fixture Account',
|
||||
return ok({ records: [{ customerId: '11', customerCode: '00011', customerName: 'Fixture Account',
|
||||
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
|
||||
pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE',
|
||||
lastChangedAt: '2026-08-10T09:00:00' }] })
|
||||
|
||||
@@ -18,6 +18,7 @@ test('candidate plans only come from resources created by the current acceptance
|
||||
financeFullFixtures: { feeSources: [{ loadingPlanId: '104' }] },
|
||||
}), ['101', '102', '103', '104'])
|
||||
assert.doesNotMatch(source, /pageSize=\d+.*smart-loading|containers\?page|历史柜/)
|
||||
assert.match(source, /channelGroupCode:\s*channelGroup\(first\.channelCode\)/)
|
||||
})
|
||||
|
||||
test('allocation builder selects one real order per distinct customer and preserves exact cents', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ const MODULE = 'firstleg-container-fee-allocation'
|
||||
const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true }
|
||||
|
||||
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
|
||||
@@ -140,7 +141,7 @@ async function provisionCrossCustomerPlan(context) {
|
||||
|| id(first.receivingWarehouseId) !== id(second.receivingWarehouseId)
|
||||
|| first.channelCode !== second.channelCode) return null
|
||||
const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
|
||||
channelCode: first.channelCode, warehouseId: id(first.receivingWarehouseId),
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId),
|
||||
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
|
||||
|
||||
@@ -48,8 +48,8 @@ test('customer confirms chargeable quantity and manual unit price in one locked
|
||||
const source = await readFile(moduleFile, 'utf8')
|
||||
assert.match(source, /const orderId = id\(fixture\?\.orderId\)/)
|
||||
assert.match(source, /context\.context\?\.merchantQualificationFixture/)
|
||||
assert.match(source, /merchantFixture\?\.username/)
|
||||
assert.match(source, /merchantFixture\?\.password/)
|
||||
assert.match(source, /merchantFixture\?\.username \|\| MERCHANT_USERNAME/)
|
||||
assert.match(source, /merchantFixture\?\.password \|\| MERCHANT_PASSWORD/)
|
||||
assert.doesNotMatch(source, /const orderId = id\(context\.createdResources\.orders\?\.orderId\)/)
|
||||
assert.match(source, /Client 读取已推送计费量、对客单价和费用合计/)
|
||||
assert.match(source, /Client 一次确认计费量、对客单价和费用/)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const MODULE = 'inbound-fees'
|
||||
const OPTIONS = { scenario: 'inbound-fee-lifecycle', deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
|
||||
function id(value) { return value === null || value === undefined ? '' : String(value) }
|
||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
@@ -134,7 +136,8 @@ export async function run(context) {
|
||||
const orderId = id(fixture?.orderId)
|
||||
const merchantFixture = context.context?.merchantQualificationFixture
|
||||
const merchantLoginResponse = await context.request('POST', '/api/auth/login', {
|
||||
loginId: merchantFixture?.username, password: merchantFixture?.password,
|
||||
loginId: merchantFixture?.username || MERCHANT_USERNAME,
|
||||
password: merchantFixture?.password || MERCHANT_PASSWORD,
|
||||
}, '')
|
||||
const merchantLogin = context.apiSuccess(MODULE, 'Client 登录并准备确认计费量和费用', merchantLoginResponse,
|
||||
`orderId=${orderId || 'missing'}`, OPTIONS)
|
||||
|
||||
@@ -30,13 +30,6 @@ function validPdf(response) {
|
||||
|
||||
async function reusablePallets(context, warehouseId) {
|
||||
const cacheKey = asId(warehouseId)
|
||||
const override = context.context?.measurementPalletOverride
|
||||
if (asId(override?.warehouseId) === cacheKey && asId(override?.id)
|
||||
&& String(override?.code || '').trim()) {
|
||||
context.assert(MODULE, '使用编排层提供的本轮专用称重托盘', null, true,
|
||||
`warehouseId=${warehouseId}, palletId=${asId(override.id)}`, OPTIONS)
|
||||
return [{ id: asId(override.id), code: String(override.code) }]
|
||||
}
|
||||
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
|
||||
const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}`
|
||||
if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey)
|
||||
@@ -64,29 +57,11 @@ async function reusablePallets(context, warehouseId) {
|
||||
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
|
||||
&& String(item?.palletCode || '') === palletCode && asId(item?.id))
|
||||
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
|
||||
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=${exact ? 'not-needed' : 'create-marker-owned'}`, OPTIONS)
|
||||
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=disabled`, OPTIONS)
|
||||
if (!exact) {
|
||||
// A newly selected development warehouse may not have the historical fixture pallets.
|
||||
// Create one marker-owned pallet through the public platform API rather than coupling
|
||||
// measurement acceptance to another warehouse's seed data.
|
||||
const createResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', {
|
||||
warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25,
|
||||
color: 'ORANGE', remark: `${context.marker}-measurement-fixture`,
|
||||
})
|
||||
const createdId = asId(context.apiSuccess(MODULE, '创建本轮称重托盘', createResponse,
|
||||
`warehouseId=${warehouseId}`, OPTIONS))
|
||||
if (!createdId) return null
|
||||
const detailResponse = await context.request('GET',
|
||||
`/api/platform/v1/pallets/${encodeURIComponent(createdId)}?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const created = context.apiSuccess(MODULE, '回读本轮称重托盘', detailResponse,
|
||||
`warehouseId=${warehouseId}, palletId=${createdId}`, OPTIONS)
|
||||
const valid = asId(created?.id) === createdId && asId(created?.warehouseId) === cacheKey
|
||||
&& String(created?.palletCode || '').trim()
|
||||
context.assert(MODULE, '新建称重托盘归属当前仓库且编码完整', detailResponse, valid,
|
||||
`warehouseId=${warehouseId}, palletId=${createdId}, palletCode=${created?.palletCode || 'missing'}`, OPTIONS)
|
||||
if (!valid) return null
|
||||
pallets.push({ id: createdId, code: String(created.palletCode) })
|
||||
break
|
||||
context.assert(MODULE, '固定称重托盘主数据完整', response, false,
|
||||
`warehouseId=${warehouseId}, missing=${palletCode}`, OPTIONS)
|
||||
return null
|
||||
}
|
||||
pallets.push({ id: asId(exact.id), code: palletCode })
|
||||
}
|
||||
@@ -115,7 +90,7 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta
|
||||
|
||||
export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
|
||||
const pendingResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?keyword=${encodeURIComponent(inboundCode)}`)
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`)
|
||||
const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse,
|
||||
`inboundId=${inboundId}`, OPTIONS)
|
||||
const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId
|
||||
@@ -135,31 +110,17 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
`taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const totalCartons = Number(measurement?.actualPackageCount)
|
||||
const createPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/create-and-bind`
|
||||
const expectedPalletCount = Number(measurement?.expectedPalletCount)
|
||||
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
|
||||
const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) }
|
||||
let palletNo = ''
|
||||
const generatedPallets = Array.isArray(claimed?.pallets) ? claimed.pallets : []
|
||||
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', claimResponse,
|
||||
generatedPallets.length === expectedPalletCount && generatedPallets.every((item) =>
|
||||
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || '')),
|
||||
`expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS)
|
||||
if (!generatedPallets.length) return null
|
||||
|
||||
let countedCartons = 0
|
||||
const sealedCartonMarks = []
|
||||
let pallet = null
|
||||
|
||||
const palletSnapshot = (value) => JSON.stringify({
|
||||
counted: value?.countedCartons, bound: value?.boundCartons, unbound: value?.unboundCartons,
|
||||
pallets: value?.pallets?.map((item) => ({ id: asId(item.palletId), no: item.palletNo,
|
||||
count: item.cartonCount, status: item.status, version: item.version })),
|
||||
})
|
||||
async function rejectEmptyCreation(before, suffix) {
|
||||
const response = await context.request('POST', createPath,
|
||||
{ ...createPayload, operationId: `${context.marker}-dropship-empty-${suffix}`.slice(0, 128) })
|
||||
context.assert(MODULE, `无待绑箱创建托盘被拒绝-${suffix}`, response,
|
||||
response.http === 200 && String(response.body?.code) === 'A0409',
|
||||
`HTTP=${response.http}, code=${response.body?.code || 'missing'}`, OPTIONS)
|
||||
const afterResponse = await context.request('GET', taskPath)
|
||||
const after = context.apiSuccess(MODULE, `拒绝创建后回查任务-${suffix}`, afterResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `拒绝创建没有空托或计数漂移-${suffix}`, afterResponse,
|
||||
palletSnapshot(after) === palletSnapshot(before), `pallets=${after?.pallets?.length ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
await rejectEmptyCreation(claimed, 'before-count')
|
||||
|
||||
for (let index = 0; index < totalCartons; index += 1) {
|
||||
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
|
||||
@@ -167,18 +128,25 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/resolve?warehouseId=${warehouseId}&boxCode=${encodeURIComponent(boxCode)}`)
|
||||
const resolved = context.apiSuccess(MODULE, `解析第${index + 1}箱的冻结箱唛`, resolveResponse,
|
||||
`boxCode=${boxCode}`, OPTIONS)
|
||||
context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU数完整`, resolveResponse,
|
||||
resolved?.canCount === true && Number(resolved?.expectedSkuQuantity) > 0,
|
||||
`canCount=${resolved?.canCount ?? 'missing'}, expectedSkuQuantity=${resolved?.expectedSkuQuantity ?? 'missing'}`, OPTIONS)
|
||||
if (resolved?.canCount !== true || Number(resolved?.expectedSkuQuantity) < 1) continue
|
||||
const resolvedSkuLines = Array.isArray(resolved?.skuLines) ? resolved.skuLines : []
|
||||
context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU明细完整`, resolveResponse,
|
||||
resolved?.canCount === true && resolvedSkuLines.length > 0
|
||||
&& resolvedSkuLines.every((line) => asId(line?.inboundDetailId) && line?.skuCode
|
||||
&& Number(line?.expectedQuantity) > 0),
|
||||
`canCount=${resolved?.canCount ?? 'missing'}, skuLines=${resolvedSkuLines.length}`, OPTIONS)
|
||||
if (resolved?.canCount !== true || resolvedSkuLines.length < 1) continue
|
||||
|
||||
const countPayload = {
|
||||
warehouseId,
|
||||
boxCode,
|
||||
actualSkuQuantity: Number(resolved.expectedSkuQuantity),
|
||||
discrepancyAction: 'MATCH',
|
||||
discrepancyRemark: null,
|
||||
palletNo: index === 0 ? null : palletNo,
|
||||
skuLines: resolvedSkuLines.map((line) => ({
|
||||
inboundDetailId: asId(line.inboundDetailId),
|
||||
skuCode: line.skuCode,
|
||||
actualQuantity: Number(line.expectedQuantity),
|
||||
discrepancyAction: 'MATCH',
|
||||
discrepancyRemark: null,
|
||||
})),
|
||||
palletNo: index === 0 ? null : generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo,
|
||||
operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128),
|
||||
}
|
||||
const countResponse = await context.request('POST',
|
||||
@@ -187,10 +155,11 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
`boxCode=${boxCode}`, OPTIONS)
|
||||
context.assert(MODULE, `第${index + 1}箱清点结果与冻结箱唛一致`, countResponse,
|
||||
counted?.carton?.boxCode === resolved?.boxCode
|
||||
&& Number(counted?.carton?.expectedSkuQuantity) === Number(resolved?.expectedSkuQuantity)
|
||||
&& Number(counted?.carton?.actualSkuQuantity) === Number(resolved?.expectedSkuQuantity)
|
||||
&& counted?.carton?.discrepancyStatus === 'MATCH',
|
||||
`cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, discrepancy=${counted?.carton?.discrepancyStatus ?? 'missing'}`, OPTIONS)
|
||||
&& Array.isArray(counted?.carton?.skuLines)
|
||||
&& counted.carton.skuLines.length === resolvedSkuLines.length
|
||||
&& counted.carton.skuLines.every((line) => Number(line?.actualQuantity) === Number(line?.expectedQuantity)
|
||||
&& line?.discrepancyStatus === 'MATCH'),
|
||||
`cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, skuLines=${counted?.carton?.skuLines?.length ?? 'missing'}`, OPTIONS)
|
||||
if (counted?.carton?.boxCode === resolved?.boxCode) {
|
||||
countedCartons += 1
|
||||
sealedCartonMarks.push(resolved.boxCode)
|
||||
@@ -209,55 +178,57 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
&& Number(replayed?.unboundCartons) === Number(counted?.unboundCartons),
|
||||
`counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const bindResponse = await context.request('POST', createPath, createPayload)
|
||||
const bound = context.apiSuccess(MODULE, '系统发码创建托盘并绑定全部待组托箱', bindResponse,
|
||||
const firstPallet = generatedPallets[0]
|
||||
const bindPayload = { warehouseId, palletNo: firstPallet.palletNo,
|
||||
operationId: `${context.marker}-dropship-bind-first`.slice(0, 128) }
|
||||
const bindResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
||||
const bound = context.apiSuccess(MODULE, '后扫预生成托盘码并绑定全部待组托箱', bindResponse,
|
||||
`taskId=${taskId}`, OPTIONS)
|
||||
pallet = bound?.pallets?.find((item) => Number(item.cartonCount) === 1) || null
|
||||
palletNo = pallet?.palletNo || ''
|
||||
const pallet = bound?.pallets?.find((item) => asId(item.palletId) === asId(firstPallet.palletId)) || null
|
||||
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
||||
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
|
||||
&& pallet?.status === 'OPEN' && /^DP-\d{6}-\d{4}$/.test(palletNo)
|
||||
&& bound?.pallets?.length === 1,
|
||||
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
||||
&& bound?.pallets?.length === expectedPalletCount,
|
||||
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
||||
const createReplayResponse = await context.request('POST', createPath, createPayload)
|
||||
const createReplay = context.apiSuccess(MODULE, '系统发码同operationId重放', createReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '系统发码重放不新增托盘且业务码和计数稳定', createReplayResponse,
|
||||
palletSnapshot(createReplay) === palletSnapshot(bound), `palletNo=${palletNo}`, OPTIONS)
|
||||
await rejectEmptyCreation(bound, 'after-bind')
|
||||
} else {
|
||||
pallet = counted?.pallet || pallet
|
||||
const expectedPallet = generatedPallets[Math.min(index, generatedPallets.length - 1)]
|
||||
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
|
||||
counted?.pallet?.palletNo === palletNo && counted?.pallet?.status === 'OPEN',
|
||||
counted?.pallet?.palletNo === expectedPallet.palletNo && counted?.pallet?.status === 'OPEN',
|
||||
`palletNo=${counted?.pallet?.palletNo ?? 'missing'}, status=${counted?.pallet?.status ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
}
|
||||
context.assert(MODULE, '一件代发工作托盘覆盖测量冻结的全部箱数', null,
|
||||
totalCartons > 0 && countedCartons === totalCartons && Number(pallet?.cartonCount) === totalCartons,
|
||||
`counted=${countedCartons}, palletCartons=${pallet?.cartonCount ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS)
|
||||
const countedTaskResponse = await context.request('GET', taskPath)
|
||||
let sealed = context.apiSuccess(MODULE, '清点完成后回查预生成托盘分配', countedTaskResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '预生成托盘覆盖测量冻结的全部箱数', countedTaskResponse,
|
||||
totalCartons > 0 && countedCartons === totalCartons
|
||||
&& Number(sealed?.boundCartons) === totalCartons
|
||||
&& sealed?.pallets?.length === expectedPalletCount
|
||||
&& sealed?.pallets?.every((item) => Number(item?.cartonCount) > 0),
|
||||
`counted=${countedCartons}, bound=${sealed?.boundCartons ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS)
|
||||
|
||||
const palletId = asId(pallet?.palletId)
|
||||
if (!palletId) return null
|
||||
|
||||
const sealResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${palletId}/seal`, {
|
||||
warehouseId, expectedVersion: Number(pallet?.version),
|
||||
operationId: `${context.marker}-dropship-seal`.slice(0, 128),
|
||||
})
|
||||
const sealed = context.apiSuccess(MODULE, '封托并完成一件代发合托任务', sealResponse,
|
||||
`taskId=${taskId}, palletId=${palletId}`, OPTIONS)
|
||||
context.assert(MODULE, '全部箱数封托后入仓单允许进入WAIT_SPLIT', sealResponse,
|
||||
let lastSealResponse = countedTaskResponse
|
||||
for (let index = 0; index < (sealed?.pallets || []).length; index += 1) {
|
||||
const pallet = sealed.pallets.find((item) => item.status === 'OPEN')
|
||||
if (!pallet) break
|
||||
const sealResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${asId(pallet.palletId)}/seal`, {
|
||||
warehouseId, expectedVersion: Number(pallet.version),
|
||||
operationId: `${context.marker}-dropship-seal-${index + 1}`.slice(0, 128),
|
||||
})
|
||||
lastSealResponse = sealResponse
|
||||
sealed = context.apiSuccess(MODULE, `封托第${index + 1}个预生成托盘`, sealResponse,
|
||||
`taskId=${taskId}, palletNo=${pallet.palletNo}`, OPTIONS)
|
||||
}
|
||||
context.assert(MODULE, '全部箱数封托后入仓单允许进入WAIT_SPLIT', lastSealResponse,
|
||||
sealed?.taskStatus === 'COMPLETED'
|
||||
&& Number(sealed?.countedCartons) === Number(sealed?.totalCartons)
|
||||
&& Number(sealed?.boundCartons) === Number(sealed?.totalCartons)
|
||||
&& Number(sealed?.unboundCartons) === 0
|
||||
&& sealed?.pallets?.every((item) => item?.status === 'SEALED'),
|
||||
`taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS)
|
||||
const sealedReplayResponse = await context.request('POST', createPath, createPayload)
|
||||
const sealedReplay = context.apiSuccess(MODULE, '封托完成后系统发码原操作仍可重放', sealedReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse,
|
||||
sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed),
|
||||
`taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS)
|
||||
return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [palletNo] }
|
||||
return { ...sealed, sealedCartonMarks,
|
||||
sealedSourcePalletNos: (sealed?.pallets || []).map((item) => item.palletNo).filter(Boolean) }
|
||||
}
|
||||
|
||||
async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) {
|
||||
@@ -813,8 +784,10 @@ export async function run(context) {
|
||||
measured = context.apiSuccess(MODULE, '回查增量 fixture 动态测量明细', measuredResponse, '', OPTIONS)
|
||||
measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
|
||||
const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean))
|
||||
context.assert(MODULE, '增量 fixture 形成动态真实独立货物测量明细', measuredResponse,
|
||||
detailIds.size >= incrementalDetailCount
|
||||
const mixedDropship = context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
|
||||
context.assert(MODULE, mixedDropship
|
||||
? '混箱一件代发复用物理箱测量明细' : '增量 fixture 形成动态真实独立货物测量明细', measuredResponse,
|
||||
mixedDropship ? detailIds.size === 1 : detailIds.size >= incrementalDetailCount
|
||||
&& measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount,
|
||||
`measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS)
|
||||
}
|
||||
@@ -871,9 +844,13 @@ export async function run(context) {
|
||||
? 'ADD' : measuredItem.coefficientMode ?? null,
|
||||
remark: context.marker,
|
||||
}]
|
||||
const palletCount = Math.max(1, Math.min(originalPackageCount,
|
||||
Number(context.context?.waitSplitVariant?.palletCount || 1)))
|
||||
const draftPayload = {
|
||||
measurementId: measured.measurementId, version: Number(measured.version),
|
||||
actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`,
|
||||
expectedPalletCount: context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
|
||||
? palletCount : null,
|
||||
items: draftItems,
|
||||
}
|
||||
const draftResponse = await context.request('PUT',
|
||||
@@ -889,7 +866,6 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1))
|
||||
const warehouseResourcesReady = await provisionWarehouseResources(context, {
|
||||
inboundId, tenantId, warehouseId: asId(summary?.warehouseId), palletCount,
|
||||
})
|
||||
@@ -1069,21 +1045,40 @@ export async function run(context) {
|
||||
let sealedCartonMarks = []
|
||||
let sealedSourcePalletNos = []
|
||||
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
|
||||
const sealed = await completeDropshipPalletization(context, {
|
||||
inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
|
||||
})
|
||||
sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
|
||||
sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
|
||||
? (sealed.sealedSourcePalletNos || []).filter(Boolean) : []
|
||||
completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
|
||||
finalResponse = completed.response
|
||||
finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
|
||||
`pollAttempts=${completed.attempts}`, OPTIONS)
|
||||
context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse,
|
||||
finalMeasurement?.processStatus === 'WAIT_SPLIT'
|
||||
&& finalMeasurement?.measureReviewStatus === 'APPROVED'
|
||||
&& finalMeasurement?.measurementStatus === 'APPROVED',
|
||||
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS)
|
||||
if (context.options?.prepareDropshipManualCount) {
|
||||
const warehouseId = asId(summary?.warehouseId)
|
||||
const pendingResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`)
|
||||
const pending = context.apiSuccess(MODULE, '准备PDA人工清点任务', pendingResponse,
|
||||
`inboundId=${inboundId}`, OPTIONS)
|
||||
const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId)
|
||||
context.assert(MODULE, '任务停在未领取、未清点、未归托状态', pendingResponse,
|
||||
Boolean(asId(task?.taskId)) && task?.taskStatus === 'PENDING'
|
||||
&& Number(task?.countedCartons) === 0 && Number(task?.boundCartons) === 0
|
||||
&& Number(task?.totalCartons) === Number(finalMeasurement?.actualPackageCount),
|
||||
`taskId=${asId(task?.taskId) || 'missing'}, status=${task?.taskStatus ?? 'missing'}, counted=${task?.countedCartons ?? 'missing'}`, OPTIONS)
|
||||
context.createdResources.dropshipManualCount = {
|
||||
taskId: asId(task?.taskId), inboundId, inboundCode, warehouseId,
|
||||
warehouseName: task?.warehouseName || null, totalCartons: Number(task?.totalCartons || 0),
|
||||
taskStatus: task?.taskStatus || null,
|
||||
}
|
||||
} else {
|
||||
const sealed = await completeDropshipPalletization(context, {
|
||||
inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
|
||||
})
|
||||
sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
|
||||
sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
|
||||
? (sealed.sealedSourcePalletNos || []).filter(Boolean) : []
|
||||
completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
|
||||
finalResponse = completed.response
|
||||
finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
|
||||
`pollAttempts=${completed.attempts}`, OPTIONS)
|
||||
context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse,
|
||||
finalMeasurement?.processStatus === 'WAIT_SPLIT'
|
||||
&& finalMeasurement?.measureReviewStatus === 'APPROVED'
|
||||
&& finalMeasurement?.measurementStatus === 'APPROVED',
|
||||
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
const approvedScanResponse = await multipartRequest(context, scanPath, {
|
||||
@@ -1103,6 +1098,7 @@ export async function run(context) {
|
||||
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
|
||||
processStatus: finalMeasurement?.processStatus || null,
|
||||
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg,
|
||||
actualPackageCount: Number(finalMeasurement?.actualPackageCount || 0),
|
||||
destinationType: context.createdResources?.orders?.destinationType || null,
|
||||
destinationWarehouseId: context.createdResources?.orders?.destinationWarehouseId || null,
|
||||
destinationWarehouseCode: context.createdResources?.orders?.destinationWarehouseCode || null,
|
||||
|
||||
@@ -43,7 +43,7 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () =>
|
||||
assert.match(sql, /193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'纽约仓海外接柜入仓收货能力'/)
|
||||
assert.match(sql, /固定平台作业仓收货能力写入校验失败/)
|
||||
|
||||
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0]
|
||||
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?deleted = 0;/)?.[0]
|
||||
assert.ok(palletSeed, 'missing pallet initialization seed')
|
||||
assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/)
|
||||
})
|
||||
@@ -67,6 +67,19 @@ test('国内统一初始化在注释门禁前创建订单申报快照表', () =>
|
||||
}
|
||||
})
|
||||
|
||||
test('国内统一增量在 Firstleg 补列前重建已由 Warehouse 段删除的过程', () => {
|
||||
const warehouseDrop = sql.indexOf('DROP PROCEDURE IF EXISTS add_column_if_missing;',
|
||||
sql.indexOf('CALL assert_platform_public_resource_scope();'))
|
||||
const firstlegCall = sql.indexOf("CALL add_column_if_missing('fl_price_book_version', 'voided_by'")
|
||||
const firstlegCreate = sql.lastIndexOf('CREATE PROCEDURE add_column_if_missing(', firstlegCall)
|
||||
const firstlegDrop = sql.indexOf('DROP PROCEDURE IF EXISTS add_column_if_missing;', firstlegCall)
|
||||
|
||||
assert.ok(warehouseDrop >= 0, 'Warehouse migration must clean up its helper procedure')
|
||||
assert.ok(firstlegCreate > warehouseDrop, 'Firstleg must recreate the helper after the Warehouse cleanup')
|
||||
assert.ok(firstlegCreate < firstlegCall, 'Firstleg helper must exist before the price-version ALTER calls')
|
||||
assert.ok(firstlegDrop > firstlegCall, 'Firstleg must clean up the helper after its ALTER calls')
|
||||
})
|
||||
|
||||
test('海外统一初始化内联空库基线且不依赖模块 SQL', () => {
|
||||
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG/)
|
||||
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/)
|
||||
|
||||
@@ -28,14 +28,14 @@ test('随机线路只固定义乌仓并从客户真实可计价候选选择', ()
|
||||
assert.doesNotMatch(runner, /channel-name|channel-code/)
|
||||
})
|
||||
|
||||
test('称重优先复用固定托盘并允许为无基线仓创建本轮托盘', () => {
|
||||
test('称重只随机复用固定托盘且不创建新托盘', () => {
|
||||
assert.match(measurement, /REUSABLE_PALLET_CODES = Object\.freeze\(\['TP-001', 'YW-TP-002'\]\)/)
|
||||
assert.match(measurement, /context\.request\('GET',[\s\S]*\/api\/platform\/v1\/pallets\?page=1&size=20/)
|
||||
assert.match(measurement, /randomlyReusePalletIds\(candidates, palletCount/)
|
||||
assert.match(measurement, /随机复用原有称重托盘/)
|
||||
assert.match(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets\?tenantId=0'/)
|
||||
assert.match(measurement, /创建本轮称重托盘/)
|
||||
assert.match(measurement, /measurement-fixture/)
|
||||
assert.match(measurement, /固定称重托盘主数据完整/)
|
||||
assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets/)
|
||||
assert.doesNotMatch(acceptance, /createPhase3FreshMeasurementPallet/)
|
||||
})
|
||||
|
||||
test('称重脚本不新增库位且容量不足由仓库服务复用 A0 公共位', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
|
||||
assert.match(source, /\.\/orders\.mjs/)
|
||||
assert.match(source, /\.\/inbound-measurement\.mjs/)
|
||||
assert.match(source, /context\.request\('POST', CONTAINERS/)
|
||||
assert.match(source, /channelGroupCode:\s*channelGroup\(summary\.channelCode\)/)
|
||||
assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/)
|
||||
const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}`')
|
||||
const transportIndex = source.indexOf('ocean-booking-link')
|
||||
|
||||
@@ -9,6 +9,7 @@ const PRELOAD_STATUSES = ['EDITING', 'LOADING']
|
||||
const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED']
|
||||
|
||||
import { resolveOceanBookingAgent } from './ocean-booking-fixture.mjs'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
||||
@@ -127,7 +128,7 @@ async function createConfirmedContainer(context, source, label) {
|
||||
maxWeightKg: 28000 }],
|
||||
}
|
||||
const simulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HC',
|
||||
channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
||||
|
||||
@@ -15,6 +15,8 @@ test('默认全量验收先准备已审核商户再运行订单链路', () => {
|
||||
|
||||
test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => {
|
||||
assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/)
|
||||
assert.match(fixture, /required-package\?unifiedSocialCreditCode=\$\{encodeURIComponent\(unifiedSocialCreditCode\)\}/)
|
||||
assert.doesNotMatch(fixture, /packageForm\.append\('unifiedSocialCreditCode'/)
|
||||
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/)
|
||||
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/dimension-initialization/)
|
||||
assert.match(fixture, /status === 'READY'/)
|
||||
|
||||
@@ -62,9 +62,10 @@ export async function run(context) {
|
||||
const packageForm = new FormData()
|
||||
packageForm.append('businessLicense', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `business-${suffix}.png`)
|
||||
packageForm.append('legalPersonId', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `legal-person-${suffix}.png`)
|
||||
packageForm.append('unifiedSocialCreditCode', `91440300${suffix}`)
|
||||
const unifiedSocialCreditCode = `91440300${suffix}`
|
||||
const packageResponse = await context.requestWithBody('POST',
|
||||
`/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package`, packageForm)
|
||||
`/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`,
|
||||
packageForm)
|
||||
context.apiSuccess(MODULE, '通过 File 上传真实必填资质包', packageResponse,
|
||||
`tenantId=${tenantId}`, { deploymentBlocker: true })
|
||||
|
||||
|
||||
@@ -37,12 +37,15 @@ test('backend OpenAPI freezes a platform channel query scoped by originWarehouse
|
||||
'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI')
|
||||
})
|
||||
|
||||
test('released requirement association locks the selected channel onto the simulation', () => {
|
||||
test('released requirement association locks the selected channel group onto the simulation', () => {
|
||||
const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url))
|
||||
assert.match(fixture, /ocean-booking-link/)
|
||||
assert.match(fixture, /订舱放舱为 RELEASED 且不预生成真实货柜/)
|
||||
assert.match(fixture, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
|
||||
assert.match(fixture, /containerDetail\?\.channelCode === channelCode/)
|
||||
assert.match(fixture, /simulation\?\.channelGroupCode === channelGroup\(channelCode\)/)
|
||||
assert.match(fixture, /simulation\?\.channelCode == null/)
|
||||
assert.match(fixture, /containerDetail\?\.channelGroupCode === channelGroup\(channelCode\)/)
|
||||
assert.match(fixture, /containerDetail\?\.channelCode == null/)
|
||||
assert.match(fixture, /selectPlatformChannelIsolationPair/)
|
||||
assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
|
||||
import { eligibleOrderDestinations, findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
|
||||
|
||||
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
@@ -18,6 +18,16 @@ test('orders fixture verifies the complete pricing tuple before selecting an FBA
|
||||
assert.match(source, /warehouse\?\.status === 'ACTIVE'/)
|
||||
})
|
||||
|
||||
test('FBA order fixture excludes warehouses without the internal code required by pallet labels', () => {
|
||||
const warehouses = [
|
||||
{ id: 'abe3', warehouseCode: 'ABE3', status: 'ACTIVE', internalCode: null },
|
||||
{ id: 'abe4', warehouseCode: 'ABE4', status: 'ACTIVE', internalCode: '126' },
|
||||
{ id: 'abe8', warehouseCode: 'ABE8', status: 'INACTIVE', internalCode: '39' },
|
||||
]
|
||||
assert.deepEqual(eligibleOrderDestinations(warehouses, 'FBA').map((item) => item.warehouseCode), ['ABE4'])
|
||||
assert.deepEqual(eligibleOrderDestinations(warehouses, 'OVERSEAS').map((item) => item.warehouseCode), ['ABE3', 'ABE4'])
|
||||
})
|
||||
|
||||
test('pricing selection skips an ACTIVE domestic warehouse with an empty channel catalog', async () => {
|
||||
const channelCalls = []
|
||||
const result = await findPricedOrderRouteAcrossOrigins({
|
||||
|
||||
@@ -55,6 +55,12 @@ export function requestedOrderDestinationType(context) {
|
||||
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
|
||||
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
|
||||
}
|
||||
export function eligibleOrderDestinations(values, destinationType) {
|
||||
const active = (Array.isArray(values) ? values : []).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
return destinationType === 'FBA'
|
||||
? active.filter((warehouse) => String(warehouse?.internalCode || '').trim() !== '')
|
||||
: active
|
||||
}
|
||||
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
|
||||
const summary = orderDetail?.summary
|
||||
const task = success(taskResponse) ? taskResponse.body?.data : null
|
||||
@@ -251,13 +257,13 @@ async function loadMasterData(context, merchantToken) {
|
||||
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
|
||||
}
|
||||
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
let destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
let destinations = eligibleOrderDestinations(records(destinationPage), destinationType)
|
||||
if (Number(destinationPage?.total) > destinations.length) {
|
||||
const nextResponse = await request(context, 'GET',
|
||||
`/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
|
||||
undefined, merchantToken)
|
||||
const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse)
|
||||
destinations = [...destinations, ...records(nextPage).filter((warehouse) => warehouse?.status === 'ACTIVE')]
|
||||
destinations = [...destinations, ...eligibleOrderDestinations(records(nextPage), destinationType)]
|
||||
}
|
||||
if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) {
|
||||
throw new Error('缺少创建测试订单所需的收货仓或目的仓')
|
||||
@@ -396,6 +402,11 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
|
||||
? context.context.waitSplitVariant.packageCounts : []
|
||||
const selectedBoxes = BOXES.slice(0, skuCount)
|
||||
const orderProfile = context.context?.orderProfile || {}
|
||||
const dropship = String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase()
|
||||
=== 'ONE_CLICK_DROPSHIP'
|
||||
const mixedPackageCount = Math.max(1,
|
||||
Number(context.context?.waitSplitVariant?.totalPackageCount || packageCountPerSku))
|
||||
const mixedPackingGroupNo = `MIX-${context.marker}`.slice(0, 32)
|
||||
const isPrivate = master.destinationType === 'PRIVATE'
|
||||
const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS'
|
||||
const receiver = isPrivate ? {
|
||||
@@ -405,7 +416,7 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
|
||||
addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354',
|
||||
} : null
|
||||
return {
|
||||
fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(),
|
||||
fulfillmentType: dropship ? 'ONE_CLICK_DROPSHIP' : 'STANDARD',
|
||||
customerId: profile.id,
|
||||
customerName: companyName,
|
||||
transportMode: 'SEA', tradeTerm: 'FOB',
|
||||
@@ -433,17 +444,19 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
|
||||
},
|
||||
receiver,
|
||||
cargoes: selectedBoxes.map((box, index) => {
|
||||
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
|
||||
const unitsPerPackage = 10 + index
|
||||
const packageCount = Number(packageCounts[index] || packageCountPerSku)
|
||||
const packageCount = dropship ? mixedPackageCount : Number(packageCounts[index] || packageCountPerSku)
|
||||
const carton = dropship ? selectedBoxes[0] : box
|
||||
const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000
|
||||
return {
|
||||
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
|
||||
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
|
||||
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
|
||||
lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm,
|
||||
unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount,
|
||||
volumeCbm: volumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
|
||||
netWeightKg: (9 + index) * packageCount,
|
||||
packingGroupNo: dropship ? mixedPackingGroupNo : null,
|
||||
lengthCm: carton.lengthCm, widthCm: carton.widthCm, heightCm: carton.heightCm,
|
||||
unitsPerPackage, packageCount, grossWeightKg: (dropship ? 10 : 10 + index) * packageCount,
|
||||
volumeCbm: cartonVolumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
|
||||
netWeightKg: (dropship ? 9 : 9 + index) * packageCount,
|
||||
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
|
||||
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
|
||||
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-pr
|
||||
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
|
||||
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
||||
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
|
||||
const MODULE = 'phase3-container-loading'
|
||||
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
|
||||
@@ -208,7 +209,7 @@ export async function run(context) {
|
||||
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
|
||||
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
|
||||
const simulationResponse = await request(context, 'POST', CONTAINERS, {
|
||||
channelCode, warehouseId, containerType: primaryRequirement.containerType,
|
||||
channelGroupCode: channelGroup(channelCode), warehouseId, containerType: primaryRequirement.containerType,
|
||||
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, '新增内部模拟柜', simulationResponse,
|
||||
@@ -216,7 +217,8 @@ export async function run(context) {
|
||||
const containerId = id(simulation?.id)
|
||||
context.assert(MODULE, '模拟柜由服务端生成稳定身份且不伪造真实柜号或订舱', simulationResponse,
|
||||
Boolean(containerId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null
|
||||
&& simulation?.oceanBookingId == null && simulation?.channelCode === channelCode
|
||||
&& simulation?.oceanBookingId == null && simulation?.channelGroupCode === channelGroup(channelCode)
|
||||
&& simulation?.channelCode == null
|
||||
&& id(simulation?.warehouseId) === warehouseId,
|
||||
`containerBookingId=${containerId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}, oceanBookingId=${id(simulation?.oceanBookingId) || 'null'}`, OPTIONS)
|
||||
if (!containerId) return
|
||||
@@ -240,7 +242,8 @@ export async function run(context) {
|
||||
} else {
|
||||
const foreignChannel = channelIsolation.pair.foreign
|
||||
const foreignContainerResponse = await request(context, 'POST', CONTAINERS, {
|
||||
channelCode: foreignChannel.channelCode, warehouseId, containerType: primaryRequirement.containerType,
|
||||
channelGroupCode: channelGroup(foreignChannel.channelCode), warehouseId,
|
||||
containerType: primaryRequirement.containerType,
|
||||
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
|
||||
})
|
||||
const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS)
|
||||
@@ -288,9 +291,10 @@ export async function run(context) {
|
||||
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
|
||||
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|
||||
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
|
||||
&& containerDetail?.channelCode === channelCode
|
||||
&& containerDetail?.channelGroupCode === channelGroup(channelCode)
|
||||
&& containerDetail?.channelCode == null
|
||||
&& ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus),
|
||||
`planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS)
|
||||
`planStatus=${containerDetail?.planStatus || 'missing'}, channelGroup=${containerDetail?.channelGroupCode || 'missing'}`, OPTIONS)
|
||||
|
||||
const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo)
|
||||
const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`)
|
||||
@@ -517,7 +521,7 @@ export async function run(context) {
|
||||
`containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS)
|
||||
|
||||
const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, {
|
||||
channelCode, warehouseId, containerType: primaryRequirement.containerType,
|
||||
channelGroupCode: channelGroup(channelCode), warehouseId, containerType: primaryRequirement.containerType,
|
||||
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
|
||||
})
|
||||
const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
||||
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
|
||||
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'phase3-loading'
|
||||
@@ -135,20 +136,6 @@ function inventorySignature(snapshot) {
|
||||
})))
|
||||
}
|
||||
|
||||
function channelGroup(channelCode) {
|
||||
const value = String(channelCode || '').toUpperCase()
|
||||
if (value.startsWith('EXX_')) return 'EXX'
|
||||
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
|
||||
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
|
||||
if (value.startsWith('HEDE_')) return 'HEDE'
|
||||
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
|
||||
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
|
||||
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
|
||||
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
|
||||
if (value === 'OA_NEW_YORK_TRUCK') return value
|
||||
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
|
||||
}
|
||||
|
||||
function operationsSignature(operations) {
|
||||
return JSON.stringify((operations || []).map((operation) => ({
|
||||
id: id(operation?.id), operationId: operation?.operationId || null,
|
||||
@@ -226,7 +213,7 @@ export async function run(context) {
|
||||
maxWeightKg: 28000 }],
|
||||
}
|
||||
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
|
||||
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HC',
|
||||
channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
|
||||
|
||||
@@ -106,6 +106,14 @@ test('booking freezes the source customer channel and warehouse scope', () => {
|
||||
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/)
|
||||
})
|
||||
|
||||
test('split chain starts from the partial inbound and derives quantities from the real tree', () => {
|
||||
assert.match(source, /fixture\?\.incrementalInboundId \|\| fixture\?\.inboundId/)
|
||||
assert.match(source, /const originalPackageCount = Number\(firstTree\?\.originalPackageCount\)/)
|
||||
assert.match(source, /while \(activeRemainders\(tree\)\.length > 0\)/)
|
||||
assert.match(source, /sourcePackageCount = Number\(source\?\.packageCount\)/)
|
||||
assert.doesNotMatch(source, /snapshotMatches\(firstTree, 4, 1, 3, 1\)/)
|
||||
})
|
||||
|
||||
test('booking fixture never invents supplier names without formal IDs', () => {
|
||||
assert.match(source, /oceanBookingDraft/)
|
||||
assert.doesNotMatch(source, /SPLIT-SHIPPING|SPLIT-AGENT/)
|
||||
|
||||
@@ -10,6 +10,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
||||
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
||||
import { ensureInitialCalculated } from './inbound-fees.mjs'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
@@ -186,7 +187,8 @@ async function loadOneFragment(context, state) {
|
||||
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
||||
if (!carrier?.id) return null
|
||||
const simulationResponse = await request(context, 'POST', CONTAINERS, {
|
||||
channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(state.channelCode), warehouseId,
|
||||
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
||||
const containerId = id(simulation?.id)
|
||||
@@ -237,9 +239,9 @@ async function loadOneFragment(context, state) {
|
||||
const candidate = Array.isArray(candidates)
|
||||
? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId)
|
||||
: null
|
||||
context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且可用箱数正确`, candidatesResponse,
|
||||
context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且存在可用货物明细`, candidatesResponse,
|
||||
Boolean(candidate?.inboundId) && Boolean(candidate?.inboundDetailId)
|
||||
&& Number(candidate?.availablePackageCount) === sourcePackageCount,
|
||||
&& Number(candidate?.availablePackageCount) >= 1,
|
||||
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
|
||||
|
||||
@@ -484,7 +486,7 @@ async function loadOneFragment(context, state) {
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
|
||||
const tenantId = id(fixture?.tenantId)
|
||||
const inboundId = id(fixture?.inboundId)
|
||||
const inboundId = id(fixture?.incrementalInboundId || fixture?.inboundId)
|
||||
const warehouseId = id(fixture?.warehouseId)
|
||||
const containerBookingId = id(fixture?.containerBookingId)
|
||||
if (!tenantId || !inboundId || !warehouseId || !containerBookingId || fixture?.marker !== context.marker) {
|
||||
@@ -511,8 +513,20 @@ export async function run(context) {
|
||||
const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
|
||||
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
|
||||
`orderId=${orderId}, tenantId=${tenantId}`, OPTIONS)
|
||||
const originalPackageCount = Number(firstTree?.originalPackageCount)
|
||||
const initialLoadedPackageCount = Number(firstTree?.cumulativeLoadedPackageCount)
|
||||
const initialRemainingPackageCount = Number(firstTree?.currentRemainderPackageCount)
|
||||
const initialActive = activeRemainders(firstTree)
|
||||
const initialActiveSequence = Number(initialActive[0]?.splitSequence)
|
||||
context.assert(MODULE, '新批次由来源单承接已装且只创建一个余量子单', firstTreeResponse,
|
||||
treeShape(firstTree, orderId) && snapshotMatches(firstTree, 4, 1, 3, 1)
|
||||
treeShape(firstTree, orderId)
|
||||
&& Number.isInteger(originalPackageCount) && originalPackageCount > 1
|
||||
&& Number.isInteger(initialLoadedPackageCount) && initialLoadedPackageCount > 0
|
||||
&& Number.isInteger(initialRemainingPackageCount) && initialRemainingPackageCount > 0
|
||||
&& originalPackageCount === initialLoadedPackageCount + initialRemainingPackageCount
|
||||
&& initialActive.length === 1 && Number.isInteger(initialActiveSequence)
|
||||
&& snapshotMatches(firstTree, originalPackageCount, initialLoadedPackageCount,
|
||||
initialRemainingPackageCount, initialActiveSequence)
|
||||
&& firstTree.children.filter((row) => row.splitRole === 'LOADED').length === 1
|
||||
&& firstTree.children.filter((row) => row.splitRole === 'REMAINDER').length === 1,
|
||||
`original=${firstTree?.originalPackageCount ?? 'missing'}, loaded=${firstTree?.cumulativeLoadedPackageCount ?? 'missing'}, remaining=${firstTree?.currentRemainderPackageCount ?? 'missing'}`, OPTIONS)
|
||||
@@ -521,7 +535,8 @@ export async function run(context) {
|
||||
const firstLoaded = firstTree.children.find((row) => row.splitRole === 'LOADED')
|
||||
context.assert(MODULE, '新拆分未生成 LOADED 子单且完成目标保持来源单', firstTreeResponse,
|
||||
id(firstLoaded?.orderId) === orderId && id(firstLoaded?.sourceOrderId) === orderId
|
||||
&& firstLoaded?.orderNo === firstTree.parentOrderNo && Number(firstLoaded?.packageCount) === 1,
|
||||
&& firstLoaded?.orderNo === firstTree.parentOrderNo
|
||||
&& Number(firstLoaded?.packageCount) === initialLoadedPackageCount,
|
||||
`loadedOrderId=${id(firstLoaded?.orderId) || 'missing'}, sourceOrderId=${id(firstLoaded?.sourceOrderId) || 'missing'}`, OPTIONS)
|
||||
|
||||
const merchantLogin = await context.request('POST', '/api/auth/login', {
|
||||
@@ -534,7 +549,9 @@ export async function run(context) {
|
||||
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken)
|
||||
const merchantTree = context.apiSuccess(MODULE, '客户查询本客户根履约树', merchantTreeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '客户履约树与平台数量聚合一致', merchantTreeResponse,
|
||||
treeShape(merchantTree, orderId) && snapshotMatches(merchantTree, 4, 1, 3, 1),
|
||||
treeShape(merchantTree, orderId)
|
||||
&& snapshotMatches(merchantTree, originalPackageCount, initialLoadedPackageCount,
|
||||
initialRemainingPackageCount, initialActiveSequence),
|
||||
`children=${merchantTree?.children?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const forbidden = await request(context, 'GET', treePath(orderId, tenantId), undefined, {}, merchantToken)
|
||||
@@ -569,41 +586,54 @@ export async function run(context) {
|
||||
cleanupNote: '公开 API 不允许删除订单/柜次/PDA审计记录;只有全部分片完成且无活动余量时标记业务清理完成。',
|
||||
}
|
||||
let tree = firstTree
|
||||
for (const step of [
|
||||
{ sourcePackageCount: 3, expectedLoaded: 2, expectedRemaining: 2, sourceSuffix: 1, activeSuffix: 2 },
|
||||
{ sourcePackageCount: 2, expectedLoaded: 3, expectedRemaining: 1, sourceSuffix: 2, activeSuffix: 3 },
|
||||
{ sourcePackageCount: 1, expectedLoaded: 4, expectedRemaining: 0, sourceSuffix: 3, activeSuffix: null },
|
||||
]) {
|
||||
let completedFragments = 0
|
||||
while (activeRemainders(tree).length > 0) {
|
||||
const active = activeRemainders(tree)
|
||||
const source = active[0]
|
||||
context.assert(MODULE, `链式拆分来源固定为根编号 -${step.sourceSuffix}`, null,
|
||||
active.length === 1 && source?.orderNo === `${tree.parentOrderNo}-${step.sourceSuffix}`,
|
||||
const sourceSuffix = Number(source?.splitSequence)
|
||||
const sourcePackageCount = Number(source?.packageCount)
|
||||
const beforeLoaded = Number(tree.cumulativeLoadedPackageCount)
|
||||
const beforeRemaining = Number(tree.currentRemainderPackageCount)
|
||||
context.assert(MODULE, `链式拆分来源固定为根编号 -${sourceSuffix}`, null,
|
||||
active.length === 1 && Number.isInteger(sourceSuffix) && sourceSuffix > 0
|
||||
&& source?.orderNo === `${tree.parentOrderNo}-${sourceSuffix}`
|
||||
&& Number.isInteger(sourcePackageCount) && sourcePackageCount === beforeRemaining,
|
||||
`active=${active.map((row) => row.orderNo).join(',') || 'none'}`, OPTIONS)
|
||||
if (!source) return
|
||||
|
||||
const result = await loadOneFragment(context, {
|
||||
tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo,
|
||||
customerName, channelCode, channelName, warehouseId, warehouseName,
|
||||
sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix,
|
||||
sourcePackageCount, sequence: sourceSuffix,
|
||||
})
|
||||
if (!result?.tree) return
|
||||
tree = result.tree
|
||||
completedFragments += 1
|
||||
const expectedLoaded = beforeLoaded + 1
|
||||
const expectedRemaining = beforeRemaining - 1
|
||||
const activeSuffix = expectedRemaining > 0 ? sourceSuffix + 1 : null
|
||||
Object.assign(context.createdResources.phase3OrderSplitting, {
|
||||
activeRemainderCount: activeRemainders(tree).length,
|
||||
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
|
||||
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
|
||||
})
|
||||
context.assert(MODULE, `完成第 ${step.sourceSuffix + 1} 个分片后根数量守恒`, result.treeResponse,
|
||||
context.assert(MODULE, `完成余量分片 -${sourceSuffix} 后根数量守恒`, result.treeResponse,
|
||||
treeShape(tree, orderId)
|
||||
&& snapshotMatches(tree, 4, step.expectedLoaded, step.expectedRemaining, step.activeSuffix),
|
||||
&& snapshotMatches(tree, originalPackageCount, expectedLoaded, expectedRemaining, activeSuffix),
|
||||
`original=${tree.originalPackageCount}, loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
|
||||
if (completedFragments > originalPackageCount) {
|
||||
context.addStep(MODULE, '余量链在原始箱数边界内收敛', null, false,
|
||||
`original=${originalPackageCount}, fragments=${completedFragments}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const remainderCodes = tree.children.filter((row) => row.splitRole === 'REMAINDER').map((row) => row.orderNo)
|
||||
const expectedRemainderCodes = Array.from({ length: completedFragments }, (_, index) =>
|
||||
`${tree.parentOrderNo}-${initialActiveSequence + index}`)
|
||||
context.assert(MODULE, '余量子单始终按根编号全局递增且禁止嵌套编号', null,
|
||||
JSON.stringify(remainderCodes) === JSON.stringify([
|
||||
`${tree.parentOrderNo}-1`, `${tree.parentOrderNo}-2`, `${tree.parentOrderNo}-3`,
|
||||
]) && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
|
||||
JSON.stringify(remainderCodes) === JSON.stringify(expectedRemainderCodes)
|
||||
&& remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
|
||||
`codes=${remainderCodes.join(',')}`, OPTIONS)
|
||||
context.assert(MODULE, '最后一次全量装完不再生成子单且根整体完成', null,
|
||||
activeRemainders(tree).length === 0 && Number(tree.currentRemainderPackageCount) === 0
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function run(context) {
|
||||
`${taskPath}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const taskDetail = context.apiSuccess(MODULE, '按稳定 taskId 读取 PDA 任务详情', taskDetailResponse, '', OPTIONS)
|
||||
const pendingResponse = await request(context, 'GET',
|
||||
`/api/platform/v1/first-leg/pda-loading/tasks/pending?keyword=${encodeURIComponent(containerNo)}`)
|
||||
`/api/platform/v1/first-leg/pda-loading/tasks/pending?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}`)
|
||||
const pendingTasks = context.apiSuccess(MODULE, '读取当前操作员 PDA pending 列表', pendingResponse, '', OPTIONS)
|
||||
const pendingTask = Array.isArray(pendingTasks)
|
||||
? pendingTasks.find((item) => id(item?.taskId) === taskId)
|
||||
|
||||
@@ -434,7 +434,9 @@ test('container loading permits manual cross-channel allocation and keeps stable
|
||||
'换柜号后按稳定计划身份回查 PDA 任务',
|
||||
'换柜号不改变任务计划柜次柜次号和库存快照身份',
|
||||
])
|
||||
assert.match(value, /containerDetail\?\.channelCode === channelCode/)
|
||||
assert.match(value, /simulation\?\.channelGroupCode === channelGroup\(channelCode\)/)
|
||||
assert.match(value, /simulation\?\.channelCode == null/)
|
||||
assert.match(value, /containerDetail\?\.channelGroupCode === channelGroup\(channelCode\)/)
|
||||
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
|
||||
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
|
||||
assert.match(value, /pdaIdentityStable: true/)
|
||||
|
||||
@@ -71,6 +71,8 @@ test('canonical states, ten ordered nodes, exception codes and complete payload
|
||||
test('actual node timestamps advance from the frozen outbound-confirm fact instead of the runner timezone', () => {
|
||||
assert.match(source, /Date\.parse\(`\$\{frozenLatestActual\}Z`\)/)
|
||||
assert.match(source, /frozenLatestActualMs\) \? Date\.now\(\) : frozenLatestActualMs/)
|
||||
assert.equal((source.match(/observedAt: localWallIso\(new Date\(\)\)/g) || []).length, 3)
|
||||
assert.match(source, /const observedAt = localWallIso\(new Date\(\)\)/)
|
||||
})
|
||||
|
||||
test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint', () => {
|
||||
@@ -185,7 +187,7 @@ function markerOwnedContext() {
|
||||
if (body.actualAt != null
|
||||
&& NODE_SEQUENCES[body.standardNode] <= NODE_SEQUENCES[task.latestActualNode]) {
|
||||
return conflict(`req-monotonic-${key}`,
|
||||
`人工实际节点必须严格晚于当前最新实际节点:${task.latestActualNode}`)
|
||||
'实际船节点必须按业务顺序向后推进')
|
||||
}
|
||||
const providerEventId = `manual-${String(nextId)}`
|
||||
nodes.push({
|
||||
|
||||
@@ -34,10 +34,15 @@ function codeOf(response) { return response?.body?.code == null ? 'missing' : St
|
||||
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
||||
function monotonicRejected(response) {
|
||||
return rejected(response)
|
||||
&& String(response?.body?.message || '').includes('人工实际节点必须严格晚于当前最新实际节点')
|
||||
&& String(response?.body?.message || '').includes('实际船节点必须按业务顺序向后推进')
|
||||
&& Boolean(response?.requestId) && Boolean(response?.traceId)
|
||||
}
|
||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||
function localWallIso(value) {
|
||||
const pad = (part) => String(part).padStart(2, '0')
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
|
||||
+ `T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`
|
||||
}
|
||||
|
||||
export function markerOwnedCarrierQuery(context) {
|
||||
const resource = context?.createdResources?.phase3CarrierScheduleQuery
|
||||
@@ -167,7 +172,7 @@ export async function runUntilPickupReady(context) {
|
||||
ownershipMatches, `registered=${queryId}, actual=${id(initialTask?.id) || 'missing'}`, OPTIONS)
|
||||
if (!ownershipMatches) return
|
||||
|
||||
const observedAt = localIso(new Date())
|
||||
const observedAt = localWallIso(new Date())
|
||||
const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000))
|
||||
const estimatedBody = manualNodePayload({ expectedVersion: Number(initialTask.version),
|
||||
standardNode: 'ARRIVED_DESTINATION_PORT', observedAt, estimatedAt, marker: context.marker })
|
||||
@@ -226,7 +231,7 @@ export async function runUntilPickupReady(context) {
|
||||
|
||||
const sameNodeSnapshot = actualProgressSnapshot(detail)
|
||||
const sameNodeBody = manualNodePayload({ expectedVersion: version,
|
||||
standardNode: detail.query.query.latestActualNode, observedAt: localIso(new Date()),
|
||||
standardNode: detail.query.query.latestActualNode, observedAt: localWallIso(new Date()),
|
||||
actualAt: localIso(new Date(baseActual)), marker: context.marker, index: frozenLatestNodeIndex + 1 })
|
||||
const sameNodeResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody,
|
||||
context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) })
|
||||
@@ -250,7 +255,7 @@ export async function runUntilPickupReady(context) {
|
||||
const standardNode = STANDARD_NODES[index]
|
||||
const actualAt = localIso(new Date(baseActual + index * 60_000))
|
||||
const body = manualNodePayload({ expectedVersion: version, standardNode,
|
||||
observedAt: localIso(new Date()), actualAt, marker: context.marker, index: index + 1 })
|
||||
observedAt: localWallIso(new Date()), actualAt, marker: context.marker, index: index + 1 })
|
||||
const key = `${context.marker}-carrier-actual-${String(index + 1).padStart(2, '0')}`.slice(0, 128)
|
||||
const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body,
|
||||
context.token, { 'Idempotency-Key': key })
|
||||
@@ -275,7 +280,7 @@ export async function runUntilPickupReady(context) {
|
||||
const progressedSnapshot = actualProgressSnapshot(detail)
|
||||
const earlierNode = STANDARD_NODES[index - 1]
|
||||
const earlierBody = manualNodePayload({ expectedVersion: version, standardNode: earlierNode,
|
||||
observedAt: localIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)),
|
||||
observedAt: localWallIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)),
|
||||
marker: context.marker, index })
|
||||
const earlierResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody,
|
||||
context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) })
|
||||
|
||||
@@ -30,6 +30,7 @@ test('module never borrows historical order or inventory candidates', () => {
|
||||
})
|
||||
|
||||
test('relabel transfer and discard cover create replay conflict list detail and legal initial states', () => {
|
||||
assert.match(source, /deliveryMethod: 'TRUCK', printShippingLabel: false/)
|
||||
assert.match(source, /chargeAmount: 0\.01/)
|
||||
assert.match(source, /cartonMark:/)
|
||||
assert.match(source, /fbaCartonMark:/)
|
||||
|
||||
@@ -108,6 +108,7 @@ async function verifyRelabel(context, token, order) {
|
||||
operationWarehouseId: order.row.overseasWarehouseId,
|
||||
labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006',
|
||||
chargeAmount: 0.01,
|
||||
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255),
|
||||
items: [{
|
||||
sourceInventoryBalanceId: id(source.inventoryBalanceId),
|
||||
|
||||
@@ -107,10 +107,12 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /安全投影已收敛/)
|
||||
assert.match(source, /projectionReady = success\(projectionResponse\) \|\| notFound\(projectionResponse\)/)
|
||||
assert.match(source, /setTimeout\(resolve, 500\)/)
|
||||
assert.match(source, /超收被拒绝/)
|
||||
assert.match(source, /重复累计不产生超收/)
|
||||
assert.match(source, /重复箱新键被拒绝/)
|
||||
assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/)
|
||||
assert.match(source, /packageCount = Number\(inbound\.pendingPackages \|\| 0\)/)
|
||||
assert.match(source, /inbounds\.reduce\(\(total, inbound\) => total \+ Number\(inbound\.totalPackages \|\| 0\), 0\)/)
|
||||
assert.match(source, /resolvedInboundCodes\.includes\(scannedInboundCode\)/)
|
||||
assert.match(source, /duplicate\.data\?\.result === 'ALREADY_RECEIVED'/)
|
||||
assert.match(source, /if \(packageCount <= 0\) return true/)
|
||||
assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/)
|
||||
assert.match(source, /模式硬拒绝/)
|
||||
|
||||
@@ -5,7 +5,7 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { auxiliaryPalletFbaWarehouseCode, run } from './phase4-overseas-pda-operations.mjs'
|
||||
import { run } from './phase4-overseas-pda-operations.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
|
||||
@@ -29,6 +29,8 @@ test('receiving hands off only transient credentials and completed warehouse fac
|
||||
assert.match(receivingSource, /context\.context\.phase4OverseasPdaOperations = \{/)
|
||||
assert.match(receivingSource, /completedFixtures: \[\]/)
|
||||
assert.match(receivingSource, /merchantToken,/)
|
||||
assert.match(receivingSource, /const refreshedMerchantToken = await loginMerchantForNegativeProbe\(context\)/)
|
||||
assert.match(receivingSource, /phase4OverseasPdaOperations\.merchantToken = refreshedMerchantToken/)
|
||||
assert.match(receivingSource, /if \(!handoffReady\) \{[\s\S]*await cleanup\(\)/)
|
||||
assert.doesNotMatch(receivingSource, /createdResources\.phase4OverseasPdaOperations/)
|
||||
assert.doesNotMatch(receivingSource, /primary: primary \? \{[^}]*password/)
|
||||
@@ -66,16 +68,25 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
assert.match(source, /traceId=/)
|
||||
})
|
||||
|
||||
test('auxiliary pallets use the authoritative inbound FBA code and never the business destination fallback', () => {
|
||||
assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ' ABE3 '), 'ABE3')
|
||||
assert.equal(auxiliaryPalletFbaWarehouseCode('OVERSEAS', 'ABE3'), null)
|
||||
assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ''), null)
|
||||
test('PDA operations reuse received plan pallets and the public location without creating resources', () => {
|
||||
assert.match(receivingSource,
|
||||
/destinationType: inbound\.destinationType,[\s\S]*fbaWarehouseCode: inbound\.destinationType === 'FBA' \? inbound\.fbaWarehouseCode : null/)
|
||||
assert.match(source, /fbaWarehouseCode,\s*\n\s*remark:/)
|
||||
assert.match(source, /createLocation\(context, fixture\.overseasWarehouseId, `\$\{suffix\}-PALLET`\)/)
|
||||
assert.match(source, /locationId: location\.locationId/)
|
||||
assert.doesNotMatch(source, /createEmptyPallet\([^\n]*main\.destinationWarehouseCode/)
|
||||
/auxiliaryPallets: generatedContainerPallets\.filter/)
|
||||
assert.match(source, /resolvePublicLocation/)
|
||||
assert.match(source, /resolveReusableAuxiliaryPallet/)
|
||||
assert.match(source, /locationType === 'VIRTUAL'/)
|
||||
assert.match(source, /P3托盘已在公共库位不重复移库/)
|
||||
assert.doesNotMatch(source, /warehouse-admin[^'`]*\/locations`,\s*\{/)
|
||||
assert.doesNotMatch(source, /warehouse-admin[^'`]*\/pallets`,\s*\{/)
|
||||
assert.doesNotMatch(source, /createLocation|createEmptyPallet/)
|
||||
})
|
||||
|
||||
test('P5 follows warehouse review, customer confirmation and fulfillment completion in order', () => {
|
||||
assert.match(source, /applicationStatus === 'CUSTOMER_CONFIRM_PENDING'/)
|
||||
assert.match(source, /const merchantRelabelBase = '\/api\/merchant\/v1\/last-leg\/relabel-applications'/)
|
||||
assert.match(source, /`\$\{merchantRelabelBase\}\/\$\{encodeURIComponent\(relabelNo\)\}\/confirm-completion`/)
|
||||
assert.match(source, /status === 'READY_FOR_OUTBOUND'/)
|
||||
assert.match(source, /status: 'COMPLETED'/)
|
||||
assert.match(source, /walletStatus === 'SETTLED'/)
|
||||
})
|
||||
|
||||
test('fixture failure still disables both picker accounts and removes all transient tokens', async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-pda'
|
||||
const SCENARIO = 'phase4-overseas-pda-container-receiving'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
@@ -91,8 +93,10 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
|
||||
|
||||
async function loginMerchantForNegativeProbe(context) {
|
||||
const fixture = context.context?.merchantQualificationFixture
|
||||
if (!fixture?.username || !fixture?.password) return null
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
||||
const username = fixture?.username || context.options?.merchantUsername
|
||||
const password = fixture?.password || context.options?.merchantPassword
|
||||
if (!username || !password) return null
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
const data = success(response) ? response.body?.data : null
|
||||
context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response,
|
||||
success(response) && nonBlank(data?.overseasAccessToken),
|
||||
@@ -204,31 +208,38 @@ async function resolveGeneratedPallet(context, inbound) {
|
||||
async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) {
|
||||
const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存',
|
||||
'/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode })
|
||||
const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : []
|
||||
context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response,
|
||||
success(inventory.response) && inventory.data?.queryCode === inboundCode
|
||||
&& Array.isArray(inventory.data?.items) && inventory.data.items.length > 0
|
||||
&& inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId
|
||||
&& id(item?.locationId) === pallet.locationId && Number(item?.availablePackages) > 0),
|
||||
&& inventoryItems.length > 0
|
||||
&& inventoryItems.every((item) => longId(id(item?.palletId))
|
||||
&& longId(id(item?.locationId)) && nonBlank(item?.palletCode)
|
||||
&& nonBlank(item?.locationCode) && Number(item?.availablePackages) > 0),
|
||||
`items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS)
|
||||
const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : []
|
||||
context.assert(MODULE, `${destinationType}实收形成服务端SKU库存账`, inventory.response,
|
||||
success(inventory.response) && inventoryItems.length > 0
|
||||
&& inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0),
|
||||
`skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS)
|
||||
|
||||
const occupied = [...inventoryItems].sort((left, right) =>
|
||||
Number(right?.availablePackages || 0) - Number(left?.availablePackages || 0))[0]
|
||||
const actualPalletCode = occupied?.palletCode || pallet.palletCode
|
||||
const actualPalletId = id(occupied?.palletId || pallet.palletId)
|
||||
const actualLocationCode = occupied?.locationCode || pallet.locationCode
|
||||
const actualLocationId = id(occupied?.locationId || pallet.locationId)
|
||||
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
|
||||
'/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode })
|
||||
'/api/pda/v1/last-leg/locations/resolve', { code: actualLocationCode })
|
||||
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
|
||||
success(location.response) && id(location.data?.locationId) === pallet.locationId
|
||||
&& location.data?.locationCode === pallet.locationCode
|
||||
success(location.response) && id(location.data?.locationId) === actualLocationId
|
||||
&& location.data?.locationCode === actualLocationCode
|
||||
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
|
||||
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
|
||||
|
||||
const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容',
|
||||
'/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode })
|
||||
'/api/pda/v1/last-leg/pallets/resolve', { code: actualPalletCode })
|
||||
context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response,
|
||||
success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === pallet.palletId
|
||||
&& id(resolvedPallet.data?.locationId) === pallet.locationId
|
||||
success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === actualPalletId
|
||||
&& id(resolvedPallet.data?.locationId) === actualLocationId
|
||||
&& Array.isArray(resolvedPallet.data?.inventory) && resolvedPallet.data.inventory.length > 0
|
||||
&& Number(resolvedPallet.data?.occupiedPackages) > 0,
|
||||
`palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS)
|
||||
@@ -242,7 +253,8 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
|
||||
&& Number(history.data?.page) === 1 && Number(history.data?.size) === 50,
|
||||
`records=${historyRows.length}; ${trace(history.response)}`, OPTIONS)
|
||||
return success(inventory.response) && success(location.response) && success(resolvedPallet.response)
|
||||
? { inboundCode, pallet: resolvedPallet.data, location: location.data, inventory: inventory.data?.items || [] }
|
||||
? { inboundCode, pallet: resolvedPallet.data, location: location.data,
|
||||
inventory: inventoryItems.filter((item) => id(item?.palletId) === actualPalletId) }
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -465,12 +477,17 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa
|
||||
continue
|
||||
}
|
||||
const resolved = response.body?.data
|
||||
const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode)
|
||||
const delimiter = cartonMark.indexOf('#')
|
||||
const scannedInboundCode = delimiter > 0 ? cartonMark.slice(0, delimiter).trim() : ''
|
||||
const resolvedInboundCodes = Array.isArray(resolved?.inboundCodes)
|
||||
? resolved.inboundCodes.map(id) : [id(resolved?.inboundCode)].filter(nonBlank)
|
||||
const matches = inbounds.filter((item) => item.inboundCode === scannedInboundCode)
|
||||
const destinations = new Set(matches.map((item) => item?.destinationType).filter(nonBlank))
|
||||
const unique = success(response) && nonBlank(resolved?.inboundCode) && !Array.isArray(resolved?.inboundCode)
|
||||
&& matches.length === 1 && destinations.size === 1
|
||||
&& ['FBA', 'OVERSEAS'].includes(resolved?.destinationType)
|
||||
&& destinations.has(resolved?.destinationType)
|
||||
&& resolvedInboundCodes.includes(scannedInboundCode)
|
||||
&& (!strictDropship || (resolved?.cartonMark === cartonMark
|
||||
&& resolved?.inboundCode === dropshipProducer.inboundCode && resolved?.destinationType === dropshipProducer.destinationType))
|
||||
context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response,
|
||||
@@ -521,29 +538,33 @@ async function probeCartonResolveNegatives(context, token, current) {
|
||||
}
|
||||
}
|
||||
|
||||
async function receiveFba(context, token, inbound, pallet, cartonFixtures, index) {
|
||||
const resolvedCartons = cartonFixtures.filter((item) => item.inbound.inboundCode === inbound.inboundCode
|
||||
async function receiveFba(context, token, inbounds, pallet, cartonFixtures, index) {
|
||||
const inboundCodes = new Set(inbounds.map((item) => item.inboundCode))
|
||||
const resolvedCartons = cartonFixtures.filter((item) => inboundCodes.has(item.inbound.inboundCode)
|
||||
&& item.resolved.destinationType === 'FBA')
|
||||
if (!resolvedCartons.length) {
|
||||
blockerGap(context, `FBA入仓码${inbound.inboundCode}箱码解析`, '没有冻结箱码以200/code 200唯一解析到当前FBA入仓码')
|
||||
if (resolvedCartons.length !== inbounds.length) {
|
||||
blockerGap(context, `FBA组${[...inboundCodes].join('|')}箱码解析`, '没有为FBA组内每个入仓码完成唯一解析')
|
||||
return false
|
||||
}
|
||||
const packageCount = Number(inbound.pendingPackages || 0)
|
||||
const packageCount = inbounds.reduce((total, inbound) => total + Number(inbound.totalPackages || 0), 0)
|
||||
if (packageCount <= 0) return true
|
||||
const body = { inboundCode: inbound.inboundCode, palletCode: pallet.palletCode,
|
||||
const body = { inboundCode: inbounds[0].inboundCode, palletCode: pallet.palletCode,
|
||||
packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) }
|
||||
const first = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}按箱数实收`,
|
||||
const first = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}按累计箱数实收`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
||||
if (!success(first.response)) return false
|
||||
const replay = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键同载荷重放`,
|
||||
const replay = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}同键同载荷重放`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
||||
context.assert(MODULE, `FBA入仓码${inbound.inboundCode}幂等结果一致`, replay.response,
|
||||
context.assert(MODULE, `FBA组${[...inboundCodes].join('|')}幂等结果一致`, replay.response,
|
||||
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
||||
await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键异载荷冲突`,
|
||||
await expected(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}同键异载荷冲突`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict)
|
||||
await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}超收被拒绝`,
|
||||
const already = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}新键重复累计为零增量`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
|
||||
{ ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict)
|
||||
{ ...body, idempotencyKey: key(context, `fba-already-${index}`) })
|
||||
context.assert(MODULE, `FBA组${[...inboundCodes].join('|')}重复累计不产生超收`, already.response,
|
||||
success(already.response) && already.data?.result === 'ALREADY_RECEIVED'
|
||||
&& Number(already.data?.acceptedPackages) === 0, trace(already.response), OPTIONS)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -604,9 +625,12 @@ async function receiveDropship(context, token, producer, inbounds) {
|
||||
await expected(context, token, 'POST', `一件代发整托${index + 1}同键异载荷冲突`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
|
||||
{ ...body, sourcePalletNo: `${sourcePalletNo}-MUTATED` }, conflict)
|
||||
await expected(context, token, 'POST', `一件代发来源托盘重复收货被拒绝${index + 1}`,
|
||||
const duplicate = await pda(context, token, 'POST', `一件代发来源托盘新键重复收货${index + 1}`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
|
||||
{ ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) }, conflict)
|
||||
{ ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) })
|
||||
context.assert(MODULE, `一件代发来源托盘重复收货不重复入账${index + 1}`, duplicate.response,
|
||||
success(duplicate.response) && duplicate.data?.result === 'ALREADY_RECEIVED'
|
||||
&& Number(duplicate.data?.acceptedPackages) === 0, trace(duplicate.response), OPTIONS)
|
||||
}
|
||||
context.assert(MODULE, '一件代发整托实收箱数与待收箱数守恒', null,
|
||||
accepted === expectedPending, `accepted=${accepted}, expected=${expectedPending}`, OPTIONS)
|
||||
@@ -728,8 +752,15 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
cartonMark: fbaCarton?.cartonMark,
|
||||
palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'),
|
||||
}, (response) => a0409Message(response, /^FBA destination must use bulk count$/))
|
||||
for (const [index, inbound] of fbaInbounds.entries()) {
|
||||
await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index)
|
||||
const groups = new Map()
|
||||
for (const inbound of fbaInbounds) {
|
||||
const fixture = cartonFixtures.find((item) => item.inbound.inboundCode === inbound.inboundCode)
|
||||
const groupKey = fixture?.resolved?.groupKey || `INBOUND:${inbound.inboundCode}`
|
||||
if (!groups.has(groupKey)) groups.set(groupKey, [])
|
||||
groups.get(groupKey).push(inbound)
|
||||
}
|
||||
for (const [index, group] of [...groups.values()].entries()) {
|
||||
await receiveFba(context, token, group, pallets.get(group[0].inboundCode), cartonFixtures, index)
|
||||
}
|
||||
} else if (!strictDropship && preferredProducer?.destinationType !== 'OVERSEAS') {
|
||||
blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码')
|
||||
@@ -785,24 +816,36 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
|
||||
const physicalPallets = [...new Map([...pallets.values()]
|
||||
.map((item) => [id(item.palletId), item])).values()]
|
||||
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
|
||||
containerNo,
|
||||
containerBookingId: id(preferredProducer?.containerBookingId),
|
||||
expectedPalletCost: physicalPallets.reduce((sum, item) => sum + Number(item.unitPrice || 0), 0),
|
||||
palletIds: physicalPallets.map((item) => item.palletId),
|
||||
})
|
||||
const generatedContainerPallets = current.data.inbounds.flatMap((item) =>
|
||||
context.createdResources.phase4OverseasPda.generatedPalletsByInbound[item.inboundCode] || [])
|
||||
const completedFixtures = []
|
||||
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
|
||||
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
|
||||
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
|
||||
pallets.get(inbound.inboundCode), inbound.destinationType)
|
||||
if (warehouseFacts && context.context?.phase4OverseasPdaOperations) {
|
||||
context.context.phase4OverseasPdaOperations.completedFixtures.push({
|
||||
const fixture = {
|
||||
containerNo, order, destinationType: inbound.destinationType,
|
||||
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
|
||||
auxiliaryPallets: generatedContainerPallets.filter((candidate) =>
|
||||
id(candidate?.palletId) !== id(warehouseFacts.pallet?.palletId)),
|
||||
...warehouseFacts,
|
||||
})
|
||||
}
|
||||
context.context.phase4OverseasPdaOperations.completedFixtures.push(fixture)
|
||||
completedFixtures.push(fixture)
|
||||
}
|
||||
}
|
||||
const receivedPallets = [...new Map(completedFixtures.map((fixture) => {
|
||||
const palletId = id(fixture.pallet?.palletId)
|
||||
const source = physicalPallets.find((candidate) => id(candidate?.palletId) === palletId)
|
||||
return [palletId, { palletId, unitPrice: Number(source?.unitPrice || 0) }]
|
||||
}).filter(([palletId]) => longId(palletId))).values()]
|
||||
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
|
||||
containerNo,
|
||||
containerBookingId: id(preferredProducer?.containerBookingId),
|
||||
expectedPalletCost: receivedPallets.reduce((sum, item) => sum + item.unitPrice, 0),
|
||||
palletIds: receivedPallets.map((item) => item.palletId),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,6 +912,12 @@ export async function run(context) {
|
||||
'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜')
|
||||
return
|
||||
}
|
||||
if (producers.length && context.options?.modules?.includes('phase5-terminal-drayage')
|
||||
&& !(await prepareTerminalDrayage(context, producers[0]))) {
|
||||
blockerGap(context, '目的港码头拖车提柜前置',
|
||||
'本轮首个PICKUP_READY货柜未能在卸柜前完成选车队、预约和实际提柜')
|
||||
return
|
||||
}
|
||||
for (const [index, producer] of (producers.length ? producers : [null]).entries()) {
|
||||
context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}`
|
||||
context.createdResources.phase4OverseasPda.finalStatus = null
|
||||
@@ -878,6 +927,10 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null)
|
||||
}
|
||||
}
|
||||
const refreshedMerchantToken = await loginMerchantForNegativeProbe(context)
|
||||
if (nonBlank(refreshedMerchantToken)) {
|
||||
context.context.phase4OverseasPdaOperations.merchantToken = refreshedMerchantToken
|
||||
}
|
||||
handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0
|
||||
} finally {
|
||||
delete context.pdaContainerSuffix
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url)
|
||||
const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
|
||||
|
||||
test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => {
|
||||
const source = await readFile(acceptanceUrl, 'utf8')
|
||||
assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/)
|
||||
assert.match(source, /\['phase4-overseas-return-receipt', \['merchant-qualification-fixture'\]\]/)
|
||||
assert.match(source, /requiresMarkerOwnedMerchantFixture[\s\S]+phase4-overseas-return-receipt/)
|
||||
})
|
||||
|
||||
test('return receipt acceptance covers both lookup paths, partial/final receipt and normal empty results', async () => {
|
||||
const source = await readFile(moduleUrl, 'utf8')
|
||||
assert.match(source, /PDA_RECEIPTS = '\/api\/pda\/v1\/last-leg\/return-receipts'/)
|
||||
assert.match(source, /\$\{PDA_RECEIPTS\}\/start/)
|
||||
assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/)
|
||||
assert.match(source, /PDA首次分批签收2件/)
|
||||
assert.match(source, /PDA按SKU完成剩余3件签收/)
|
||||
assert.match(source, /不存在SKU不是业务错误/)
|
||||
assert.match(source, /已完成退货单不再进入待签收流程/)
|
||||
})
|
||||
|
||||
test('return receipt acceptance proves idempotency, over-receipt isolation and exact inventory', async () => {
|
||||
const source = await readFile(moduleUrl, 'utf8')
|
||||
assert.match(source, /预报幂等重放复用同一退货单/)
|
||||
assert.match(source, /签收重放不重复创建签收单、箱或计费批次/)
|
||||
assert.match(source, /超收被拒绝且不得生成费用批次/)
|
||||
assert.match(source, /订单5件核平且恰有两个唯一仓储计费批次/)
|
||||
assert.match(source, /两个实收箱逐一引用对应计费批次且数量守恒/)
|
||||
})
|
||||
|
||||
test('return receipt acceptance includes fee-batch evidence and return packing lifecycle', async () => {
|
||||
const source = await readFile(moduleUrl, 'utf8')
|
||||
assert.match(source, /billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt'/)
|
||||
assert.match(source, /receiptBatchNos/)
|
||||
assert.match(source, /sourceBatchNo/)
|
||||
assert.match(source, /PDA查询完成退货单的可合箱库存/)
|
||||
assert.match(source, /PDA将同客户退货库存合为新箱/)
|
||||
assert.match(source, /合箱重放复用同一新箱且不重复扣减/)
|
||||
})
|
||||
|
||||
test('HTTP acceptance uses injected clients and never bypasses APIs with database access', async () => {
|
||||
const source = await readFile(moduleUrl, 'utf8')
|
||||
assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i)
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-return-receipt'
|
||||
const SCENARIO = 'phase4-overseas-return-receipt-and-billing'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders'
|
||||
const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts'
|
||||
const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
|
||||
function code(response) { return String(response?.body?.code ?? '') }
|
||||
function success(response) { return response?.http === 200 && code(response) === '200' }
|
||||
function conflict(response) {
|
||||
return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response))))
|
||||
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
|
||||
}
|
||||
function notFound(response) {
|
||||
return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response))))
|
||||
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
|
||||
}
|
||||
function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] }
|
||||
function trace(response) {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
}
|
||||
function query(path, values) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [name, value] of Object.entries(values || {})) {
|
||||
if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value))
|
||||
}
|
||||
return params.size ? `${path}?${params}` : path
|
||||
}
|
||||
function key(context, action) {
|
||||
return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255)
|
||||
}
|
||||
function markerSuffix(context) {
|
||||
return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
||||
}
|
||||
|
||||
function add(context, name, response, passed, evidence = '') {
|
||||
context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
async function overseas(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
add(context, name, response, success(response))
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
}
|
||||
|
||||
async function resolveWarehouse(context) {
|
||||
const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId)
|
||||
const response = await context.request('GET',
|
||||
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
|
||||
const rows = records(response?.body?.data)
|
||||
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
|
||||
const warehouse = rows.find((item) => id(item?.id) === existing)
|
||||
|| rows.find((item) => fixedIds.has(id(item?.id)))
|
||||
|| rows[0]
|
||||
add(context, '读取可执行退货签收的海外仓', response,
|
||||
success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`)
|
||||
return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
|
||||
}
|
||||
|
||||
async function loginMerchant(context) {
|
||||
const fixture = context.context?.merchantQualificationFixture
|
||||
if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) {
|
||||
add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`)
|
||||
return null
|
||||
}
|
||||
const response = await context.request('POST', '/api/auth/login', {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const token = success(response) ? response.body?.data?.overseasAccessToken : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
|
||||
return nonBlank(token) ? token : null
|
||||
}
|
||||
|
||||
function itemMap(items) {
|
||||
return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item]))
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasReturnReceipt = {
|
||||
marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [],
|
||||
billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt',
|
||||
}
|
||||
const merchantToken = await loginMerchant(context)
|
||||
const warehouse = await resolveWarehouse(context)
|
||||
const access = await resolvePickerAccess(context)
|
||||
if (!merchantToken || !warehouse || !access) return
|
||||
const picker = await createPicker(context, 91, warehouse.id, access)
|
||||
if (!picker?.token) {
|
||||
add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌')
|
||||
if (picker?.membershipId) await disablePicker(context, picker, 91)
|
||||
return
|
||||
}
|
||||
|
||||
const suffix = markerSuffix(context)
|
||||
const skuA = `RET-A-${suffix}`.slice(0, 128)
|
||||
const skuB = `RET-B-${suffix}`.slice(0, 128)
|
||||
const createBody = {
|
||||
overseasWarehouseId: warehouse.id,
|
||||
warehouseContactName: '全量验收海外仓', warehouseContactPhone: '0000000000',
|
||||
customerReferenceNo: `REF-${suffix}`, needRelabel: false, remark: context.marker,
|
||||
idempotencyKey: key(context, 'CREATE'),
|
||||
items: [
|
||||
{ skuCode: skuA, productNameZh: '退货验收商品A', expectedQuantity: 3 },
|
||||
{ skuCode: skuB, productNameZh: '退货验收商品B', expectedQuantity: 2 },
|
||||
],
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await overseas(context, merchantToken, 'POST', '客户创建双SKU退货预报', RETURN_ORDERS, createBody)
|
||||
const returnNo = id(created.data?.order?.returnNo)
|
||||
context.assert(MODULE, '退货预报初始数量和状态正确', created.response,
|
||||
nonBlank(returnNo) && created.data?.order?.status === 'PENDING_RECEIPT'
|
||||
&& Number(created.data?.order?.expectedQuantity) === 5
|
||||
&& Number(created.data?.order?.receivedQuantity) === 0
|
||||
&& itemMap(created.data?.items).size === 2,
|
||||
`returnNo=${returnNo || 'missing'}`, OPTIONS)
|
||||
if (!returnNo) return
|
||||
context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo
|
||||
|
||||
const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody)
|
||||
context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response,
|
||||
id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS)
|
||||
const createConflict = await context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)
|
||||
add(context, '退货预报同键异载荷返回冲突', createConflict, conflict(createConflict))
|
||||
|
||||
const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` })
|
||||
context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response,
|
||||
success(emptySku.response) && Array.isArray(emptySku.data?.candidates) && emptySku.data.candidates.length === 0,
|
||||
trace(emptySku.response), OPTIONS)
|
||||
|
||||
const started = await overseas(context, picker.token, 'POST', 'PDA按退货单号开始签收',
|
||||
`${PDA_RECEIPTS}/start`, { returnNo })
|
||||
context.assert(MODULE, '首次开始返回全部未收SKU', started.response,
|
||||
started.data?.status === 'PENDING_RECEIPT' && itemMap(started.data?.expectedItems).size === 2,
|
||||
trace(started.response), OPTIONS)
|
||||
const skuCandidates = await overseas(context, picker.token, 'POST', 'PDA按SKU查询当前仓候选退货单',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
|
||||
context.assert(MODULE, 'SKU候选包含本轮退货单及准确数量', skuCandidates.response,
|
||||
skuCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo
|
||||
&& Number(candidate.skuExpectedQuantity) === 2 && Number(candidate.skuReceivedQuantity) === 0),
|
||||
trace(skuCandidates.response), OPTIONS)
|
||||
|
||||
const cartonA = `RC-${suffix}-A`.slice(0, 128)
|
||||
const firstBody = {
|
||||
returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', cartonMark: cartonA,
|
||||
items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-1'),
|
||||
}
|
||||
const first = await overseas(context, picker.token, 'POST', 'PDA首次分批签收2件', `${PDA_RECEIPTS}/confirm`, firstBody)
|
||||
context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response,
|
||||
first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false
|
||||
&& Number(first.data?.receivedQuantity) === 2 && first.data?.cartonMark === cartonA
|
||||
&& nonBlank(first.data?.receiptNo) && nonBlank(first.data?.receiptBatchNo),
|
||||
`receiptNo=${id(first.data?.receiptNo) || 'missing'}, billingBatch=${id(first.data?.receiptBatchNo) || 'missing'}`, OPTIONS)
|
||||
const firstReceiptNo = id(first.data?.receiptNo)
|
||||
const firstBatchNo = id(first.data?.receiptBatchNo)
|
||||
|
||||
const firstReplay = await overseas(context, picker.token, 'POST', '首次签收同键同载荷重放', `${PDA_RECEIPTS}/confirm`, firstBody)
|
||||
context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response,
|
||||
id(firstReplay.data?.receiptNo) === firstReceiptNo
|
||||
&& id(firstReplay.data?.receiptBatchNo) === firstBatchNo
|
||||
&& id(firstReplay.data?.cartonMark) === cartonA,
|
||||
trace(firstReplay.response), OPTIONS)
|
||||
const firstConflict = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`,
|
||||
}, {}, picker.token)
|
||||
add(context, '签收同键异载荷返回冲突', firstConflict, conflict(firstConflict))
|
||||
|
||||
const overReceipt = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 2 }],
|
||||
idempotencyKey: key(context, 'OVER-RECEIPT'),
|
||||
}, {}, picker.token)
|
||||
add(context, '超收被拒绝且不得生成费用批次', overReceipt, conflict(overReceipt))
|
||||
|
||||
const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态',
|
||||
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
|
||||
context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response,
|
||||
afterPartial.data?.order?.status === 'PARTIALLY_RECEIVED'
|
||||
&& Number(afterPartial.data?.order?.receivedQuantity) === 2
|
||||
&& afterPartial.data?.receipts?.length === 1
|
||||
&& id(afterPartial.data.receipts[0]?.receiptBatchNo) === firstBatchNo,
|
||||
`receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const secondBody = {
|
||||
returnNo, startedBy: 'SKU',
|
||||
items: [{ skuCode: skuA, receivedQuantity: 1 }, { skuCode: skuB, receivedQuantity: 2 }],
|
||||
idempotencyKey: key(context, 'RECEIPT-2'),
|
||||
}
|
||||
const second = await overseas(context, picker.token, 'POST', 'PDA按SKU完成剩余3件签收', `${PDA_RECEIPTS}/confirm`, secondBody)
|
||||
context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response,
|
||||
second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true
|
||||
&& Number(second.data?.receivedQuantity) === 5 && nonBlank(second.data?.cartonMark)
|
||||
&& nonBlank(second.data?.receiptNo) && nonBlank(second.data?.receiptBatchNo)
|
||||
&& id(second.data?.receiptBatchNo) !== firstBatchNo,
|
||||
`billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS)
|
||||
const secondReceiptNo = id(second.data?.receiptNo)
|
||||
const secondBatchNo = id(second.data?.receiptBatchNo)
|
||||
const cartonB = id(second.data?.cartonMark)
|
||||
context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo)
|
||||
context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo)
|
||||
context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB)
|
||||
|
||||
const completedStart = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token)
|
||||
add(context, '已完成退货单不再进入待签收流程', completedStart, notFound(completedStart))
|
||||
const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
|
||||
context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response,
|
||||
!completedCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo),
|
||||
trace(completedCandidates.response), OPTIONS)
|
||||
|
||||
const detail = await overseas(context, merchantToken, 'GET', '客户回查完成退货单与两次签收',
|
||||
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
|
||||
const detailItems = itemMap(detail.data?.items)
|
||||
const detailReceipts = Array.isArray(detail.data?.receipts) ? detail.data.receipts : []
|
||||
const detailBatches = new Set(detailReceipts.map((receipt) => id(receipt.receiptBatchNo)))
|
||||
context.assert(MODULE, '订单5件核平且恰有两个唯一仓储计费批次', detail.response,
|
||||
detail.data?.order?.status === 'COMPLETED' && Number(detail.data?.order?.receivedQuantity) === 5
|
||||
&& Number(detailItems.get(skuA)?.receivedQuantity) === 3
|
||||
&& Number(detailItems.get(skuB)?.receivedQuantity) === 2
|
||||
&& detailReceipts.length === 2 && detailBatches.size === 2
|
||||
&& detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo),
|
||||
`receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS)
|
||||
|
||||
const merchantInventory = await context.requestOverseas('GET',
|
||||
query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }),
|
||||
undefined, {}, merchantToken)
|
||||
add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory))
|
||||
|
||||
const customerId = id(context.context?.merchantQualificationFixture?.tenantId)
|
||||
const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存',
|
||||
query('/api/platform/v1/last-leg/return-inventory/cartons', {
|
||||
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo,
|
||||
}))
|
||||
const inventoryRows = records(inventory.data)
|
||||
const sourceBatches = new Set(inventoryRows.map((row) => id(row.sourceBatchNo)))
|
||||
context.assert(MODULE, '两个实收箱逐一引用对应计费批次且数量守恒', inventory.response,
|
||||
inventoryRows.length === 2
|
||||
&& inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0) === 5
|
||||
&& sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo),
|
||||
`cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS)
|
||||
|
||||
const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存',
|
||||
query('/api/platform/v1/last-leg/return-inventory', {
|
||||
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id,
|
||||
}))
|
||||
const summaries = new Map(records(summary.data).filter((row) => [skuA, skuB].includes(row.skuCode))
|
||||
.map((row) => [row.skuCode, row]))
|
||||
context.assert(MODULE, '退货SKU汇总数量与两个实收箱一致', summary.response,
|
||||
Number(summaries.get(skuA)?.totalQuantity) === 3
|
||||
&& Number(summaries.get(skuB)?.totalQuantity) === 2,
|
||||
`skuA=${summaries.get(skuA)?.totalQuantity ?? 'missing'}, skuB=${summaries.get(skuB)?.totalQuantity ?? 'missing'}`, OPTIONS)
|
||||
|
||||
for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 2 },
|
||||
{ cartonMark: cartonB, batchNo: secondBatchNo, quantity: 3 }].entries()) {
|
||||
const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`,
|
||||
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`)
|
||||
context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response,
|
||||
carton.data?.sourceBatchNo === expected.batchNo
|
||||
&& Number(carton.data?.totalQuantity) === expected.quantity
|
||||
&& Array.isArray(carton.data?.items) && carton.data.items.length > 0,
|
||||
`batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS)
|
||||
const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`,
|
||||
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`)
|
||||
context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response,
|
||||
label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo
|
||||
&& Number(label.data?.totalQuantity) === expected.quantity && nonBlank(label.data?.printableHtml),
|
||||
trace(label.response), OPTIONS)
|
||||
}
|
||||
|
||||
const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存',
|
||||
query('/api/pda/v1/last-leg/return-inventory/packing/orders', { returnNo }))
|
||||
const packingSkus = new Map((packing.data?.skus || []).map((row) => [row.skuCode, row]))
|
||||
context.assert(MODULE, '退货合箱候选只含本轮完成单且库存准确', packing.response,
|
||||
packing.data?.orders?.some?.((order) => order.returnNo === returnNo && order.status === 'COMPLETED')
|
||||
&& Number(packingSkus.get(skuA)?.availableQuantity) === 3
|
||||
&& Number(packingSkus.get(skuB)?.availableQuantity) === 2,
|
||||
trace(packing.response), OPTIONS)
|
||||
|
||||
const mergeBody = {
|
||||
returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }, { skuCode: skuB, quantity: 1 }],
|
||||
length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'),
|
||||
}
|
||||
const merged = await overseas(context, picker.token, 'POST', 'PDA将同客户退货库存合为新箱',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
|
||||
if (success(merged.response)) {
|
||||
context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response,
|
||||
nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2
|
||||
&& merged.data?.label?.sourceBatchNo === 'MERGED'
|
||||
&& Number(merged.data?.label?.items?.length) === 2
|
||||
&& nonBlank(merged.data?.label?.printableHtml),
|
||||
`cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS)
|
||||
const mergeReplay = await overseas(context, picker.token, 'POST', '退货合箱同键同载荷重放',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
|
||||
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
|
||||
success(mergeReplay.response) && nonBlank(merged.data?.cartonMark)
|
||||
&& id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS)
|
||||
const mergeConflict = await context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
{ ...mergeBody, weight: 3 }, {}, picker.token)
|
||||
add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict))
|
||||
}
|
||||
} finally {
|
||||
await disablePicker(context, picker, 91)
|
||||
}
|
||||
}
|
||||
+4
@@ -18,6 +18,7 @@ test('采购单号按北京时间日期执行精确合同', () => {
|
||||
test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => {
|
||||
for (const token of [
|
||||
'/accounts/${encodeURIComponent(customerId)}/credit-limit',
|
||||
'creditExpiresOn',
|
||||
'/accounts/${encodeURIComponent(customerId)}/adjustments',
|
||||
"const CLIENT_FINANCE = '/api/client/finance/v1'",
|
||||
"['VOUCHER', 'RECEIPT']",
|
||||
@@ -28,6 +29,8 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
|
||||
'item.sourceNo === businessNo',
|
||||
"'RELEASED'",
|
||||
"terminalStatus === 'COMPLETED'",
|
||||
"status: 'CUSTOMER_CONFIRM_PENDING'",
|
||||
"status: 'READY_FOR_OUTBOUND'",
|
||||
"'CANCELLED'",
|
||||
'relabelPayload(context, warehouseId, source, suffix, 0.01)',
|
||||
'/relabel-applications/inventory-candidates',
|
||||
@@ -38,6 +41,7 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
|
||||
'destinationFbaWarehouseCode: source.destinationFbaWarehouseCode',
|
||||
'cartonMark:',
|
||||
'fbaCartonMark:',
|
||||
"deliveryMethod: 'TRUCK'",
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/)
|
||||
assert.match(source, /voucherFileId: id\(files\.find\(\(item\) => item\.category === 'VOUCHER'\)\?\.fileId\)/)
|
||||
|
||||
@@ -67,8 +67,10 @@ async function prepareWallet(context, customerId) {
|
||||
if (!before.account) return null
|
||||
|
||||
const creditLimit = 2.00
|
||||
const creditExpiresOn = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||
const credit = await context.request('PUT', `${FINANCE}/accounts/${encodeURIComponent(customerId)}/credit-limit`,
|
||||
{ creditLimit, remark: context.marker, expectedAccountVersion: Number(before.account.accountVersion) }, context.token,
|
||||
{ creditLimit, creditExpiresOn, remark: context.marker,
|
||||
expectedAccountVersion: Number(before.account.accountVersion) }, context.token,
|
||||
{ 'Idempotency-Key': `${context.marker}-wallet-credit-limit`.slice(0, 128) })
|
||||
assert(context, '授信额度可通过正式接口初始化', credit,
|
||||
success(credit) && money(credit.body.data?.creditLimitAfter) === creditLimit,
|
||||
@@ -175,6 +177,7 @@ function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
|
||||
customerOrderNo: `${context.marker}-${suffix}`.slice(0, 128), operationWarehouseId: id(warehouseId),
|
||||
labelDeadline: new Date(Date.now() + 86_400_000).toISOString().slice(0, 19),
|
||||
contactName: '全量验收联系人', contactPhone: '+1-213-555-0199', chargeAmount,
|
||||
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-${suffix}`.slice(0, 255),
|
||||
items: [{ sourceInventoryBalanceId: id(source.inventoryBalanceId),
|
||||
sourceInventoryPositionId: id(source.position.positionId), sourceInboundOrderId: id(source.inboundOrderId),
|
||||
@@ -223,6 +226,14 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal
|
||||
const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker })
|
||||
assert(context, '换标单冻结后才允许进入处理中', processing,
|
||||
success(processing) && processing.body.data?.application?.status === 'PROCESSING')
|
||||
const confirmPending = await context.requestOverseas('PUT', `${detailPath}/status`,
|
||||
{ status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker })
|
||||
assert(context, '换标处理完成后进入客户确认', confirmPending,
|
||||
success(confirmPending) && confirmPending.body.data?.application?.status === 'CUSTOMER_CONFIRM_PENDING')
|
||||
const readyForOutbound = await context.requestOverseas('PUT', `${detailPath}/status`,
|
||||
{ status: 'READY_FOR_OUTBOUND', remark: context.marker })
|
||||
assert(context, '换标客户确认后进入待出库', readyForOutbound,
|
||||
success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND')
|
||||
}
|
||||
const terminal = await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker })
|
||||
assert(context, `${suffix}换标单进入${terminalStatus}`, terminal,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { run } from './phase5-terminal-drayage.mjs'
|
||||
import { prepareTerminalDrayage, run } from './phase5-terminal-drayage.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
@@ -19,11 +19,12 @@ test('four real command payloads mutate contract fields and preserve remarks and
|
||||
let conflicts = 0
|
||||
let replays = 0
|
||||
const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline-request', traceId: 'offline-trace' })
|
||||
const producer = { containerBookingId, containerNo, shippingReady: true,
|
||||
carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' }
|
||||
const context = {
|
||||
marker, token: 'offline-only',
|
||||
createdResources: {
|
||||
phase4InboundProducerFixtures: { marker, containers: [{ containerBookingId, containerNo,
|
||||
shippingReady: true, carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' }] },
|
||||
phase4InboundProducerFixtures: { marker, containers: [producer] },
|
||||
phase4OverseasPda: { marker, finalStatus: 'COMPLETED', completedContainerFacts: [{ containerBookingId, containerNo }] },
|
||||
},
|
||||
apiSuccess(_module, name, result) { assert.equal(result.body.code, '200', name); return result.body.data },
|
||||
@@ -58,13 +59,15 @@ test('four real command payloads mutate contract fields and preserve remarks and
|
||||
state = { ...state, version: state.version + 1 }
|
||||
if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] })
|
||||
if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt
|
||||
if (index === 2) Object.assign(state, { status: 'UNLOADED', actualPickupAt: body.actualAt, unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] })
|
||||
if (index === 2) Object.assign(state, { status: 'PICKED_UP', actualPickupAt: body.actualAt, allowedActions: [] })
|
||||
if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
|
||||
history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] })
|
||||
commands.set(key, { body: structuredClone(body), state: structuredClone(state) })
|
||||
return response(state)
|
||||
},
|
||||
}
|
||||
await prepareTerminalDrayage(context, producer)
|
||||
state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }
|
||||
await run(context)
|
||||
assert.equal(commands.size, 4)
|
||||
assert.equal(replays, 4)
|
||||
@@ -73,7 +76,7 @@ test('four real command payloads mutate contract fields and preserve remarks and
|
||||
assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED')
|
||||
})
|
||||
|
||||
test('terminal drayage runs after pickup-ready and overseas unloading but before final carrier validation', () => {
|
||||
test('terminal drayage prepares pickup before unloading and finalizes return before carrier validation', () => {
|
||||
const modules = normalizeModuleOrder(['phase4-carrier-nodes'])
|
||||
const pickup = modules.indexOf('phase4-carrier-pickup-ready')
|
||||
const pda = modules.indexOf('phase4-overseas-pda')
|
||||
@@ -81,6 +84,8 @@ test('terminal drayage runs after pickup-ready and overseas unloading but before
|
||||
const finalCarrier = modules.indexOf('phase4-carrier-nodes')
|
||||
assert.ok(pickup >= 0 && pickup < pda)
|
||||
assert.ok(pda < drayage && drayage < finalCarrier)
|
||||
assert.match(source, /attempt <= 30/)
|
||||
assert.match(source, /initialData\?\.status === 'UNLOADED'/)
|
||||
})
|
||||
|
||||
test('module uses only public business endpoints and reuses public trucking master data', () => {
|
||||
@@ -106,12 +111,10 @@ test('all four commands cover idempotent replay hash conflict and lifecycle sema
|
||||
|
||||
test('target is strictly current-marker pickup and completed-PDA container', () => {
|
||||
assert.match(source, /matrix\?\.marker !== context\.marker/)
|
||||
assert.match(source, /pda\?\.marker !== context\.marker/)
|
||||
assert.match(source, /prepared\?\.marker !== context\.marker/)
|
||||
assert.match(source, /pda\?\.finalStatus !== 'COMPLETED'/)
|
||||
assert.match(source, /completedContainerFacts/)
|
||||
assert.match(source, /item\?\.shippingReady === true/)
|
||||
assert.match(source, /carrierScheduleQueryId/)
|
||||
assert.match(source, /carrierLatestActualAt/)
|
||||
assert.match(source, /producer\?\.shippingReady !== true/)
|
||||
assert.match(source, /phase4CarrierPickupReady =/)
|
||||
assert.match(source, /禁止借历史货柜/)
|
||||
})
|
||||
|
||||
@@ -20,22 +20,15 @@ function blocker(context, name, evidence, response = null) {
|
||||
context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS)
|
||||
context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true })
|
||||
}
|
||||
function fixture(context) {
|
||||
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
||||
function completedTarget(context) {
|
||||
const prepared = context.createdResources?.phase5TerminalDrayage?.prepared
|
||||
const pda = context.createdResources?.phase4OverseasPda
|
||||
if (matrix?.marker !== context.marker || pda?.marker !== context.marker
|
||||
if (prepared?.marker !== context.marker || pda?.marker !== context.marker
|
||||
|| pda?.finalStatus !== 'COMPLETED') return null
|
||||
const producers = Array.isArray(matrix.containers) ? matrix.containers : []
|
||||
for (const completed of pda.completedContainerFacts || []) {
|
||||
const producer = producers.find((item) => id(item?.containerBookingId) === id(completed?.containerBookingId)
|
||||
&& id(item?.containerNo) === id(completed?.containerNo)
|
||||
&& item?.shippingReady === true && id(item?.carrierScheduleQueryId)
|
||||
&& item?.carrierLatestActualAt)
|
||||
if (producer) return {
|
||||
matrix, producer, pda, completed, containerBookingId: id(producer.containerBookingId),
|
||||
}
|
||||
}
|
||||
return null
|
||||
const completed = (pda.completedContainerFacts || []).find((item) =>
|
||||
id(item?.containerBookingId) === id(prepared.containerBookingId)
|
||||
&& id(item?.containerNo) === id(prepared.containerNo))
|
||||
return completed ? { ...prepared, pda, completed } : null
|
||||
}
|
||||
async function command(context, target, action, path, body) {
|
||||
const key = `${context.marker}-terminal-${path}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 128)
|
||||
@@ -58,32 +51,37 @@ async function command(context, target, action, path, body) {
|
||||
return first
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase5TerminalDrayage = {
|
||||
export async function prepareTerminalDrayage(context, producer) {
|
||||
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
||||
if (matrix?.marker !== context.marker || !producer || producer?.shippingReady !== true
|
||||
|| !id(producer?.containerBookingId) || !id(producer?.containerNo)
|
||||
|| !id(producer?.carrierScheduleQueryId) || !producer?.carrierLatestActualAt) {
|
||||
blocker(context, '取得本轮PICKUP_READY货柜执行码头提柜前置',
|
||||
`matrix=${matrix?.marker || 'missing'}, container=${id(producer?.containerNo) || 'missing'}; 禁止借历史货柜`)
|
||||
return null
|
||||
}
|
||||
const resource = context.createdResources.phase5TerminalDrayage || {
|
||||
marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null,
|
||||
cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence',
|
||||
}
|
||||
const target = fixture(context)
|
||||
if (!target) {
|
||||
blocker(context, '取得本轮PICKUP_READY且已卸柜货柜',
|
||||
`matrix=${context.createdResources?.phase4InboundProducerFixtures?.marker || 'missing'}, shippingReady=${context.createdResources?.phase4InboundProducerFixtures?.containers?.filter((item) => item?.shippingReady === true).length ?? 0}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`)
|
||||
return
|
||||
}
|
||||
context.createdResources.phase5TerminalDrayage = resource
|
||||
const target = { marker: context.marker, producer, containerBookingId: id(producer.containerBookingId),
|
||||
containerNo: id(producer.containerNo) }
|
||||
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
|
||||
const initial = await api(context, 'GET', '读取目的港码头拖车初始聚合', path)
|
||||
context.assert(MODULE, '初始聚合绑定本轮货柜且允许选择车队', initial.response,
|
||||
const initial = await api(context, 'GET', '卸柜前读取目的港码头拖车初始聚合', path)
|
||||
context.assert(MODULE, 'PICKUP_READY货柜允许选择车队', initial.response,
|
||||
id(initial.data?.containerBookingId) === target.containerBookingId
|
||||
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
|
||||
&& id(initial.data?.containerNo) === target.containerNo
|
||||
&& Number(initial.data?.version) === 0 && initial.data?.allowedActions?.includes('SELECT_FLEET'),
|
||||
`status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
|
||||
if (!success(initial.response)) return
|
||||
if (!success(initial.response) || !initial.data?.allowedActions?.includes('SELECT_FLEET')) return null
|
||||
|
||||
const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商',
|
||||
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=100')
|
||||
const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE')
|
||||
if (!fleet) {
|
||||
blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING公共供应商;脚本不临时创建主数据', suppliers.response)
|
||||
return
|
||||
return null
|
||||
}
|
||||
const selected = await command(context, target, '选择车队', 'fleet', {
|
||||
expectedVersion: Number(initial.data.version), fleetSupplierId: id(fleet.id),
|
||||
@@ -92,9 +90,9 @@ export async function run(context) {
|
||||
context.assert(MODULE, '选车队后只绑定公共供应商并开放预约', selected.response,
|
||||
id(selected.data?.fleetSupplierId) === id(fleet.id) && selected.data?.fleetSupplierName === fleet.name
|
||||
&& selected.data?.allowedActions?.includes('SCHEDULE_PICKUP'), trace(selected.response), OPTIONS)
|
||||
if (!success(selected.response)) return
|
||||
if (!success(selected.response)) return null
|
||||
|
||||
const latestActual = Date.parse(`${target.producer.carrierLatestActualAt}Z`)
|
||||
const latestActual = Date.parse(`${producer.carrierLatestActualAt}Z`)
|
||||
const appointmentAt = localIso(new Date((Number.isNaN(latestActual) ? Date.now() : latestActual) + 60 * 60_000))
|
||||
const scheduled = await command(context, target, '预约提柜', 'pickup-appointment', {
|
||||
expectedVersion: Number(selected.data.version), pickupAppointmentAt: appointmentAt, reason: context.marker,
|
||||
@@ -103,22 +101,66 @@ export async function run(context) {
|
||||
scheduled.data?.pickupAppointmentAt === appointmentAt
|
||||
&& Number(scheduled.data?.version) === Number(selected.data.version) + 1,
|
||||
trace(scheduled.response), OPTIONS)
|
||||
if (!success(scheduled.response)) return
|
||||
if (!success(scheduled.response)) return null
|
||||
|
||||
const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000))
|
||||
const picked = await command(context, target, '确认实际提柜', 'pickup', {
|
||||
expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '实际提柜同步PICKED_UP事实并在已卸柜聚合开放还柜', picked.response,
|
||||
picked.data?.status === 'UNLOADED' && picked.data?.actualPickupAt === pickupAt
|
||||
&& picked.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), trace(picked.response), OPTIONS)
|
||||
if (!success(picked.response)) return
|
||||
context.assert(MODULE, '实际提柜同步PICKED_UP并等待海外仓卸柜', picked.response,
|
||||
picked.data?.status === 'PICKED_UP' && picked.data?.actualPickupAt === pickupAt,
|
||||
trace(picked.response), OPTIONS)
|
||||
if (!success(picked.response) || picked.data?.status !== 'PICKED_UP') return null
|
||||
resource.prepared = { ...target, appointmentAt, pickupAt, pickedVersion: Number(picked.data.version) }
|
||||
resource.containerBookingId = target.containerBookingId
|
||||
resource.containerNo = target.containerNo
|
||||
return resource.prepared
|
||||
}
|
||||
|
||||
const unloadingAt = Date.parse(`${picked.data?.unloadingCompletedAt}Z`)
|
||||
const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
|
||||
export async function run(context) {
|
||||
const resource = context.createdResources.phase5TerminalDrayage || {
|
||||
marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null,
|
||||
cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence',
|
||||
}
|
||||
context.createdResources.phase5TerminalDrayage = resource
|
||||
const target = completedTarget(context)
|
||||
if (!target) {
|
||||
blocker(context, '取得本轮已提柜且已卸柜货柜',
|
||||
`prepared=${resource.prepared?.marker || 'missing'}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`)
|
||||
return
|
||||
}
|
||||
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
|
||||
let initialResponse = null
|
||||
let initialData = null
|
||||
let pollRequests = 0
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
pollRequests = attempt
|
||||
initialResponse = await context.request('GET', path, undefined, context.token)
|
||||
initialData = success(initialResponse) ? initialResponse.body?.data : null
|
||||
if (initialData?.status === 'UNLOADED'
|
||||
&& initialData?.allowedActions?.includes('RECORD_EMPTY_RETURN')) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
const initial = {
|
||||
response: initialResponse,
|
||||
data: context.apiSuccess(MODULE, '卸柜后等待跨区事件并读取目的港码头拖车聚合', initialResponse,
|
||||
`pollRequests=${pollRequests}; ${trace(initialResponse)}`, OPTIONS),
|
||||
}
|
||||
context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response,
|
||||
id(initial.data?.containerBookingId) === target.containerBookingId
|
||||
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
|
||||
&& Number(initial.data?.version) === Number(target.pickedVersion)
|
||||
&& initial.data?.status === 'UNLOADED'
|
||||
&& initial.data?.actualPickupAt === target.pickupAt
|
||||
&& initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'),
|
||||
`pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
|
||||
if (!success(initial.response)) return
|
||||
|
||||
const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`)
|
||||
const returnBase = Math.max(Date.parse(`${target.pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
|
||||
const returnedAt = localIso(new Date(returnBase + 60 * 60_000))
|
||||
const returned = await command(context, target, '确认实际还柜', 'empty-return', {
|
||||
expectedVersion: Number(picked.data.version), actualAt: returnedAt, remark: context.marker,
|
||||
expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response,
|
||||
returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export function channelGroup(channelCode) {
|
||||
const value = String(channelCode || '').trim().toUpperCase()
|
||||
if (value.startsWith('EXX_') || ['EXXHK', 'EXXHWK', 'NYEXX'].includes(value)) return 'EXX'
|
||||
if (value.startsWith('MATSON_CLX_') || ['ZBYHK', 'ZBYHWK', 'ZBYHP'].includes(value)) return 'MATSON_CLX'
|
||||
if (value.startsWith('MATSON_MAX_') || ['BYHK', 'BYHWK', 'BYHP'].includes(value)) return 'MATSON_MAX'
|
||||
if (value.startsWith('HEDE_') || ['HDHK', 'HDHWK', 'HDHP'].includes(value)) return 'HEDE'
|
||||
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL'
|
||||
|| ['PCHK', 'PCHWK', 'PCKK', 'PCHP'].includes(value)) return 'OA_LA'
|
||||
if (value.startsWith('OA_CHICAGO_') || value === 'CPHT') return 'OA_CHICAGO'
|
||||
if (value.startsWith('OA_HOUSTON_') || value === 'HPHK') return 'OA_HOUSTON'
|
||||
if (value.startsWith('OA_SAVANNAH_') || value === 'SPHK') return 'OA_SAVANNAH'
|
||||
if (['OA_NEW_YORK_TRUCK', 'NPHK', 'NPHWK'].includes(value)) return 'OA_NEW_YORK_TRUCK'
|
||||
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
|
||||
}
|
||||
@@ -87,11 +87,50 @@ $overseasPdaAgentsPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/AGEN
|
||||
$backendAgentsPath = Join-Path $backendRoot 'AGENTS.md'
|
||||
|
||||
$rootAgents = Read-RequiredText $rootAgentsPath
|
||||
$businessIndexPath = Join-Path $workspaceRoot 'docs/业务快速索引.md'
|
||||
$businessIndex = Read-RequiredText $businessIndexPath
|
||||
Assert-Contains $rootAgents 'docs/业务快速索引.md' '根按需业务索引入口'
|
||||
Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详细业务路由表'
|
||||
Assert-MarkdownTableUnique $businessIndexPath '## 常用说法与歧义分流' 0 '业务歧义分流表'
|
||||
Assert-Contains $businessIndex '## 业务快速路由表' '详细业务表存在'
|
||||
Assert-Contains $businessIndex '## 常用说法与歧义分流' '业务歧义分流存在'
|
||||
$adminAgents = Read-RequiredText $adminAgentsPath
|
||||
$adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md'
|
||||
$adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath
|
||||
Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口'
|
||||
Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表'
|
||||
$clientAgents = Read-RequiredText $clientAgentsPath
|
||||
$pdaAgents = Read-RequiredText $pdaAgentsPath
|
||||
$overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath
|
||||
$backendAgents = Read-RequiredText $backendAgentsPath
|
||||
|
||||
# 固定样本只检查目标索引行和真实文件,不递归搜索;字符数是读取量指标,不是耗时或 Token 实测。
|
||||
$lookupSamples = @(
|
||||
@{ Key = '客户专属价格簿'; Text = $adminAgents; Entry = 'modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue'; Base = 'apps/admin-web/src' },
|
||||
@{ Key = '入库扣货、解除与凭证历史'; Text = $adminAgents; Entry = 'modules/unified-orders/pages/UnifiedOrdersView.vue'; Base = 'apps/admin-web/src' },
|
||||
@{ Key = '国内/海外 PDA 独立人员'; Text = $adminAgents; Entry = 'modules/iam/pages/InternalUsersView.vue'; Base = 'apps/admin-web/src' },
|
||||
@{ Key = '尾程订单控制'; Text = $adminOverseasAgents; Entry = 'pages/LastMileOrdersView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
|
||||
@{ Key = '排车查询'; Text = $adminOverseasAgents; Entry = 'pages/DispatchesView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
|
||||
@{ Key = '海外出库与派送'; Text = $adminOverseasAgents; Entry = 'pages/OutboundsView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
|
||||
@{ Key = '异常退回与客户回访'; Text = $adminOverseasAgents; Entry = 'pages/ExceptionOrdersView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
|
||||
@{ Key = '库存余额'; Text = $adminOverseasAgents; Entry = 'pages/InventoryView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
|
||||
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-receipt/return-receipt.vue'; Base = 'apps/pda-overseas-uniapp' },
|
||||
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-packing/return-packing.vue'; Base = 'apps/pda-overseas-uniapp' }
|
||||
)
|
||||
$lookupCharacters = 0
|
||||
foreach ($sample in $lookupSamples) {
|
||||
$hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key) })
|
||||
$script:checked++
|
||||
if ($hits.Count -ne 1) {
|
||||
Add-Failure "检索样本必须唯一命中: $($sample.Key),实际 $($hits.Count) 行"
|
||||
continue
|
||||
}
|
||||
Assert-Contains $hits[0] $sample.Entry "检索样本 $($sample.Key)"
|
||||
Assert-Path ($sample.Base + '/' + $sample.Entry)
|
||||
$lookupCharacters += $hits[0].Length
|
||||
}
|
||||
Write-Host "定向检索样本:$($lookupSamples.Count) 项,命中行共 $lookupCharacters 字符(不含上层指引读取量,非端到端性能测量)。"
|
||||
|
||||
$scriptsAgents = Read-RequiredText (Join-Path $backendRoot 'scripts/AGENTS.md')
|
||||
$engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md')
|
||||
$docsIndex = Read-RequiredText (Join-Path $workspaceRoot 'docs/README.md')
|
||||
@@ -104,7 +143,7 @@ Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机
|
||||
Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围'
|
||||
Assert-Contains $rootAgents '后端 MyBatis Mapper 新增、移动或扫描遗漏' '根 Mapper 快速入口'
|
||||
Assert-Contains $rootAgents 'node scripts/mapper-scan-contract.mjs' '根 Mapper 静态门禁'
|
||||
Assert-Contains $rootAgents '脚本管理、全量验证与测试造数' '根脚本管理快速入口'
|
||||
Assert-Contains $businessIndex '脚本管理、全量验证与测试造数' '根脚本管理快速入口'
|
||||
Assert-Contains $rootAgents 'baiyue-firstleg/scripts/AGENTS.md' '根脚本最近指引'
|
||||
Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则'
|
||||
Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界'
|
||||
@@ -124,29 +163,15 @@ Assert-Contains $rootAgents 'docs/独立任务协作登记.md' '根测试协作
|
||||
Assert-Contains $engineeringConstraints '不得同时加载两表后混合选取任务' '工程协作登记选择门禁'
|
||||
Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务协作登记.md)' '文档测试登记入口'
|
||||
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
|
||||
Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目'
|
||||
Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录'
|
||||
# 启用状态属于各环境当前登记事实;不得用测试分支的历史任务名称/ID强制重新启用本地清空表。
|
||||
$registeredTestRows = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|'))
|
||||
if ($registeredTestRows.Count -eq 0) {
|
||||
Assert-Contains $testTaskRegistry '不启用总控调度' '测试登记清空状态'
|
||||
Assert-Contains $testTaskRegistry '当前无已登记总控或长期专项' '测试登记清空明示'
|
||||
Assert-Contains $testTaskRegistry '持续授权同步失效' '测试登记清空授权边界'
|
||||
} else {
|
||||
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
|
||||
Assert-Contains $testTaskRegistry 'parent_task_id' '测试登记父任务字段'
|
||||
Assert-Contains $testTaskRegistry 'project_id' '测试登记项目字段'
|
||||
Assert-Matches $testTaskRegistry '(?m)^\|[^\r\n]*【总控】[^\r\n]*\|' '测试登记总控职责'
|
||||
$registeredIds = @($registeredTestRows | ForEach-Object { $_.Groups[1].Value })
|
||||
if (@($registeredIds | Select-Object -Unique).Count -ne $registeredIds.Count) {
|
||||
Add-Failure '测试登记存在重复 task_id'
|
||||
}
|
||||
$taskLines = @($testTaskRegistry -split '\r?\n' | Where-Object { $_ -match '^\|\s*`[0-9a-f-]{36}`\s*\|' })
|
||||
$controlLines = @($taskLines | Where-Object { $_.Contains('【总控】') })
|
||||
if ($controlLines.Count -ne 1) { Add-Failure '测试登记必须有且仅有一个总控' }
|
||||
foreach ($taskLine in $taskLines) {
|
||||
Assert-Contains $taskLine '`c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试任务项目隔离'
|
||||
}
|
||||
# 当前测试登记已明确清空;历史 projectId/task_id 不得成为恢复调度的依据。
|
||||
Assert-Contains $testTaskRegistry '当前无登记任务' '测试登记清空明示'
|
||||
Assert-Contains $testTaskRegistry '清空全部任务登记与调度规则' '测试登记清空状态'
|
||||
Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工、恢复任务或回传进度' '测试登记禁止历史派工'
|
||||
Assert-Contains $testTaskRegistry '不作为当前调度或授权依据' '测试登记清空授权边界'
|
||||
Assert-NotContains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记不得隐式启用'
|
||||
$checked++
|
||||
if ($testTaskRegistry -match '(?m)^\|\s*`[0-9a-f-]{36}`\s*\|') {
|
||||
Add-Failure '测试登记不得残留任务行'
|
||||
}
|
||||
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
|
||||
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
|
||||
@@ -164,7 +189,7 @@ foreach ($businessDocsEntry in @(
|
||||
'docs/国内货柜履约/README.md',
|
||||
'docs/海外仓业务/README.md'
|
||||
)) {
|
||||
Assert-Contains $rootAgents $businessDocsEntry "根业务域入口 $businessDocsEntry"
|
||||
Assert-Contains $businessIndex $businessDocsEntry "根业务域入口 $businessDocsEntry"
|
||||
}
|
||||
foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三期/', 'docs/四期/')) {
|
||||
Assert-NotContains $rootAgents $removedPhaseDocsEntry "根已删除阶段目录 $removedPhaseDocsEntry"
|
||||
@@ -172,18 +197,18 @@ foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三
|
||||
Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围'
|
||||
|
||||
# 新增稳定业务能力必须从根表和目标部署单元直接命中。
|
||||
Assert-Contains $rootAgents '入库单扣货、解除与履约门禁' '根入库扣货路由'
|
||||
Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货路由'
|
||||
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由'
|
||||
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货路由'
|
||||
Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户资金账户路由'
|
||||
Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由'
|
||||
Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由'
|
||||
Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由'
|
||||
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户资金账户路由'
|
||||
Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照'
|
||||
Assert-Contains $businessIndex '顶层授权快照' '根海外顶层授权快照'
|
||||
Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照'
|
||||
Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由'
|
||||
Assert-Contains $rootAgents '与订舱级拖车预约是不同生命周期' '根两类拖车边界'
|
||||
Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由'
|
||||
Assert-Contains $businessIndex '目的港码头拖车与还空预警' '根目的港码头拖车路由'
|
||||
Assert-Contains $businessIndex '与订舱级拖车预约是不同生命周期' '根两类拖车边界'
|
||||
Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由'
|
||||
Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由'
|
||||
Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由'
|
||||
Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由'
|
||||
@@ -209,7 +234,7 @@ $firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md'
|
||||
$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
|
||||
$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md')
|
||||
$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml')
|
||||
Assert-Contains $rootAgents 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
|
||||
Assert-Contains $businessIndex 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
|
||||
Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义'
|
||||
Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口'
|
||||
Assert-Contains $firstlegAgents '服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数' 'Firstleg 同柜建组边界'
|
||||
@@ -233,7 +258,7 @@ Assert-Contains $lastlegAgents '/api/platform/v1/last-leg/inbound-plans/convert-
|
||||
# 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。
|
||||
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
|
||||
$financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md')
|
||||
Assert-Contains $rootAgents '客户头程订单批量导入' '根批量下单快速路由'
|
||||
Assert-Contains $businessIndex '客户头程订单批量导入' '根批量下单快速路由'
|
||||
Assert-Contains $orderAgents 'MerchantBatchImportService.java' 'Order 批量导入入口'
|
||||
Assert-Contains $orderAgents '仓库目录必须翻页读取全部候选' 'Order 批量模板全量仓库约束'
|
||||
Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口'
|
||||
@@ -259,7 +284,7 @@ $orderDeclarationService = Read-RequiredText (Join-Path $backendRoot 'order/src/
|
||||
$unifiedOrderQueryService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java')
|
||||
$firstlegDeclarationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java')
|
||||
$adminDeclarationEditor = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue')
|
||||
Assert-Contains $rootAgents '平台修改客户原始申报与履约渠道' '根订单申报快速路由'
|
||||
Assert-Contains $businessIndex '平台修改客户原始申报与履约渠道' '根订单申报快速路由'
|
||||
Assert-Contains $adminAgents '客户原始申报资料编辑与渠道变更' 'Admin 订单申报快速路由'
|
||||
Assert-Contains $backendAgents '平台修改客户原始申报、履约渠道变更与跨服务同步' '后端订单申报快速路由'
|
||||
Assert-Contains $orderAgents '平台版本化修改客户原始申报' 'Order 申报编辑入口'
|
||||
@@ -272,7 +297,7 @@ Assert-Contains $orderDeclarationService 'ORDER_DECLARATION_UPDATED_V1' 'Order
|
||||
Assert-Contains $firstlegDeclarationService 'mapper.byEvent(event.tenantId(), event.eventId())' 'Firstleg 申报 eventId 幂等'
|
||||
Assert-Contains $firstlegDeclarationService 'event.version() < current.orderVersion()' 'Firstleg 申报版本单调性'
|
||||
Assert-Contains $adminDeclarationEditor '仓库/PDA 实测数据不会被覆盖' 'Admin 申报事实边界提示'
|
||||
Assert-Contains $rootAgents '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态'
|
||||
Assert-Contains $businessIndex '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态'
|
||||
Assert-Contains $orderAgents '统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER`' 'Order 待审核优先状态'
|
||||
Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBMITTED' 'Order 待审核状态判断'
|
||||
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
|
||||
@@ -285,14 +310,14 @@ $fbaPricingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/mai
|
||||
$inboundFeeDetailService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java')
|
||||
$adminUnifiedOrdersApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts')
|
||||
$adminCarrierRatesView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
|
||||
Assert-Contains $rootAgents '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
|
||||
Assert-Contains $businessIndex '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
|
||||
Assert-Contains $adminAgents '订单列表、批量审核预览、详情、商品图片与入库费用' 'Admin 批量审核预览路由'
|
||||
Assert-Contains $backendAgents '平台订单批量审核预览' '后端批量审核预览路由'
|
||||
Assert-Contains $orderAgents '平台批量审核预览' 'Order 批量审核预览入口'
|
||||
Assert-Contains $orderOpenApi '/api/platform/v1/orders/first-leg/batch-review-preview:' 'Order 批量审核预览 OpenAPI'
|
||||
Assert-Contains $batchReviewPreviewService '固定两次批量查询生成审核弹窗快照' 'Order 批量审核禁止逐票查询'
|
||||
Assert-Contains $adminUnifiedOrdersApi '/platform/v1/orders/first-leg/batch-review-preview' 'Admin 批量审核预览 API'
|
||||
Assert-Contains $rootAgents '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
|
||||
Assert-Contains $businessIndex '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
|
||||
Assert-Contains $adminAgents '客户专属价格簿、柜级阶梯运费与受控调价' 'Admin 客户专属报价路由'
|
||||
Assert-Contains $backendAgents '客户专属价格簿、柜级阶梯运费与受控调价' '后端客户专属报价路由'
|
||||
Assert-Contains $firstlegAgents '客户专属价格簿、柜级阶梯运费与调价边界' 'Firstleg 客户专属报价入口'
|
||||
@@ -309,8 +334,8 @@ Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/
|
||||
# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。
|
||||
$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java')
|
||||
$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java')
|
||||
Assert-Contains $rootAgents '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
|
||||
Assert-Contains $adminAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
|
||||
Assert-Contains $businessIndex '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
|
||||
Assert-Contains $adminOverseasAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
|
||||
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
|
||||
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
|
||||
Assert-Contains $lastlegAgents '`RETURN_STOCK_IN` 流水' 'Lastleg 退回库存流水边界'
|
||||
@@ -325,9 +350,9 @@ $pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/sr
|
||||
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
|
||||
$clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts')
|
||||
$clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts')
|
||||
Assert-Contains $rootAgents '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
|
||||
Assert-Contains $rootAgents '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
|
||||
Assert-Contains $rootAgents '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
|
||||
Assert-Contains $businessIndex '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
|
||||
Assert-Contains $businessIndex '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
|
||||
Assert-Contains $businessIndex '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
|
||||
Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口'
|
||||
Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口'
|
||||
Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界'
|
||||
@@ -338,7 +363,7 @@ Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans
|
||||
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI'
|
||||
Assert-Contains $pendingReviewOrderService 'orders.pendingReviewPage' 'Order 待审核候选权威查询'
|
||||
Assert-Contains $pendingReviewOrderService '"PENDING_REVIEW"' 'Order 待审核候选状态映射'
|
||||
Assert-Contains $rootAgents '客户入库结果与头程费用确认' '根客户入库费用确认入口'
|
||||
Assert-Contains $businessIndex '客户入库结果与头程费用确认' '根客户入库费用确认入口'
|
||||
Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认'
|
||||
Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口'
|
||||
Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI'
|
||||
@@ -358,7 +383,7 @@ $smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/m
|
||||
$inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql')
|
||||
$adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
|
||||
$clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/views/FirstLegOrdersView.vue')
|
||||
Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
|
||||
Assert-Contains $businessIndex '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
|
||||
Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由'
|
||||
Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口'
|
||||
Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择'
|
||||
@@ -378,7 +403,7 @@ Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_i
|
||||
Assert-Contains $adminWeightPanel 'row.locationCode' 'Admin 称重卡片库位消费'
|
||||
Assert-Contains $adminWeightPanel 'putawayStatusLabel(row.slotStatus)' 'Admin 称重卡片槽位状态'
|
||||
Assert-NotContains $adminWeightPanel 'class="putaway-guide"' 'Admin 已移除整票预留引导区'
|
||||
Assert-Contains $rootAgents '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量'
|
||||
Assert-Contains $businessIndex '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量'
|
||||
Assert-Contains $firstlegAgents '`totalPackageCount/remainingPackageCount`' 'Firstleg 智能装柜暂估数量'
|
||||
Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin 智能装柜暂估数量'
|
||||
Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI'
|
||||
@@ -397,7 +422,7 @@ $iamMemberService = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/java
|
||||
$adminCustomersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/customers/pages/CustomersView.vue')
|
||||
$clientCompanyProfile = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/CompanyProfileView.vue')
|
||||
$clientMembersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/MembersView.vue')
|
||||
Assert-Contains $rootAgents '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界'
|
||||
Assert-Contains $businessIndex '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界'
|
||||
Assert-Contains $adminAgents '公司联系电话只作企业联系信息,不修改管理员/成员登录手机号' 'Admin 企业联系人边界'
|
||||
Assert-Contains $clientAgents '成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计' 'Client 成员登录手机号边界'
|
||||
Assert-Contains $authAgents '登录标识与企业联系人解耦' 'Auth 登录标识入口'
|
||||
@@ -411,7 +436,7 @@ Assert-Contains $clientCompanyProfile '不会修改任何成员的登录手机
|
||||
Assert-Contains $clientMembersView '用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话' 'Client 成员登录手机号提示'
|
||||
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
|
||||
$qualificationOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/qualification-required-package-v1.yaml')
|
||||
Assert-Contains $rootAgents '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义'
|
||||
Assert-Contains $businessIndex '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义'
|
||||
Assert-Contains $fileAgents '法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换' 'File 法人身份证单独上传入口'
|
||||
Assert-Contains $qualificationOpenApi '/api/merchant/v1/files/qualifications:' '客户单文件资质 OpenAPI'
|
||||
Assert-Contains $qualificationOpenApi 'LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换' '客户法人身份证单独上传契约'
|
||||
@@ -427,8 +452,8 @@ $adminLastMileOrders = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-w
|
||||
$loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml')
|
||||
$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js')
|
||||
$cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java')
|
||||
Assert-Contains $rootAgents '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义'
|
||||
Assert-Contains $rootAgents '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
|
||||
Assert-Contains $businessIndex '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义'
|
||||
Assert-Contains $businessIndex '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
|
||||
Assert-Contains $adminAgents '订舱级拖车预约' 'Admin 拖车快速入口'
|
||||
Assert-Contains $adminAgents '不选择或提交客户、柜次、装柜计划或真实集装箱号' 'Admin 拖车订舱级边界'
|
||||
Assert-Contains $backendAgents '订舱级拖车' '后端拖车快速入口'
|
||||
@@ -454,7 +479,7 @@ Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间
|
||||
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
|
||||
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口'
|
||||
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口'
|
||||
Assert-Contains $adminAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
|
||||
Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
|
||||
Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界'
|
||||
Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界'
|
||||
Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁'
|
||||
@@ -465,7 +490,7 @@ Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜
|
||||
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'
|
||||
Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API'
|
||||
Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API'
|
||||
Assert-Contains $rootAgents '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界'
|
||||
Assert-Contains $businessIndex '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界'
|
||||
Assert-Contains $firstlegAgents '完全重放返回当前视图且不得再次上传 File' 'Firstleg 装柜证据重放边界'
|
||||
Assert-Contains $cabinetEvidenceService 'uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart)' '装柜证据上传请求哈希'
|
||||
Assert-Contains $cabinetEvidenceService 'digest.update(multipart.getBytes())' '装柜证据文件内容哈希'
|
||||
@@ -525,13 +550,13 @@ foreach ($pdaApplication in $pdaApplications) {
|
||||
}
|
||||
}
|
||||
|
||||
# 海外 PDA 已开放拆柜收货、库内、换标和现场查询真实作业。
|
||||
# 海外 PDA 已开放拆柜收货、移库、托盘、换标和现场查询真实作业;库存盘点仅保留底层实现。
|
||||
$overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js'
|
||||
$overseasWorkbench = Read-RequiredText $overseasWorkbenchPath
|
||||
Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关'
|
||||
Assert-Matches $overseasWorkbench "(?s)name:\s*'托盘管理'.{0,300}?available:\s*true.{0,200}?route:\s*'/pages/pallet/pallet'" 'Overseas PDA 托盘管理开关'
|
||||
Assert-NotContains $overseasWorkbench "key: 'stocktake'" 'Overseas PDA 隐藏库存盘点入口'
|
||||
foreach ($enabledOperation in @(
|
||||
@{ Name = '库存盘点'; Route = '/pages/stocktake/stocktake' },
|
||||
@{ Name = '商品移库'; Route = '/pages/move/move' },
|
||||
@{ Name = '换标收货'; Route = '/pages/relabel/relabel\?op=relabelReceive' },
|
||||
@{ Name = '换标执行'; Route = '/pages/relabel/relabel\?op=relabelExecute' },
|
||||
@@ -589,7 +614,7 @@ Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 T
|
||||
$oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
|
||||
Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义'
|
||||
Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约'
|
||||
Assert-Contains $oceanBookingOpenApi 'exact warehouseId, channelCode and containerType' '订舱关联候选筛选语义'
|
||||
Assert-Contains $oceanBookingOpenApi 'booking channel belonging to channelGroupCode' '订舱关联候选筛选语义'
|
||||
|
||||
$adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts')
|
||||
$adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue')
|
||||
@@ -612,7 +637,7 @@ foreach ($overseasFulfillmentRoute in @(
|
||||
foreach ($overseasFulfillmentRootEntry in @(
|
||||
'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue'
|
||||
)) {
|
||||
Assert-Contains $rootAgents $overseasFulfillmentRootEntry '根海外履约正式业务路由'
|
||||
Assert-Contains $businessIndex $overseasFulfillmentRootEntry '根海外履约正式业务路由'
|
||||
}
|
||||
Assert-Contains $adminAgents '海外排车、出库与派送' 'Admin 海外出库派送入口'
|
||||
Assert-Contains $adminAgents '海外异常退回与客户回访' 'Admin 海外异常回访入口'
|
||||
@@ -704,8 +729,8 @@ $containerCustomsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/s
|
||||
$adminContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts')
|
||||
$clientContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/api/customs.ts')
|
||||
$customsFileOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/customs-file-and-export-v1.yaml')
|
||||
Assert-Contains $rootAgents 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口'
|
||||
Assert-Contains $rootAgents 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口'
|
||||
Assert-Contains $businessIndex 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口'
|
||||
Assert-Contains $businessIndex 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口'
|
||||
Assert-Contains $adminAgents 'components/ContainerCustomsPanel.vue' 'Admin 整柜报关入口'
|
||||
Assert-Contains $adminAgents 'complete-all-buy' 'Admin 全买单柜入口'
|
||||
Assert-Contains $clientAgents 'components/CustomsDocumentsDialog.vue' 'Client 退税报关资料入口'
|
||||
@@ -746,16 +771,16 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans
|
||||
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
|
||||
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
|
||||
Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
|
||||
Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
|
||||
Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
|
||||
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
|
||||
Assert-Contains $adminAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
|
||||
Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
|
||||
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
|
||||
Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
|
||||
Assert-Contains $adminAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
|
||||
Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
|
||||
Assert-Contains $adminAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
|
||||
Assert-Contains $adminAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
|
||||
Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
|
||||
Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
|
||||
Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
|
||||
Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
|
||||
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
|
||||
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
|
||||
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'
|
||||
@@ -791,8 +816,8 @@ $eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/s
|
||||
$overseasDispatcher = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java')
|
||||
$financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java')
|
||||
$financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java')
|
||||
Assert-Contains $rootAgents '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
|
||||
Assert-Contains $rootAgents '海外业务费用来源与客户财务' '根海外费用快速路由'
|
||||
Assert-Contains $businessIndex '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
|
||||
Assert-Contains $businessIndex '海外业务费用来源与客户财务' '根海外费用快速路由'
|
||||
Assert-Contains $adminAgents '财务、客户资金账户与柜级公共费用分摊' 'Admin 柜费分摊快速路由'
|
||||
Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由'
|
||||
Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由'
|
||||
@@ -827,16 +852,16 @@ Assert-Contains $overseasDispatcher 'EventTypes.WALLET_COMMAND_REQUESTED' 'Event
|
||||
Assert-Matches $overseasDispatcher '(?s)FINANCE_EVENT_TYPES\.contains\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge Finance 事件定向投递'
|
||||
Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界'
|
||||
Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界'
|
||||
Assert-Contains $rootAgents '入库单扣货、解除与履约门禁' '根入库扣货快速路由'
|
||||
Assert-Contains $adminAgents '入库单扣货、解除、凭证与历史' 'Admin 入库扣货快速路由'
|
||||
Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货快速路由'
|
||||
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货快速路由'
|
||||
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由'
|
||||
Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口'
|
||||
Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户钱包快速路由'
|
||||
Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户钱包快速路由'
|
||||
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户钱包快速路由'
|
||||
Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口'
|
||||
Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口'
|
||||
Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界'
|
||||
Assert-Contains $rootAgents '海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集' '根海外小 Token 边界'
|
||||
Assert-Contains $businessIndex '海外 Token 不携带角色或权限集' '根海外小 Token 边界'
|
||||
Assert-Contains $backendAgents '海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集' '后端海外小 Token 边界'
|
||||
Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等'
|
||||
Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影'
|
||||
@@ -1121,6 +1146,7 @@ foreach ($removedOverseasWarehouseMasterPath in @(
|
||||
$indexedAgentFiles = @(
|
||||
$rootAgentsPath,
|
||||
$adminAgentsPath,
|
||||
$adminOverseasAgentsPath,
|
||||
$clientAgentsPath,
|
||||
$pdaAgentsPath,
|
||||
$overseasPdaAgentsPath,
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8')
|
||||
const start = source.indexOf('$registeredTestRows =')
|
||||
const end = source.indexOf("Assert-NotContains $testTaskRegistry '01a0194a", start)
|
||||
assert.ok(start > 0 && end > start)
|
||||
const registryContract = source.slice(start, end)
|
||||
const project = 'c64729ff-6710-43c6-8ef2-d84e9666cc1e'
|
||||
const root = '00000000-0000-4000-8000-000000000001'
|
||||
const child = '00000000-0000-4000-8000-000000000002'
|
||||
const enabled = `已重新启用测试环境总控调度\nparent_task_id project_id\n| \`${root}\` | \`${project}\` | 【总控】测试环境全流程 |\n| \`${child}\` | \`${root}\` | \`${project}\` | 【前端】专项 |`
|
||||
|
||||
function checkRegistry(text) {
|
||||
const script = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$failures = [System.Collections.Generic.List[string]]::new()
|
||||
function Add-Failure([string]$message) { $script:failures.Add($message) }
|
||||
function Assert-Contains([string]$text, [string]$needle, [string]$scope) { if (-not $text.Contains($needle)) { Add-Failure "$scope 缺少 $needle" } }
|
||||
function Assert-Matches([string]$text, [string]$pattern, [string]$scope) { if ($text -notmatch $pattern) { Add-Failure "$scope 缺少结构" } }
|
||||
$testTaskRegistry = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(text).toString('base64')}'))
|
||||
${registryContract}
|
||||
Write-Output ('RESULT:' + (ConvertTo-Json -InputObject @($failures) -Compress))
|
||||
`
|
||||
const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { encoding: 'utf8' })
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const output = result.stdout.match(/RESULT:(\[[^\r\n]*\])/)
|
||||
assert.ok(output, result.stdout)
|
||||
return JSON.parse(output[1])
|
||||
}
|
||||
|
||||
test('cleared test registry stays disabled and explicitly revokes inherited authorization', () => {
|
||||
assert.deepEqual(checkRegistry('不启用总控调度\n当前无已登记总控或长期专项\n持续授权同步失效'), [])
|
||||
assert.ok(checkRegistry('当前无已登记总控或长期专项').length > 0)
|
||||
assert.ok(checkRegistry('').length > 0)
|
||||
test('cleared test registry is mandatory and cannot inherit historical task authorization', () => {
|
||||
for (const token of [
|
||||
"Assert-Contains $testTaskRegistry '当前无登记任务'",
|
||||
"Assert-Contains $testTaskRegistry '清空全部任务登记与调度规则'",
|
||||
"Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工、恢复任务或回传进度'",
|
||||
"Assert-Contains $testTaskRegistry '不作为当前调度或授权依据'",
|
||||
"Assert-NotContains $testTaskRegistry '已重新启用测试环境总控调度'",
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(source, /if \(\$testTaskRegistry -match '\(\?m\)\^\\\|\\s\*`\[0-9a-f-\]\{36\}`\\s\*\\\|'\)/)
|
||||
})
|
||||
|
||||
test('enabled registry requires explicit activation, one controller, unique tasks and its own project', () => {
|
||||
assert.deepEqual(checkRegistry(enabled), [])
|
||||
assert.ok(checkRegistry(enabled.replace('已重新启用测试环境总控调度', '')).length > 0)
|
||||
assert.ok(checkRegistry(enabled.replace('parent_task_id', '')).length > 0)
|
||||
assert.ok(checkRegistry(enabled.replace(child, root)).length > 0)
|
||||
assert.ok(checkRegistry(enabled.replace('【前端】专项', '【总控】其他')).length > 0)
|
||||
assert.ok(checkRegistry(enabled.replaceAll(project, '6f874ec3-d3b2-49bd-8625-327f4d685f98')).length > 0)
|
||||
test('cleared registry rejects every retired controller and project identity', () => {
|
||||
for (const retired of [
|
||||
'01a0194a-259c-7171-8967-074731a27618',
|
||||
'01a06c0b-0515-7c60-b329-77342b54e666',
|
||||
'6f874ec3-d3b2-49bd-8625-327f4d685f98',
|
||||
]) assert.match(source, new RegExp(`Assert-NotContains \\$testTaskRegistry '${retired}'`))
|
||||
})
|
||||
|
||||
@@ -273,7 +273,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
|
||||
actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际入托体积(立方米)',
|
||||
actual_package_count INT NOT NULL DEFAULT 0 COMMENT '实际入托箱数',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no),
|
||||
KEY idx_ll_inbound_pallet_no (pallet_no),
|
||||
UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id,sequence_no),
|
||||
KEY idx_ll_inbound_pallet_plan (plan_id)
|
||||
) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托';
|
||||
@@ -1265,10 +1265,16 @@ PREPARE inventory_carton_qty_020 FROM @ddl; EXECUTE inventory_carton_qty_020; DE
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
|
||||
source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照',
|
||||
manifest_version INT NOT NULL COMMENT '整托清单版本', manifest_package_count INT NOT NULL COMMENT '整托总箱数',
|
||||
manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数', manifest_hash CHAR(64) NOT NULL COMMENT '整托完整清单摘要',
|
||||
inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order(customer_id,order_id), UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key)
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no),
|
||||
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id),
|
||||
KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)
|
||||
) ENGINE=InnoDB COMMENT='一件代发托盘档案';
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
@@ -3928,7 +3934,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), KEY idx_wh_pallet_batch_item_pallet (pallet_id),
|
||||
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
|
||||
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
|
||||
USE baiyue_lastleg;
|
||||
|
||||
@@ -25,7 +25,7 @@ test('Compose and deploy script expose finance with fail-closed database config'
|
||||
assert.match(compose, /image: \$\{IMAGE_REPOSITORY\}\/finance:\$\{IMAGE_TAG\}/)
|
||||
assert.match(compose, /SERVER_PORT: 9203/)
|
||||
assert.match(compose, /FINANCE_DB_URL: \$\{FINANCE_DB_URL\}/)
|
||||
assert.match(compose, /FINANCE_WALLET_DEFAULT_CREDIT_LIMIT: \$\{FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:-0\.00\}/)
|
||||
assert.doesNotMatch(compose, /FINANCE_WALLET_DEFAULT_CREDIT_LIMIT/)
|
||||
assert.match(serviceMap, /^ finance:\s*$/m)
|
||||
assert.match(serviceMap, /jar: finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar/)
|
||||
assert.match(deployScript, /FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD/)
|
||||
|
||||
@@ -160,10 +160,12 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV
|
||||
assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/)
|
||||
assert.match(source, /includes\('-P4-FBA-STANDARD:'\)/)
|
||||
assert.match(source, /-P4-FBA-STANDARD\(\?:-R\\d\+\)\?:/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(source, /runUntilPickupReady\(this\)/)
|
||||
const variant = source.slice(source.indexOf('async runPhase4ProducerVariant'), source.indexOf('async ensurePhase4PdaProducerMatrix'))
|
||||
assert.match(variant, /this\.phase4ProducerAttempts\.get\(label\)/)
|
||||
assert.match(variant, /attempt === 1 \? '' : `-R\$\{attempt\}`/)
|
||||
assert.ok(variant.indexOf('ensureInitialCalculated') < variant.indexOf('phase3-loading-phase4-'))
|
||||
assert.match(variant, /fulfillmentType === 'ONE_CLICK_DROPSHIP'/)
|
||||
assert.doesNotMatch(variant, /inboundFees\.run\(this\)/)
|
||||
|
||||
@@ -121,6 +121,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
|
||||
feign('order/src/main/java/com/baiyue/order/client/FirstlegColoadGroupClient.java',
|
||||
'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '批量读取同柜不可拆组聚合'),
|
||||
feign('order/src/main/java/com/baiyue/order/client/FinanceWalletClient.java',
|
||||
'order-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', '提交订单前读取客户钱包余额与有效授信'),
|
||||
feign('order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java',
|
||||
'order-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'BUSINESS_KEY',
|
||||
'列表查询/缺失修复在线执行;审核建单由 Outbox 投递,按 orderId/inboundCode 收敛'),
|
||||
|
||||
@@ -37,6 +37,7 @@ const expectedOnlineEdges = [
|
||||
'lastleg-service->warehouse-overseas-service',
|
||||
'lastleg-service->warehouse-service',
|
||||
'order-service->file-service',
|
||||
'order-service->finance-service',
|
||||
'order-service->firstleg-service',
|
||||
'order-service->iam-service',
|
||||
'order-service->warehouse-service',
|
||||
@@ -98,11 +99,11 @@ async function discoverFeignEdges() {
|
||||
return clients.sort((left, right) => left.path.localeCompare(right.path))
|
||||
}
|
||||
|
||||
test('all 56 Feign clients have reviewed context, access and idempotency metadata', async () => {
|
||||
test('all 57 Feign clients have reviewed context, access and idempotency metadata', async () => {
|
||||
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
|
||||
.map(({ path, source, target }) => ({ path, source, target }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
assert.equal(reviewed.length, 56)
|
||||
assert.equal(reviewed.length, 57)
|
||||
assert.deepEqual(await discoverFeignEdges(), reviewed)
|
||||
})
|
||||
|
||||
@@ -193,7 +194,7 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines',
|
||||
])
|
||||
assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [
|
||||
['auth-service', 'iam-service'],
|
||||
['firstleg-service', 'order-service']
|
||||
['finance-service', 'firstleg-service', 'order-service']
|
||||
])
|
||||
assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[
|
||||
'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service'
|
||||
@@ -210,7 +211,7 @@ test('overseas security projection stays in Lastleg infrastructure and backgroun
|
||||
assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service'))
|
||||
})
|
||||
|
||||
test('Order and Firstleg remain a bilateral REQUEST SCC until loading-complete timing is frozen', () => {
|
||||
test('Order, Firstleg and Finance form the reviewed REQUEST SCC', () => {
|
||||
const requestClients = SYNCHRONOUS_CLIENTS.filter(client => client.contexts.includes('REQUEST'))
|
||||
const orderToFirstleg = requestClients.filter(client =>
|
||||
client.source === 'order-service' && client.target === 'firstleg-service')
|
||||
@@ -230,7 +231,8 @@ test('Order and Firstleg remain a bilateral REQUEST SCC until loading-complete t
|
||||
'firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java'
|
||||
])
|
||||
assert.ok(stronglyConnectedComponents(graphEdges('REQUEST')).some(component =>
|
||||
component.includes('firstleg-service') && component.includes('order-service')))
|
||||
component.includes('firstleg-service') && component.includes('order-service')
|
||||
&& component.includes('finance-service')))
|
||||
})
|
||||
|
||||
test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => {
|
||||
@@ -246,13 +248,13 @@ test('known user entries expose every longest simple REQUEST path over the two-h
|
||||
'iam-service->auth-service',
|
||||
'iam-service->warehouse-service'
|
||||
] },
|
||||
'order-service': { hops: 4, paths: [
|
||||
'order-service->firstleg-service->file-service->iam-service->auth-service',
|
||||
'order-service->firstleg-service->file-service->iam-service->warehouse-service'
|
||||
'order-service': { hops: 5, paths: [
|
||||
'order-service->finance-service->firstleg-service->file-service->iam-service->auth-service',
|
||||
'order-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'firstleg-service': { hops: 4, paths: [
|
||||
'firstleg-service->order-service->file-service->iam-service->auth-service',
|
||||
'firstleg-service->order-service->file-service->iam-service->warehouse-service'
|
||||
'firstleg-service': { hops: 5, paths: [
|
||||
'firstleg-service->order-service->finance-service->file-service->iam-service->auth-service',
|
||||
'firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'finance-service': { hops: 5, paths: [
|
||||
'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service',
|
||||
|
||||
Reference in New Issue
Block a user