merge: integrate TEST updates and verify appointment release
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
`appointment-work-test-env.mjs`:预约事项与稳定批次 TEST 定向 HTTP 验收,默认 `--plan` 零网络;显式 `--execute --environment=TEST --run-id=<新批次> --mode=read|workflow --fixture=<本轮清单>` 才执行。运行者用 `node --env-file=<明确TEST配置>` 注入平台、客户及只读平台凭据,变量名和清单合同见 `--plan`。清单必须引用本轮正式业务生产者报告及 SHA256,不借用历史业务样本。read 只登录、查询、退出自身会话;workflow 还写一项跟进并验证原键重放、陈旧版本、异载荷和权限拒绝,可按清单登记客户仓无需预约要求,不代替排车/PDA/签收原域事实。报告写入独占的新 `reports/appointment-work-<run-id>/`;失败保留报告和业务记录,不自动重发未知结果、删除、重置或执行 SQL。离线合同为 `appointment-work-test-env.test.mjs`;只读或离线通过不代表完整运输履约、真实页面通过。
|
||||
|
||||
`../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。
|
||||
|
||||
`db/cargo-discard-consolidated-incremental-20261014.sql`:废弃业务结构增量汇总,MySQL 8.0.16+,仅对 `baiyue_lastleg` 已有业务库执行。覆盖废弃基础表、PDA完工字段、照片关联、退货箱废弃状态、通用库存来源和部分选箱;表/列/索引按存在性判断,约束原子替换,可重复执行。不回填历史订单、不补造出库单、不修改库存数量,无业务 DML;File及仓储资源库无需本次SQL。实例和凭据由执行者指定,完整执行、遇错停止、不使用 `--force`;DDL隐式提交,修正错误后可重跑,末尾 `SHOW CREATE TABLE` 结果由执行者保存核对,不自动清理或连接环境。
|
||||
|
||||
`db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。
|
||||
|
||||
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
|
||||
@@ -506,3 +510,16 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
|
||||
`f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。
|
||||
|
||||
- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。
|
||||
|
||||
废弃明细字段专项修复:db/cargo-discard-item-columns-repair-20261014.sql。仅对 MySQL 8.0.16+ 的 baiyue_lastleg 已有 ll_cargo_discard_item 表补齐 10 个新增字段,逐列判断,可重复执行,独立于退货约束。实例和凭据由执行者指定;不写业务数据,不修改库存,不自动访问环境。完整执行、遇错停止,DDL隐式提交,修正错误后可重跑;末尾只读查询应返回10行,由执行者保存结果,无自动清理。
|
||||
|
||||
废弃退货箱归零约束修复:db/cargo-discard-return-quantity-repair-20261014.sql。仅 baiyue_lastleg,MySQL 8.0.16+,将箱内SKU数量约束改为非负以支持整箱废弃。实例及凭据由执行者指定;可重复执行,原子替换约束,无业务DML,不连接环境自动执行。整份执行、遇错停止,DDL隐式提交,修正后可重跑;末尾输出表结构供保存核对,不清理业务数据。正式增量为 deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql,已纳入废弃汇总脚本。
|
||||
|
||||
客户废弃照片确认增量:db/cargo-discard-customer-confirmation-incremental-20261014.sql。MySQL 8.0.16+,目标明确为 baiyue_lastleg 和 baiyue_iam;连接实例与凭据由执行者指定。补齐客户确认时间和独立确认权限,默认客户管理员获得授权,自定义角色需手动分配;授权与受影响管理员的安全版本在同一事务提交,重跑安全版本仍单调递增。完整执行、遇错停止、修正后可重跑;不确认或回填历史单据,不修改库存,不自动访问环境或清理。执行结果由操作者保存,权限更新后需重新登录客户账号。对应正式迁移为 Lastleg V27 和 IAM V6;未执行时页面权限与服务字段尚不可用。
|
||||
|
||||
`db/warehouse-transfer-carton-counting-incremental.sql`:仓储转运逐箱测量与清点增量,显式 USE baiyue_lastleg;逐列检查并新增箱子尺寸、重量、实点件数、清点操作人、时间、幂等字段、箱所属转运托盘ID及独立SKU申报件数 declared_quantity,可重复执行,无业务DML、不回填历史事实。适用于明确授权的本地或开发MySQL8,凭据由操作者外部提供;失败保留原错误,修复后重跑,不清理单据。已同步尾程基线和海外统一增量。
|
||||
|
||||
`local-warehouse-transfer-carton-acceptance.mjs`:仅本机统一Gateway的仓储转运逐箱验收;命令前显式 `node --env-file=<本地配置文件>`,必须提供 ADMIN_TEST_GATEWAY、ADMIN_TEST_USERNAME/PASSWORD、LOCAL_TRANSFER_PDA_PASSWORD,可选 OVERSEAS_TEST_CUSTOMER_ID、TRANSFER_CARTON_OUTPUT、TRANSFER_KEEP_PDA。复用既有有效客户和海外仓,以业务HTTP创建4笔带批次标识的新单,覆盖单箱、多SKU分箱多托、短收及超收、SKU申报件数与预计箱数独立持久化且PDA实收不覆盖原申报、先生成多托盘再逐箱选择托盘、箱级测量清点及托盘体积重量自动汇总、错误SKU/零件数/缺少差异原因拒绝、幂等重放、箱标签、打托绑定、测试照片和入库完成回查。测试照片为合成PNG,不证明真机拍照或物理打印。创建本轮PDA账号,默认finally停用;仅显式 TRANSFER_KEEP_PDA=true 时保留供本轮页面验收后人工停用。不改主档、不写业务数据库、不删除单据;报告不保存凭据或Token,失败保留已创建单号,排查后用新批次重跑,不自动清库或续跑既有单。
|
||||
|
||||
|
||||
`db/warehouse-transfer-consolidated-incremental-20261015.sql`:仓储转运近期本地增量汇总,固定 `USE baiyue_lastleg`,适用已有 Lastleg 基线的本地/开发 MySQL 8。依次合入 `warehouse-transfer-outbound-incremental.sql` 和 `warehouse-transfer-carton-counting-incremental.sql`,前者已覆盖旧 `dev-warehouse-transfer-pallet-lifecycle-incremental.sql`,无须重复跑旧生命周期补丁。包含审核、打托/照片、换标、目的地/出库/派送结构、实收箱数,以及逐箱尺寸、重量、实点件数、清点幂等、差异原因、箱所属托盘和独立 SKU 申报件数。只执行 DDL/元数据查询,无业务 DML;不回填单据或修改库存。实例和凭据由操作者在数据库客户端外部提供,无内置连接信息或报告文件。需支持 DELIMITER 和 CREATE ROUTINE/ALTER/INDEX 权限,整文件串行执行,遇错停止,不使用 --force;可按缺失结构检查重复执行,不修复任意同名异构旧表。末尾 `missing_carton_and_quantity_columns=0`、`inbound_pallet_unique_index=READY`、`migration_result=WAREHOUSE_TRANSFER_RECENT_SCHEMA_READY` 表示本轮列/索引核对通过;同客户重复托盘号会报告 INDEX_BLOCKED,不擅自更改旧托盘号。仅整理文件不表示目标库已经执行;连接必须由操作者明确选中,不重放 `deploy/sql/海外区域统一增量-MySQL8.sql` 空库基线。
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, open, readFile } from 'node:fs/promises'
|
||||
import { resolve, dirname, relative, isAbsolute } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
|
||||
import { guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const API = '/api/platform/v1/last-leg', WORK = `${API}/appointment-work`
|
||||
const id = value => String(value ?? ''), enc = encodeURIComponent
|
||||
const digest = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex')
|
||||
const requireFact = (condition, message) => { if (!condition) throw new Error(message) }
|
||||
const ok = r => r?.http === 200 && id(r.body?.code) === '200'
|
||||
const refused = (r, status) => (r?.http === status || r?.http === 200) && id(r?.body?.code) === `A0${status}` && r.body?.data == null
|
||||
const sort = rows => rows.map(row => JSON.stringify(row)).sort()
|
||||
const source = row => ({ customerId: id(row.customerId), sourceKey: row.sourceKey, destinationKey: row.destinationKey, packageCount: Number(row.packageCount) })
|
||||
|
||||
export function plan() {
|
||||
return { environment: 'TEST', execute: false, entry: 'scripts/appointment-work-test-env.mjs',
|
||||
modes: { read: 'Only login/logout and GET: selected current-run work, exact FBA allocations/private dispatch pallets, shared trips, explicit retired replacements and actual dates.',
|
||||
workflow: 'Also writes SAVE_FOLLOWUP on one pending fixture item; verifies reader denial, exact replay, stale version and changed-payload rejection. Optional CUSTOMER SAVE_REQUIREMENT records no booking, without execution.' },
|
||||
command: 'node --env-file=<private>/test-acceptance.env scripts/appointment-work-test-env.mjs --execute --environment=TEST --run-id=<new> --mode=read|workflow --fixture=reports/<producer>/appointment-work-fixture.json',
|
||||
prerequisites: ['Latest IAM/Firstleg/Lastleg and permission/DDL baseline deployed by coordinator',
|
||||
'Current-run formal HTTP producer report, its SHA256 and an explicit fixture; no historical discovery or fabricated inventory',
|
||||
'CHONGCHONG_ENV=test and CHONGCHONG_ADMIN_API_BASE_URL ending /api; CHONGCHONG_ADMIN_USERNAME/PASSWORD',
|
||||
'CHONGCHONG_MERCHANT_USERNAME/PASSWORD for customer denial; workflow also APPOINTMENT_WORK_READER_USERNAME/PASSWORD (platform list + source reads, without followup/manage-work)',
|
||||
'Owner is an active platform IAM user; scheduled synchronization complete before execution'],
|
||||
fixture: { schemaVersion: 1, environment: 'TEST', runId: '<new>', producerRunId: '<current producer>', producedAt: '<ISO instant>', producerReport: 'reports/<producer>/report.json', producerSha256: '<sha256>',
|
||||
cases: [{ name: 'customer', containerBookingId: '<id>', type: 'DELIVERY', flow: 'TRANSFER', requirementKey: 'DISPATCH:<id>', batchKey: 'DISPATCH:<id>', batchNo: '<dispatch business number>',
|
||||
expected: { executionStatus: 'PENDING', planStatus: 'UNPLANNED', dateBasis: 'NONE', calendarDate: null }, containerIds: ['<id>'] }],
|
||||
batches: [{ case: 'customer', kind: 'CUSTOMER', dispatchNo: '<business number>', packageCount: 1, pallets: [{ planPalletId: '<id>', packageCount: 1 }] }],
|
||||
sharedTrips: [], retired: [], workflow: { case: 'customer', ownerId: '<active IAM user id>', followupDate: '<YYYY-MM-DD>', noBooking: true } },
|
||||
fixtureNotes: ['FBA batch entries instead use kind=FBA, appointmentNo, sources:[{customerId,sourceKey,destinationKey,packageCount}], minimumReadyCandidates (0 allowed).',
|
||||
'sharedTrips:[{case,tripNo,tripId,containerIds,dispatches:[{dispatchNo,customerId,packageCount}]}]. The case must select TRANSPORT.',
|
||||
'retired:[{workItemKey,containerBookingId,replacementCases:[name]}] checks absence from active container work and exact named replacements in old detail.',
|
||||
'For COMPLETED cases provide expected.actual and calendarDate/dateBasis=ACTUAL; completed rows are loaded with their date filter.',
|
||||
'workflow requires one pending item exposing SAVE_FOLLOWUP; noBooking additionally requires editable CUSTOMER DELIVERY and no original appointment plan.'],
|
||||
minimumProducer: { entry:'scripts/admin-test-env-acceptance.mjs', arguments:'--environment test --modules phase4-carrier-pickup-ready --wait-split-count 1 --wait-split-package-count 1 --output reports/<new-producer>/producer.json',
|
||||
notes:'Uses its existing ADMIN_TEST_* and ADMIN_PDA_* configuration. Dependency closure creates a new customer/order, measurement/loading/customs/outbound and formal ship-node evidence. Select only the current report createdResources.phase3CarrierScheduleQuery.containerBookingId. First acceptance can target its UNKNOWN ROUTE work with workflow.noBooking=false; this does not prove stable batches.',
|
||||
batchPrerequisites:'Then use current formal route, cargo-verification/FBA appointment or PRIVATE receiving/pre-arrange APIs to prepare new stable batches; shared trip and completion stages need actual original dispatch/fleet/PDA execution before a new read report.',
|
||||
existingProducerBlocker:'phase4-overseas-inbound-operations.mjs currently cancels FBA by PUT details.appointmentStatus=CANCELLED; the current service rejects this and requires the dedicated cancellation operation. Do not use that whole module as a verified batch producer until its owner fixes the contract.' },
|
||||
remaining: ['This package does not create sailed containers, appointments, dispatches, stock or fleet trips, cancel original businesses, pick/depart/sign, or run internal synchronization.',
|
||||
'Prepare/advance those samples with existing formal producers; run a new read report after each state transition. This is not end-to-end execution proof.',
|
||||
'PRIVATE per-order pick quantity conservation and DIRECT receiving-write rejection require the original PDA/dispatch producer; this reader checks public batch identity/pallet quantities and completed facts only.',
|
||||
'1920x1080 manual page evidence remains separate.'],
|
||||
boundary: 'Default plan is zero network/file writes. Execute claims a new report directory, journals before every write, stops on unknown outcome, retains samples, and logs out only its sessions. No SQL, deployment, deletes or automatic resume.' }
|
||||
}
|
||||
|
||||
export function options(args, execArgs, env) {
|
||||
if (!args.length || args.length === 1 && args[0] === '--plan') return { execute: false }
|
||||
const values = new Map()
|
||||
for (const arg of args) {
|
||||
const match = /^--(execute|environment|run-id|mode|fixture)(?:=(.*))?$/.exec(arg)
|
||||
requireFact(match && !values.has(match[1]), 'Unknown or duplicate argument')
|
||||
values.set(match[1], match[1] === 'execute' && match[2] === undefined ? true : match[2])
|
||||
}
|
||||
requireFact(values.size === 5 && values.get('execute') === true && values.get('environment') === 'TEST'
|
||||
&& /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(values.get('run-id') || '') && ['read', 'workflow'].includes(values.get('mode')) && values.get('fixture'), 'Explicit TEST, new run-id, mode and fixture required')
|
||||
requireFact(execArgs.length === 1 && execArgs[0].startsWith('--env-file=')
|
||||
&& resolve(execArgs[0].slice(11)).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env'), 'Explicit authorized TEST env-file required')
|
||||
requireFact(env.CHONGCHONG_ENV === 'test', 'TEST profile required')
|
||||
const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
|
||||
requireFact(['http:', 'https:'].includes(base.protocol) && base.pathname.replace(/\/$/, '') === '/api' && !base.username && !base.password && !base.search && !base.hash, 'Unified TEST API base required')
|
||||
const credentialKeys = ['CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'CHONGCHONG_MERCHANT_USERNAME', 'CHONGCHONG_MERCHANT_PASSWORD']
|
||||
if (values.get('mode') === 'workflow') credentialKeys.push('APPOINTMENT_WORK_READER_USERNAME', 'APPOINTMENT_WORK_READER_PASSWORD')
|
||||
requireFact(credentialKeys.every(key => typeof env[key] === 'string' && env[key]), 'Required actor credentials missing')
|
||||
return { execute: true, runId: values.get('run-id'), mode: values.get('mode'), fixture: values.get('fixture'), gateway: base.origin, credentialKeys }
|
||||
}
|
||||
|
||||
export function validateFixture(f, config, now = Date.now()) {
|
||||
requireFact(f?.schemaVersion === 1 && f.environment === 'TEST' && f.runId === config.runId && /^[A-Za-z0-9_-]{1,80}$/.test(f.producerRunId || '')
|
||||
&& /^[a-f0-9]{64}$/.test(f.producerSha256 || '') && typeof f.producerReport === 'string', 'Current-run HTTP producer provenance required')
|
||||
const age = now - Date.parse(f.producedAt)
|
||||
requireFact(Number.isFinite(age) && age >= 0 && age < 24 * 3600000, 'Fixture must have been prepared in the current 24-hour window')
|
||||
requireFact(Array.isArray(f.cases) && f.cases.length > 0 && f.cases.length <= 30, 'One to thirty explicit work cases required')
|
||||
const names = new Set()
|
||||
for (const c of f.cases) {
|
||||
requireFact(/^[A-Za-z0-9_-]{1,40}$/.test(c.name || '') && !names.has(c.name), 'Unique case names required'); names.add(c.name)
|
||||
requireFact(typeof c.containerBookingId === 'string' && c.containerBookingId.length > 0 && ['ROUTE','PICKUP','WAREHOUSE_RECEIPT','DELIVERY','TRANSPORT','EMPTY_RETURN'].includes(c.type)
|
||||
&& ['UNKNOWN','TRANSFER','DIRECT'].includes(c.flow) && Array.isArray(c.containerIds) && c.containerIds.includes(c.containerBookingId), 'Exact case scope required')
|
||||
requireFact(['PENDING','IN_PROGRESS','COMPLETED'].includes(c.expected?.executionStatus) && ['UNPLANNED','TARGET_SET','AWAITING_REPLY','CONFIRMED','NOT_REQUIRED'].includes(c.expected?.planStatus)
|
||||
&& ['NONE','TARGET','CONFIRMED','ACTUAL'].includes(c.expected?.dateBasis) && Object.hasOwn(c.expected, 'calendarDate'), 'Explicit status and calendar expectation required')
|
||||
if (c.expected.executionStatus === 'COMPLETED') requireFact(c.expected.actual && c.expected.dateBasis === 'ACTUAL' && /^\d{4}-\d{2}-\d{2}$/.test(c.expected.calendarDate || ''), 'Completion requires independent actual evidence and date')
|
||||
}
|
||||
for (const key of ['batches', 'sharedTrips', 'retired']) requireFact(Array.isArray(f[key]) && f[key].length <= 30, `${key} must be an explicit bounded list`)
|
||||
for (const b of f.batches) {
|
||||
const c = f.cases.find(c => c.name === b.case)
|
||||
requireFact(c?.type === 'DELIVERY' && c.batchKey && ['FBA','CUSTOMER'].includes(b.kind), 'Batch requires its delivery case')
|
||||
if (b.kind === 'FBA') requireFact(/^FBA_APPOINTMENT:[1-9]\d*$/.test(c.batchKey) && b.appointmentNo && Array.isArray(b.sources) && b.sources.length > 0 && b.sources.every(s => s.customerId && s.sourceKey && s.destinationKey && Number.isSafeInteger(s.packageCount) && s.packageCount > 0) && Number.isSafeInteger(b.minimumReadyCandidates) && b.minimumReadyCandidates >= 0, 'Exact FBA source expectations required')
|
||||
else requireFact(/^DISPATCH:[1-9]\d*$/.test(c.batchKey) && b.dispatchNo && Number.isSafeInteger(b.packageCount) && b.packageCount > 0 && Array.isArray(b.pallets) && b.pallets.length > 0 && b.pallets.every(p => typeof p.planPalletId === 'string' && Number.isSafeInteger(p.packageCount) && p.packageCount > 0), 'Exact private dispatch physical expectations required')
|
||||
}
|
||||
for (const t of f.sharedTrips) requireFact(f.cases.some(c => c.name === t.case && c.type === 'TRANSPORT') && t.tripNo && typeof t.tripId === 'string' && Array.isArray(t.containerIds) && t.containerIds.length > 0 && Array.isArray(t.dispatches) && t.dispatches.length > 0, 'Exact trip source expectations required')
|
||||
for (const r of f.retired) requireFact(r.workItemKey && r.containerBookingId && r.replacementCases?.length > 0 && r.replacementCases.every(n => names.has(n)), 'Explicit retired replacements required')
|
||||
if (config.mode === 'workflow') requireFact(names.has(f.workflow?.case) && typeof f.workflow.ownerId === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(f.workflow.followupDate || '') && typeof f.workflow.noBooking === 'boolean', 'Workflow target, owner and follow-up required')
|
||||
return f
|
||||
}
|
||||
|
||||
export function validatePage(page, number, size = 100) {
|
||||
requireFact(page && Number.isSafeInteger(page.total) && page.total >= 0 && page.page === number && page.size === size && page.pages === Math.ceil(page.total / size)
|
||||
&& Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, page.total - (number - 1) * size)), 'Incomplete work page')
|
||||
return page.records
|
||||
}
|
||||
export function assertItem(item, c) {
|
||||
requireFact(item?.workItemKey && item.type === c.type && item.flow === c.flow, `Case ${c.name}: wrong work identity`)
|
||||
for (const key of ['requirementKey','batchKey','batchNo']) if (Object.hasOwn(c, key)) assert.equal(item[key], c[key], `Case ${c.name}: ${key}`)
|
||||
assert.deepEqual(item.containers.map(r => id(r.containerBookingId)).sort(), [...c.containerIds].sort(), `Case ${c.name}: current container scope`)
|
||||
for (const [key, value] of Object.entries(c.expected)) { requireFact(['executionStatus','planStatus','dateBasis','calendarDate','actual','reviewRequired','timezone'].includes(key), 'Unsupported expectation field'); assert.deepEqual(item[key], value, `Case ${c.name}: ${key}`) }
|
||||
if (item.executionStatus !== 'COMPLETED') assert.equal(item.actual, null, 'Unfinished work must not invent completion')
|
||||
if (c.flow === 'DIRECT') requireFact(item.type !== 'WAREHOUSE_RECEIPT', 'DIRECT cannot create warehouse receipt work')
|
||||
return item
|
||||
}
|
||||
|
||||
/** Runs only on explicit current-run identities. Caller provides authenticated, journaled transport. */
|
||||
export async function verify(context, fixture, config) {
|
||||
const checks = [], resolved = new Map()
|
||||
const check = (name, condition) => { requireFact(condition, name); checks.push(name) }
|
||||
async function data(method, path, body, actor = 'admin') {
|
||||
const r = await context.request(actor, method, path, body)
|
||||
requireFact(ok(r), `HTTP_FAILURE ${method} ${path.split('?')[0]} http=${r?.http} code=${id(r?.body?.code)} requestId=${id(r?.requestId)} traceId=${id(r?.traceId)}`)
|
||||
return r.body.data
|
||||
}
|
||||
async function rows(container, date) {
|
||||
const result = []; let total = null
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const query = new URLSearchParams({ view: 'ALL', page: id(page), size: '100', containerBookingId: container, ...(date ? { date } : {}) })
|
||||
const response = await data('GET', `${WORK}/items?${query}`), next = validatePage(response, page)
|
||||
requireFact(total == null || total === response.total, 'Work scope changed during pagination'); total = response.total
|
||||
result.push(...next); if (page >= response.pages) { requireFact(result.length === total && new Set(result.map(i => i.workItemKey)).size === total, 'Duplicate or missing work rows'); return result }
|
||||
}
|
||||
throw Error('Work pagination limit exceeded')
|
||||
}
|
||||
const anonymous = await context.request('anonymous', 'GET', `${WORK}/summary`)
|
||||
check('anonymous cannot read work', [401,403].includes(anonymous?.http) && anonymous.body?.data == null)
|
||||
check('customer cannot read platform work', refused(await context.request('merchant', 'GET', `${WORK}/summary`), 403))
|
||||
const summary = await data('GET', `${WORK}/summary`)
|
||||
check('summary contains complete initialized counters', ['all','followToday','overdue','undated','review'].every(k=>Number.isSafeInteger(summary[k])&&summary[k]>=0))
|
||||
for (const c of fixture.cases) {
|
||||
const current = await rows(c.containerBookingId, c.expected.executionStatus === 'COMPLETED' ? c.expected.calendarDate : null)
|
||||
if (c.flow === 'DIRECT') check(`${c.name}: no active warehouse receiving branch`, !current.some(i => i.type === 'WAREHOUSE_RECEIPT'))
|
||||
const matches = current.filter(i => i.type === c.type && i.flow === c.flow && ['requirementKey','batchKey','batchNo'].every(k => !Object.hasOwn(c,k) || i[k] === c[k]))
|
||||
check(`${c.name}: exactly one current item`, matches.length === 1); assertItem(matches[0], c)
|
||||
const detail = await data('GET', `${WORK}/items/${enc(matches[0].workItemKey)}`); assertItem(detail.item, c)
|
||||
const second = await data('GET', `${WORK}/items/${enc(matches[0].workItemKey)}`)
|
||||
assert.deepEqual(second, detail, 'Repeated GET must not write history or versions')
|
||||
resolved.set(c.name, detail); checks.push(`${c.name}: detail/get-no-write/status/date verified`)
|
||||
if (c.expected.calendarDate) {
|
||||
const day = c.expected.calendarDate, calendar = await data('GET', `${WORK}/calendar?from=${enc(day)}&to=${enc(day)}`)
|
||||
check(`${c.name}: calendar uses authoritative date`, calendar.days.some(d => d.date === day && d.total > 0 && d.typeCounts[c.type] > 0))
|
||||
}
|
||||
}
|
||||
for (const b of fixture.batches) {
|
||||
const item = resolved.get(b.case).item
|
||||
if (b.kind === 'FBA') {
|
||||
const batch = await data('GET', `${API}/appointment-planning/batches/${enc(item.batchKey)}`)
|
||||
check(`${b.case}: immutable FBA batch and business number`, batch.appointment.batchKey === item.batchKey && batch.appointment.appointmentNo === b.appointmentNo && item.batchNo === b.appointmentNo)
|
||||
assert.deepEqual(sort(batch.appointment.items.map(source)), sort(b.sources.map(source)), 'FBA per-source quantities must match producer evidence')
|
||||
const candidates = await data('GET', `${API}/appointment-planning/batches/${enc(item.batchKey)}/dispatch-candidates`)
|
||||
check(`${b.case}: complete FBA candidates`, candidates.batchKey === item.batchKey && candidates.version === batch.appointment.version && Array.isArray(candidates.candidates) && Array.isArray(candidates.blockReasons))
|
||||
const ready = candidates.candidates.filter(c => c.blockReasons.length === 0)
|
||||
check(`${b.case}: required real physical candidates`, ready.length >= b.minimumReadyCandidates)
|
||||
for (const candidate of ready) check(`${b.case}: candidate per-source quantity conservation`, candidate.allocations.length > 0 && candidate.allocations.every(a => a.batchKey === item.batchKey && id(a.customerId) === id(candidate.customerId) && id(a.planPalletId) === id(candidate.planPalletId)) && candidate.allocations.reduce((n,a) => n+a.packageCount,0) === candidate.packageCount)
|
||||
} else {
|
||||
const dispatch = await data('GET', `${API}/dispatches/${enc(b.dispatchNo)}`)
|
||||
check(`${b.case}: original private dispatch identity`, item.batchKey === `DISPATCH:${dispatch.id}` && item.batchNo === dispatch.dispatchNo && dispatch.dispatchNo === b.dispatchNo)
|
||||
check(`${b.case}: physical total`, dispatch.packageCount === b.packageCount && dispatch.items.reduce((n,p) => n+p.packageCount,0) === b.packageCount)
|
||||
assert.deepEqual(sort(dispatch.items.map(p => ({planPalletId:id(p.planPalletId),packageCount:p.packageCount}))), sort(b.pallets), 'Private original pallet allocation drift')
|
||||
}
|
||||
}
|
||||
for (const t of fixture.sharedTrips) {
|
||||
const detail = resolved.get(t.case), trip = await data('GET', `${API}/fleet/trips/${enc(t.tripNo)}`)
|
||||
check(`${t.case}: canonical trip identity`, detail.item.businessNo === t.tripNo && detail.references.some(r => r.kind === 'FLEET_TRIP' && r.key === t.tripId && r.businessNo === t.tripNo) && trip.trip.tripNo === t.tripNo)
|
||||
assert.deepEqual(detail.item.containers.map(c => c.containerBookingId).sort(), [...t.containerIds].sort(), 'Shared trip container membership drift')
|
||||
assert.deepEqual(sort(trip.dispatches.map(d => ({dispatchNo:d.dispatchNo,customerId:id(d.customerId),packageCount:d.packageCount}))), sort(t.dispatches), 'Shared trip original dispatch membership drift')
|
||||
for (const container of t.containerIds) check(`${t.case}: shared transport counted once per container`, (await rows(container, detail.item.calendarDate || undefined)).filter(i => i.type === 'TRANSPORT' && i.workItemKey === detail.item.workItemKey).length === 1)
|
||||
}
|
||||
for (const r of fixture.retired) {
|
||||
check('retired item excluded from active scope', !(await rows(r.containerBookingId)).some(i => i.workItemKey === r.workItemKey))
|
||||
const old = await data('GET', `${WORK}/items/${enc(r.workItemKey)}`)
|
||||
check('retired item retains history and read-only actions', old.history.some(h => h.action === 'REPLACED') && old.item.allowedActions.every(a => ['VIEW_HISTORY','VIEW_DETAIL'].includes(a.code)))
|
||||
for (const name of r.replacementCases) check('original requirement restored or explicitly replaced', old.relatedItems.some(i => i.workItemKey === resolved.get(name).item.workItemKey))
|
||||
}
|
||||
if (config.mode === 'workflow') {
|
||||
const w = fixture.workflow, before = resolved.get(w.case), key = before.item.workItemKey, path = `${WORK}/items/${enc(key)}`
|
||||
check('workflow sample is unfinished and exposes followup', before.item.executionStatus !== 'COMPLETED' && before.item.allowedActions.some(a => a.code === 'SAVE_FOLLOWUP'))
|
||||
await data('GET', path, undefined, 'reader')
|
||||
const command = { command:'SAVE_FOLLOWUP', expectedVersion:before.item.version, idempotencyKey:`AW-${config.runId}-FOLLOWUP`, payload:{ownerId:w.ownerId,followupDate:w.followupDate,note:`预约工作HTTP验收 ${config.runId}`} }
|
||||
check('list-only platform cannot write followup', refused(await context.request('reader','POST',`${path}/commands`,command),403))
|
||||
assert.deepEqual(await data('GET',path),before,'Denied write changed original work')
|
||||
const result = await data('POST',`${path}/commands`,command), saved = await data('GET',path)
|
||||
check('followup resolves the active IAM owner', result.item.owner?.id === w.ownerId && typeof result.item.owner.name === 'string' && result.item.owner.name.length > 0 && result.item.followupDate === w.followupDate)
|
||||
assert.deepEqual(result.item.actual,before.item.actual,'Followup invented execution'); assert.equal(result.item.executionStatus,before.item.executionStatus)
|
||||
check('one command creates one history entry',saved.history.length===before.history.length+1)
|
||||
assert.deepEqual(await data('POST',`${path}/commands`,command),result,'Idempotent replay changed response')
|
||||
assert.deepEqual(await data('GET',path),saved,'Idempotent replay wrote history or version')
|
||||
check('changed payload under same key rejected',refused(await context.request('admin','POST',`${path}/commands`,{...command,payload:{...command.payload,note:'变更内容应拒绝'}}),409))
|
||||
check('stale version rejected',refused(await context.request('admin','POST',`${path}/commands`,{...command,idempotencyKey:`AW-${config.runId}-STALE`}),409))
|
||||
assert.deepEqual(await data('GET',path),saved,'Rejected stale/tampered write changed work')
|
||||
if(w.noBooking) {
|
||||
check('explicit customer requirement is editable',before.item.type==='DELIVERY' && saved.requirement && saved.item.allowedActions.some(a=>a.code==='SAVE_REQUIREMENT'))
|
||||
const updated=await data('POST',`${path}/commands`,{command:'SAVE_REQUIREMENT',expectedVersion:saved.item.version,idempotencyKey:`AW-${config.runId}-NOBOOK`,payload:{target:null,bookingRequired:false,receivingRequirements:'本轮测试客户仓按到货接收,无需外部预约',reason:`明确收货要求 ${config.runId}`}})
|
||||
check('no booking does not mean executed',updated.item.planStatus==='NOT_REQUIRED' && updated.item.executionStatus===before.item.executionStatus && updated.item.actual===null)
|
||||
}
|
||||
}
|
||||
return { checks, resolved: Object.fromEntries([...resolved].map(([name,d])=>[name,{workItemKey:d.item.workItemKey,version:d.item.version,batchKey:d.item.batchKey,batchNo:d.item.batchNo,executionStatus:d.item.executionStatus}])) }
|
||||
}
|
||||
|
||||
function inReports(path) {
|
||||
const absolute=resolve(ROOT,path), rel=relative(resolve(ROOT,'reports'),absolute)
|
||||
requireFact(rel && !rel.startsWith('..') && !isAbsolute(rel), 'Fixture and producer evidence must stay inside this TEST workspace reports directory')
|
||||
return absolute
|
||||
}
|
||||
export async function execute(args=process.argv.slice(2),execArgs=process.execArgv,env=process.env) {
|
||||
const config=options(args,execArgs,env);if(!config.execute)return plan()
|
||||
requireFact(ROOT.replaceAll('\\','/').endsWith('/chongchong-global/baiyue-firstleg'),'Registered TEST workspace required')
|
||||
const fixtureBytes=await readFile(inReports(config.fixture));requireFact(fixtureBytes.length<=1024*1024,'Fixture too large')
|
||||
const fixture=validateFixture(JSON.parse(fixtureBytes),config)
|
||||
const producer=await readFile(inReports(fixture.producerReport));requireFact(digest(producer)===fixture.producerSha256,'Producer evidence digest mismatch')
|
||||
const directory=resolve(ROOT,'reports',`appointment-work-${config.runId}`)
|
||||
await mkdir(dirname(directory),{recursive:true});await mkdir(directory)
|
||||
let sequence=0
|
||||
const report={runId:config.runId,producerRunId:fixture.producerRunId,fixtureSha256:digest(fixtureBytes),mode:config.mode,status:'RUNNING',startedAt:new Date().toISOString(),deploymentBlocker:false,checks:[],sessionsClosed:[],remaining:plan().remaining}
|
||||
const secrets=config.credentialKeys.map(k=>env[k]),sessions=new Map()
|
||||
async function save(name,value) {const file=await open(resolve(directory,name),'wx',0o600);try{await file.writeFile(JSON.stringify(redactFleetWrite(value,secrets),null,2)+'\n');await file.sync()}finally{await file.close()}}
|
||||
async function checkpoint(entry) {
|
||||
// Public query responses can include unrelated records; persist only own request and diagnostic metadata.
|
||||
const {responseBody,raw,...evidence}=entry
|
||||
if(evidence.path?.startsWith('/api/auth/')) evidence.body={sessionOperation:true}
|
||||
await save(`${String(++sequence).padStart(5,'0')}-${entry.status||'event'}.json`,{...evidence,responseCode:responseBody?.code})
|
||||
}
|
||||
await save('claim.json',report)
|
||||
const transport=new AcceptanceRun({environment:'test',gateway:config.gateway,username:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD,quiet:true,modules:[]})
|
||||
const cleanupRequest=transport.request.bind(transport)
|
||||
guardFleetWrites(transport,checkpoint)
|
||||
const request=async(actor,method,path,body)=> {
|
||||
requireFact(method==='GET' || method==='POST' && (['/api/auth/login','/api/auth/logout'].includes(path) || /^\/api\/platform\/v1\/last-leg\/appointment-work\/items\/[^/]+\/commands$/.test(path)),'Unexpected write boundary')
|
||||
const response=await transport.request(method,path,body,sessions.get(actor)?.accessToken||'')
|
||||
if(method==='GET')await checkpoint({status:'READ',actor,method,path,http:response.http,code:response.body?.code,requestId:response.requestId,traceId:response.traceId})
|
||||
return response
|
||||
}
|
||||
try {
|
||||
const actors=[['admin','CHONGCHONG_ADMIN'],['merchant','CHONGCHONG_MERCHANT'],...(config.mode==='workflow'?[['reader','APPOINTMENT_WORK_READER']]:[])]
|
||||
for(const[name,prefix]of actors){const r=await request(name,'POST','/api/auth/login',{loginId:env[`${prefix}_USERNAME`],password:env[`${prefix}_PASSWORD`]});requireFact(ok(r)&&r.body.data?.accessToken&&r.body.data?.refreshToken,'Incomplete login');sessions.set(name,r.body.data);secrets.push(r.body.data.accessToken,r.body.data.refreshToken)}
|
||||
Object.assign(report,await verify({request},fixture,config));report.status='VERIFIED'
|
||||
}catch(error){report.status=String(error.message).includes('Unknown write outcome')?'UNKNOWN_STOP':'FAILED';report.deploymentBlocker=true;report.failure=redactFleetWrite(String(error.message),secrets)}
|
||||
finally {
|
||||
for(const[name,session]of sessions){
|
||||
try {await checkpoint({status:'LOGOUT_SENDING',actor:name,path:'/api/auth/logout'});const r=await cleanupRequest('POST','/api/auth/logout',{refreshToken:session.refreshToken},session.accessToken);await checkpoint({status:'LOGOUT_RESULT',actor:name,http:r.http,code:r.body?.code,requestId:r.requestId,traceId:r.traceId});requireFact(ok(r),'Logout failed');report.sessionsClosed.push(name)}
|
||||
catch{report.deploymentBlocker=true;report.logoutFailure=true;if(report.status==='VERIFIED')report.status='FAILED'}
|
||||
}
|
||||
report.completedAt=new Date().toISOString();await save('report.json',report)
|
||||
}
|
||||
return {success:report.status==='VERIFIED',status:report.status,checks:report.checks.length,report:resolve(directory,'report.json')}
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){execute().then(result=>{console.log(JSON.stringify(result,null,2));if(result.success===false)process.exitCode=1}).catch(()=>{console.error('Appointment work acceptance not started: verify explicit TEST configuration, current-run fixture and unused report directory.');process.exitCode=1})}
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { options, plan, validateFixture, validatePage, assertItem, verify, execute } from './appointment-work-test-env.mjs'
|
||||
import { guardFleetWrites } from './fleet-test-env-acceptance.mjs'
|
||||
|
||||
const WORK='/api/platform/v1/last-leg/appointment-work', API='/api/platform/v1/last-leg'
|
||||
const now=Date.parse('2026-10-15T08:00:00Z')
|
||||
function sample() {
|
||||
return {schemaVersion:1,environment:'TEST',runId:'aw-test',producerRunId:'producer-test',producedAt:'2026-10-15T07:00:00Z',producerReport:'reports/producer/report.json',producerSha256:'a'.repeat(64),
|
||||
cases:[{name:'private',containerBookingId:'1',containerIds:['1'],type:'DELIVERY',flow:'TRANSFER',requirementKey:'DISPATCH:20',batchKey:'DISPATCH:20',batchNo:'D-20',expected:{executionStatus:'PENDING',planStatus:'UNPLANNED',dateBasis:'NONE',calendarDate:null}}],
|
||||
batches:[{case:'private',kind:'CUSTOMER',dispatchNo:'D-20',packageCount:4,pallets:[{planPalletId:'10',packageCount:4}]}],sharedTrips:[],retired:[],workflow:{case:'private',ownerId:'90',followupDate:'2026-10-16',noBooking:true}}
|
||||
}
|
||||
function workItem(c=sample().cases[0]) {
|
||||
return {workItemKey:'WORK-1',version:1,type:c.type,flow:c.flow,requirementKey:c.requirementKey,batchKey:c.batchKey,batchNo:c.batchNo,businessNo:'D-20',
|
||||
containers:c.containerIds.map(id=>({containerBookingId:id,containerNo:`CNT-${id}`,bookingNo:null})),...c.expected,actual:c.expected.actual||null,
|
||||
allowedActions:[{code:'SAVE_FOLLOWUP'},{code:'SAVE_REQUIREMENT'}],blockReasons:[],reviewRequired:false,owner:null,followupDate:null}
|
||||
}
|
||||
function response(data,http=200,code='200') {return {http,body:{code,data},requestId:'REQ',traceId:'TRACE'}}
|
||||
function harness(fixture=sample()) {
|
||||
const requests=[], detail={item:workItem(fixture.cases[0]),history:[],references:[],relatedItems:[],requirement:{bookingRequired:true},review:null,returnHandoff:null},replays=new Map()
|
||||
const clone=v=>structuredClone(v)
|
||||
async function request(actor,method,path,body) {
|
||||
requests.push({actor,method,path,body:clone(body)})
|
||||
if(actor==='anonymous')return response(null,401,'A0401')
|
||||
if(actor==='merchant'||actor==='reader'&&method==='POST')return response(null,403,'A0403')
|
||||
if(path===`${WORK}/summary`)return response({all:1,followToday:0,overdue:0,undated:1,review:0})
|
||||
if(path.startsWith(`${WORK}/items?`))return response({records:[clone(detail.item)],page:1,size:100,total:1,pages:1})
|
||||
if(path===`${WORK}/items/WORK-1`&&method==='GET')return response(clone(detail))
|
||||
if(path===`${API}/dispatches/D-20`)return response({id:'20',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:4}]})
|
||||
if(path===`${WORK}/items/WORK-1/commands`) {
|
||||
if(replays.has(body.idempotencyKey)){const old=replays.get(body.idempotencyKey);return JSON.stringify(old.body)===JSON.stringify(body)?response(clone(old.result)):response(null,409,'A0409')}
|
||||
if(body.expectedVersion!==detail.item.version)return response(null,409,'A0409')
|
||||
if(body.command==='SAVE_FOLLOWUP'){detail.item.owner={id:body.payload.ownerId,name:'验收操作员'};detail.item.followupDate=body.payload.followupDate}
|
||||
else if(body.command==='SAVE_REQUIREMENT'){detail.item.planStatus='NOT_REQUIRED';detail.requirement.bookingRequired=false}
|
||||
else throw Error('Unexpected command')
|
||||
detail.item.version++;detail.history.push({action:body.command,summary:'fixture'})
|
||||
const result={item:clone(detail.item),affectedWorkItemKeys:[]};replays.set(body.idempotencyKey,{body:clone(body),result:clone(result)});return response(result)
|
||||
}
|
||||
throw Error(`Unexpected request ${method} ${path}`)
|
||||
}
|
||||
return {request,requests,detail}
|
||||
}
|
||||
|
||||
test('default and explicit plan perform no network and no fixture/file reads',async()=>{
|
||||
const original=globalThis.fetch;globalThis.fetch=()=>{throw Error('NETWORK_FORBIDDEN')}
|
||||
try {assert.equal((await execute([],[],{})).execute,false);assert.deepEqual(await execute(['--plan'],[],{}),plan())}finally{globalThis.fetch=original}
|
||||
const child=spawnSync(process.execPath,['scripts/appointment-work-test-env.mjs','--plan'],{encoding:'utf8'})
|
||||
assert.equal(child.status,0,child.stderr);assert.equal(JSON.parse(child.stdout).execute,false)
|
||||
})
|
||||
test('execution rejects ambiguous environments, duplicate args and wrong private profile',()=>{
|
||||
const args=['--execute','--environment=TEST','--run-id=aw-test','--mode=workflow','--fixture=reports/new/fixture.json']
|
||||
const env={CHONGCHONG_ENV:'test',CHONGCHONG_ADMIN_API_BASE_URL:'https://example.invalid/api',CHONGCHONG_ADMIN_USERNAME:'admin',CHONGCHONG_ADMIN_PASSWORD:'secret',CHONGCHONG_MERCHANT_USERNAME:'customer',CHONGCHONG_MERCHANT_PASSWORD:'secret',APPOINTMENT_WORK_READER_USERNAME:'reader',APPOINTMENT_WORK_READER_PASSWORD:'secret'}
|
||||
const flags=['--env-file=C:/Users/test/.codex/env/chongchong-global/test-acceptance.env']
|
||||
assert.equal(options(args,flags,env).mode,'workflow')
|
||||
assert.throws(()=>options([...args,'--mode=read'],flags,env))
|
||||
assert.throws(()=>options(args,flags,{...env,CHONGCHONG_ENV:'prod'}))
|
||||
assert.throws(()=>options(args,['--env-file=other.env'],env))
|
||||
assert.throws(()=>options(args,flags,{...env,CHONGCHONG_ADMIN_API_BASE_URL:'https://example.invalid/overseas-api'}))
|
||||
assert.throws(()=>options(args,flags,{...env,APPOINTMENT_WORK_READER_PASSWORD:''}))
|
||||
})
|
||||
test('fixture requires current producer evidence and explicit nonempty expectations',()=>{
|
||||
assert.equal(validateFixture(sample(),{runId:'aw-test',mode:'workflow'},now).cases.length,1)
|
||||
for(const patch of [{cases:[]},{runId:'old'},{producedAt:'2026-10-13T00:00:00Z'},{producerSha256:''},{batches:[{case:'private',kind:'CUSTOMER',dispatchNo:'D-20',packageCount:4,pallets:[]}]}])assert.throws(()=>validateFixture({...sample(),...patch},{runId:'aw-test',mode:'workflow'},now))
|
||||
const incomplete=sample();incomplete.cases[0].expected.executionStatus='COMPLETED';assert.throws(()=>validateFixture(incomplete,{runId:'aw-test',mode:'read'},now))
|
||||
})
|
||||
test('pagination and actual evidence cannot silently truncate or invent completion',()=>{
|
||||
assert.throws(()=>validatePage({records:[],page:1,size:100,total:1,pages:1},1))
|
||||
const c=sample().cases[0], item=workItem(c);assertItem(item,c)
|
||||
assert.throws(()=>assertItem({...item,actual:{localOccurredAt:'2026-10-15T11:00:00',occurredAt:null,timezone:null}},c))
|
||||
assert.throws(()=>assertItem({...item,containers:[{containerBookingId:'foreign'}]},c))
|
||||
})
|
||||
test('workflow runs denial/replay/stale/no-booking checks without original-business writes',async()=>{
|
||||
const h=harness(),result=await verify(h,sample(),{mode:'workflow',runId:'aw-test'})
|
||||
assert.ok(result.checks.includes('no booking does not mean executed'))
|
||||
assert.ok(result.checks.includes('stale version rejected'))
|
||||
assert.equal(h.detail.item.executionStatus,'PENDING');assert.equal(h.detail.item.actual,null)
|
||||
assert.equal(h.detail.history.length,2)
|
||||
assert.ok(h.requests.filter(r=>r.method!=='GET').every(r=>r.method==='POST'&&r.path===`${WORK}/items/WORK-1/commands`))
|
||||
assert.ok(!h.requests.some(r=>r.path.includes('/DISPATCH%3A20/dispatch-candidates')))
|
||||
})
|
||||
test('read mode is GET-only and rejects private dispatch identity or quantity drift',async()=>{
|
||||
const h=harness();await verify(h,sample(),{mode:'read',runId:'aw-test'});assert.ok(h.requests.every(r=>r.method==='GET'))
|
||||
for(const wrong of [{id:'21',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:4}]},{id:'20',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:3}]}]){
|
||||
const h=harness();await assert.rejects(()=>verify({request:async(...args)=>args[2]===`${API}/dispatches/D-20`?response(wrong):h.request(...args)},sample(),{mode:'read',runId:'aw-test'}))
|
||||
}
|
||||
})
|
||||
test('denied writes must leave history and versions unchanged',async()=>{
|
||||
const h=harness();await assert.rejects(()=>verify({request:async(...args)=>{const r=await h.request(...args);if(args[0]==='reader'&&args[1]==='POST')h.detail.item.version++;return r}},sample(),{mode:'workflow',runId:'aw-test'}),/Denied write/)
|
||||
})
|
||||
test('FBA batch source allocation and candidate quantity are independently checked',async()=>{
|
||||
const f=sample(),c=f.cases[0];Object.assign(c,{name:'fba',batchKey:'FBA_APPOINTMENT:20',requirementKey:'FBA_APPOINTMENT:20',batchNo:'AP-20'})
|
||||
const expected={customerId:'C1',sourceKey:'ORDER:1',destinationKey:'FBA:ONT8',packageCount:4}
|
||||
f.batches=[{case:'fba',kind:'FBA',appointmentNo:'AP-20',sources:[expected],minimumReadyCandidates:1}]
|
||||
const h=harness(f)
|
||||
let count=4
|
||||
const request=async(...args)=>{
|
||||
if(args[2]===`${API}/appointment-planning/batches/FBA_APPOINTMENT%3A20`)return response({appointment:{batchKey:c.batchKey,appointmentNo:'AP-20',version:1,items:[expected]}})
|
||||
if(args[2].endsWith('/dispatch-candidates'))return response({batchKey:c.batchKey,version:1,blockReasons:[],candidates:[{planPalletId:'10',customerId:'C1',packageCount:4,blockReasons:[],allocations:[{...expected,packageCount:count,batchKey:c.batchKey,planPalletId:'10'}]}]})
|
||||
return h.request(...args)
|
||||
}
|
||||
await verify({request},f,{mode:'read',runId:'aw-test'});count=3;await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/quantity conservation/)
|
||||
})
|
||||
test('shared trip checks original dispatch identities, quantities and current containers',async()=>{
|
||||
const f=sample(),c=f.cases[0];Object.assign(c,{name:'trip',type:'TRANSPORT',containerIds:['1','2'],batchKey:null,batchNo:null,requirementKey:null});f.batches=[]
|
||||
f.sharedTrips=[{case:'trip',tripNo:'TR-1',tripId:'100',containerIds:['1','2'],dispatches:[{dispatchNo:'D-20',customerId:'C1',packageCount:4}]}]
|
||||
const h=harness(f);h.detail.item.businessNo='TR-1';h.detail.references=[{kind:'FLEET_TRIP',key:'100',businessNo:'TR-1'}]
|
||||
const request=async(...args)=>args[2]===`${API}/fleet/trips/TR-1`?response({trip:{tripNo:'TR-1'},dispatches:f.sharedTrips[0].dispatches}):h.request(...args)
|
||||
await verify({request},f,{mode:'read',runId:'aw-test'})
|
||||
h.detail.references[0].key='999';await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/canonical trip identity/)
|
||||
})
|
||||
test('completed local facts keep their explicit date and do not disappear behind the pending filter',async()=>{
|
||||
const f=sample();f.batches=[];Object.assign(f.cases[0].expected,{executionStatus:'COMPLETED',dateBasis:'ACTUAL',calendarDate:'2026-10-15',actual:{occurredAt:null,localOccurredAt:'2026-10-15T23:30:00',timezone:null}})
|
||||
const h=harness(f),request=async(...args)=>args[2].startsWith(`${WORK}/calendar?`)?response({days:[{date:'2026-10-15',total:1,typeCounts:{DELIVERY:1}}]}):h.request(...args)
|
||||
await verify({request},f,{mode:'read',runId:'aw-test'})
|
||||
assert.ok(h.requests.some(r=>r.path.includes('/items?')&&r.path.includes('date=2026-10-15')))
|
||||
assert.equal(h.requests.filter(r=>r.method==='POST').length,0)
|
||||
})
|
||||
test('retired cancellation requires visible original history and exact replacement identities',async()=>{
|
||||
const f=sample();f.retired=[{workItemKey:'OLD',containerBookingId:'1',replacementCases:['private']}]
|
||||
const h=harness();let replacements=[structuredClone(h.detail.item)]
|
||||
const request=async(...args)=>args[2]===`${WORK}/items/OLD`?response({history:[{action:'REPLACED'}],item:{allowedActions:[{code:'VIEW_HISTORY'}]},relatedItems:replacements}):h.request(...args)
|
||||
await verify({request},f,{mode:'read',runId:'aw-test'});replacements=[]
|
||||
await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/original requirement restored/)
|
||||
})
|
||||
test('existing write guard persists before send and halts after unknown result',async()=>{
|
||||
const events=[],context={options:{},request:async()=>{events.push('SENT');return {http:503,body:null}}}
|
||||
guardFleetWrites(context,async entry=>events.push(entry.status))
|
||||
await assert.rejects(()=>context.request('POST',`${WORK}/items/WORK-1/commands`,{}),/Unknown write outcome/)
|
||||
await assert.rejects(()=>context.request('POST',`${WORK}/items/WORK-1/commands`,{}))
|
||||
assert.deepEqual(events,['SENDING','SENT','UNKNOWN_RESULT'])
|
||||
let sends=0;const diskFailure={options:{},request:async()=>{sends++}}
|
||||
guardFleetWrites(diskFailure,async()=>{throw Error('DISK_FAILED')})
|
||||
await assert.rejects(()=>diskFailure.request('POST','/api/auth/login',{}),/DISK_FAILED/);assert.equal(sends,0)
|
||||
})
|
||||
test('entry keeps report exclusive and reuses formal transport without SQL/internal sync/deploy',async()=>{
|
||||
const code=await readFile(new URL('./appointment-work-test-env.mjs',import.meta.url),'utf8')
|
||||
assert.match(code,/new AcceptanceRun/);assert.match(code,/guardFleetWrites\(transport,checkpoint\)/)
|
||||
assert.match(code,/await mkdir\(directory\)/);assert.match(code,/'wx',0o600/);assert.match(code,/await file\.sync\(\)/)
|
||||
assert.match(code,/digest\(producer\)===fixture\.producerSha256/)
|
||||
assert.doesNotMatch(code,/mysql|JENKINS_|\/internal\/|fetch\(/)
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
-- 货物废弃增量汇总(2026-10-14),MySQL 8.0.16+,可重复执行。
|
||||
-- 目标仅 baiyue_lastleg,连接实例和凭据由执行者指定。
|
||||
-- 前提:已有尾程入仓箱、退货库存及出库基线;不是空库初始化脚本。
|
||||
-- 覆盖基础废弃表、PDA完工、退货箱废弃、照片、通用库存及部分选箱。
|
||||
-- 不回填历史单据、不补造出库单、不修改库存数量、不执行业务DML。
|
||||
-- File复用已有文件表,无需修改 baiyue_file 或仓储资源库。
|
||||
-- 完整执行,遇错停止,不使用 --force;DDL隐式提交,失败修正后可重跑。
|
||||
-- 已有同名表/列/索引沿用,不修复任意未知旧结构,末尾输出结构供核对。
|
||||
USE `baiyue_lastleg`;
|
||||
SET NAMES utf8mb4;
|
||||
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port, VERSION() AS mysql_version;
|
||||
|
||||
-- 一、废弃业务表,存在则保留。
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认完成时间',
|
||||
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
|
||||
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合',
|
||||
total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用',
|
||||
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
|
||||
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
|
||||
reviewed_at DATETIME(3) NULL 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_cargo_discard_no(customer_id,discard_no),
|
||||
UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key),
|
||||
KEY idx_ll_cargo_discard_list(customer_id,status,created_at),
|
||||
KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='货物废弃申请单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
|
||||
return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID',
|
||||
sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
|
||||
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
|
||||
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
|
||||
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
|
||||
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
|
||||
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
|
||||
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
|
||||
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
|
||||
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
|
||||
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
|
||||
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
|
||||
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
|
||||
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
|
||||
KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id)
|
||||
) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='货物废弃审批历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
|
||||
id BIGINT NOT NULL,
|
||||
customer_id VARCHAR(64) NOT NULL,
|
||||
application_id BIGINT NOT NULL,
|
||||
pallet_no VARCHAR(128) NOT NULL,
|
||||
file_id BIGINT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(128) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
operator_id VARCHAR(64) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
submitted_at DATETIME(6) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
|
||||
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 二、补齐已有表的PDA字段及完工照片快照。
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'),
|
||||
'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认完成时间''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='completion_photo_file_ids'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 三、退货箱来源和废弃状态约束(V4)。
|
||||
ALTER TABLE ll_cargo_discard_item
|
||||
MODIFY COLUMN order_id BIGINT NULL COMMENT '一件代发订单ID',
|
||||
MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID',
|
||||
MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID';
|
||||
|
||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'),
|
||||
'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))',
|
||||
'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'),
|
||||
'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status, ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))',
|
||||
'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
-- 四、通用库存、部分选箱、箱来源(V25)。
|
||||
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
|
||||
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
|
||||
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
-- 五、退货箱SKU归零约束(V26)。
|
||||
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
|
||||
-- 原子替换约束,可重复执行;不修改业务记录。
|
||||
SET @ddl = IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
|
||||
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
|
||||
),
|
||||
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
|
||||
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 六、只读核对。
|
||||
SHOW CREATE TABLE `ll_cargo_discard_application`;
|
||||
SHOW CREATE TABLE `ll_cargo_discard_item`;
|
||||
SHOW CREATE TABLE `ll_cargo_discard_carton`;
|
||||
SHOW CREATE TABLE `ll_cargo_discard_status_history`;
|
||||
SHOW CREATE TABLE `ll_cargo_discard_photo`;
|
||||
SHOW CREATE TABLE `ll_return_inventory_carton`;
|
||||
SHOW CREATE TABLE `ll_inbound_plan_carton`;
|
||||
SHOW CREATE TABLE ll_return_inventory_carton_item;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 客户照片确认功能增量,MySQL 8.0.16+,可重复执行。
|
||||
-- 完整执行,遇错停止;须连接同时承载这两个业务库的指定实例。
|
||||
-- 仅新增确认时间和权限,不确认/回填任何历史业务单据。
|
||||
USE `baiyue_lastleg`;
|
||||
-- 客户确认废弃完结时间,可重复执行;仅结构变更。
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
USE `baiyue_iam`;
|
||||
-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。
|
||||
-- 自定义客户角色由管理员按需分配;不自动扩大其权限。
|
||||
-- 可重复执行;授权和持久化安全版本在同一事务提交。
|
||||
START TRANSACTION;
|
||||
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||
VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
|
||||
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
|
||||
ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm'
|
||||
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0;
|
||||
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
|
||||
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
|
||||
JOIN iam_membership m ON m.user_id=u.id
|
||||
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
|
||||
JOIN iam_role r ON r.id=mr.role_id
|
||||
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
|
||||
AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT'
|
||||
AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0
|
||||
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 废弃明细新增字段专项补齐,MySQL 8.0.16+,可重复执行。
|
||||
-- 仅 baiyue_lastleg;前提是 ll_cargo_discard_item 已存在。
|
||||
-- 独立于退货库存约束,无需先执行其他汇总章节。
|
||||
-- 不修改业务记录或库存数量;完整执行,遇错停止,DDL隐式提交。
|
||||
USE `baiyue_lastleg`;
|
||||
SET NAMES utf8mb4;
|
||||
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='completion_photo_file_ids'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
-- 核对新增字段;应返回10行。
|
||||
SELECT column_name, column_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item'
|
||||
AND column_name IN ('actual_discard_packages','pda_status','pda_operator_id','pda_completed_at','completion_photo_file_ids','source_type','return_carton_id','inventory_position_id','carton_marks_json','carton_scan_required')
|
||||
ORDER BY ordinal_position;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- MySQL 8.0.16+;对Lastleg连接的实例完整执行,遇错停止。DDL隐式提交。
|
||||
USE `baiyue_lastleg`;
|
||||
SET NAMES utf8mb4;
|
||||
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port;
|
||||
|
||||
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
|
||||
-- 原子替换约束,可重复执行;不修改业务记录。
|
||||
SET @ddl = IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
|
||||
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
|
||||
),
|
||||
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
|
||||
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SHOW CREATE TABLE ll_return_inventory_carton_item;
|
||||
@@ -1388,9 +1388,13 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
|
||||
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
|
||||
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
|
||||
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
|
||||
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
|
||||
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
|
||||
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
|
||||
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
|
||||
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
|
||||
@@ -1398,6 +1402,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
|
||||
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
|
||||
@@ -4134,7 +4139,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)
|
||||
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
|
||||
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||
@@ -5079,3 +5084,28 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
|
||||
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
|
||||
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
|
||||
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
|
||||
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
|
||||
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
|
||||
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { randomInt, randomUUID } from 'node:crypto'
|
||||
|
||||
const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '')
|
||||
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('Explicit localhost Gateway required')
|
||||
if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD) throw new Error('Explicit local platform credentials required')
|
||||
const marker = `WT-BOX-${new Date().toISOString().replace(/\D/g, '').slice(0,14)}-${randomInt(1000,9999)}`
|
||||
const output = resolve(process.env.TRANSFER_CARTON_OUTPUT || `reports/${marker}.json`)
|
||||
const report = { marker, gateway, environment: 'LOCAL', photoEvidence: 'SYNTHETIC_TEST_PNG', passed: false, orders: [], checks: [] }
|
||||
let admin, pda, userId
|
||||
const ok = value => value.http === 200 && String(value.body?.code) === '200'
|
||||
async function call(token, method, path, body, expectedFailure = false) {
|
||||
const isForm = body instanceof FormData
|
||||
const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(!isForm && body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : isForm ? body : JSON.stringify(body) })
|
||||
let value; try { value = await response.json() } catch { value = null }
|
||||
const result = { http: response.status, body: value, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId }
|
||||
// Never persist auth bodies, credentials, customer profiles or tokens.
|
||||
report.checks.push({ method, path, expectedFailure, http: result.http, code: value?.code, requestId: result.requestId, traceId: result.traceId, passed: expectedFailure ? !ok(result) && ['A0400','A0422','A0409','A0404','A0403','400','409','404','403'].includes(String(value?.code)) : ok(result), message: ok(result) ? undefined : value?.message })
|
||||
if (expectedFailure ? report.checks.at(-1).passed !== true : !ok(result)) throw new Error(`${method} ${path}: ${response.status}/${value?.code} ${value?.message}; requestId=${result.requestId}; traceId=${result.traceId}`)
|
||||
return expectedFailure ? result : value.data
|
||||
}
|
||||
function assert(value, name) { report.checks.push({ name, passed: Boolean(value) }); if (!value) throw new Error(name) }
|
||||
async function save() { await mkdir(resolve(output, '..'), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2)) }
|
||||
try {
|
||||
admin = (await call('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })).accessToken
|
||||
const tenants = await call(admin,'GET','/api/platform/v1/iam/tenants?page=1&size=200')
|
||||
const customer = tenants.records.find(row => row.status === 'ACTIVE' && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || row.id === process.env.OVERSEAS_TEST_CUSTOMER_ID))
|
||||
assert(customer?.id, 'Existing active local customer')
|
||||
const customerId = customer.id
|
||||
const warehouses = (await call(admin,'GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records
|
||||
const warehouse = warehouses.find(row => row.warehouseCode === 'US-LAX-01') || warehouses[0]
|
||||
assert(warehouse?.id, 'Existing overseas warehouse')
|
||||
report.customerId = customerId; report.warehouseId = warehouse.id
|
||||
const roles = await call(admin,'GET','/api/platform/v1/iam/roles')
|
||||
const role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE')
|
||||
assert(role?.id, 'Existing PDA role')
|
||||
const username = `pda_box_${marker.replace(/\D/g,'').slice(-18)}`
|
||||
const password = process.env.LOCAL_TRANSFER_PDA_PASSWORD
|
||||
if (!password) throw new Error('Explicit fixture PDA password required')
|
||||
const user = await call(admin,'POST','/api/platform/v1/iam/users', { username, password, displayName: marker, phone: `18${randomInt(100000000,999999999)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status:'ACTIVE' })
|
||||
userId = user.membershipId
|
||||
pda = (await call('', 'POST', '/api/auth/login', { loginId: username, password })).accessToken
|
||||
assert(pda, 'Dedicated warehouse-bound PDA session')
|
||||
await writeFile(resolve('.env.transfer-carton-session'), `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\n`, { mode: 0o600 })
|
||||
for (const scenario of [
|
||||
{ name:'single-box', counts:[1], actual:1 },
|
||||
{ name:'multi-box-multi-sku', counts:[2,1], actual:3, pallets:[1,2] },
|
||||
{ name:'short-receipt', counts:[3], actual:2, reason:'验收短收一箱' },
|
||||
{ name:'over-receipt', counts:[2], actual:3, reason:'验收超收一箱' }
|
||||
]) {
|
||||
const key = `${marker}-${scenario.name}`
|
||||
const items = scenario.counts.map((packageCount,index) => ({ skuCode:`${key}-SKU${index+1}`,productNameZh:`验收货物${index+1}`,quantity:120+index*30,packageCount,shippingMark:`${key}-CUSTOMER${index+1}` }))
|
||||
let detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, { operatingWarehouseId:warehouse.id,destinationType:'PRIVATE',privateDestinationAddress:'100 Test Warehouse Road',privateDestinationPostalCode:'90001',privateDestinationContactName:'测试收件人',privateDestinationContactPhone:'0000000000',deliveryAddress:'100 Test Warehouse Road',pickupContactName:'测试操作员',pickupContactPhone:'0000000000',packageCount:scenario.counts.reduce((a,b)=>a+b,0),volumeCbm:0.1,deliveryMethod:'TRUCK',estimatedArrivalAt:new Date().toISOString().slice(0,19),estimatedShipAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:key,needRelabel:false,idempotencyKey:key,items })
|
||||
assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity && item.packageCount === items[index].packageCount), 'SKU declared units and expected cartons persist independently')
|
||||
const no = detail.transferNo
|
||||
const fact = { scenario: scenario.name, transferNo:no, plannedBoxes:detail.packageCount, actualBoxes:scenario.actual, status:detail.status, palletNos:[] }; report.orders.push(fact); await save()
|
||||
detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers/${no}/review?customerId=${customerId}`,{decision:'APPROVE',remark:key})
|
||||
assert(detail.status === 'WAIT_PDA_RECEIVING', 'Review enters PDA receiving')
|
||||
const path = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(no)}`
|
||||
await call(pda,'POST',`${path}/palletize`,{idempotencyKey:`${key}-premature`,photoFileIds:['1']},true)
|
||||
detail = await call(pda,'GET',path)
|
||||
const palletAssignments = [];
|
||||
for (const [index, cartonCount] of (scenario.pallets || [scenario.actual]).entries()) {
|
||||
detail = await call(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:`${key}-pallet${index}`})
|
||||
let pallet = detail.pallets.find(row=>row.status==='DRAFT')
|
||||
assert(pallet?.id,'Saved pallet draft')
|
||||
detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:`${key}-generate${index}`})
|
||||
pallet=detail.pallets.find(row=>row.id===pallet.id)
|
||||
detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:`${key}-bind${index}`,palletCode:pallet.palletNo})
|
||||
fact.palletNos.push(pallet.palletNo); palletAssignments.push({ id: pallet.id, count: cartonCount })
|
||||
}
|
||||
let allocationIndex = 0;
|
||||
const palletIds = palletAssignments.flatMap(pallet => Array(pallet.count).fill(pallet.id));
|
||||
let boxes = detail.cartons.slice(0,scenario.actual).map((box,index)=>({ cartonMark:box.cartonMark,transferItemId:box.transferItemId,transferPalletId:palletIds[allocationIndex++],length:30+index,width:20+index,height:15+index,weight:2+index,skuCode:detail.items.find(row=>row.id===box.transferItemId).skuCode,quantity:5+index }))
|
||||
if(scenario.actual>boxes.length) boxes.push({cartonMark:null,transferItemId:detail.items[0].id,transferPalletId:palletIds[allocationIndex++],length:44,width:33,height:22,weight:4.5,skuCode:detail.items[0].skuCode,quantity:13})
|
||||
const count = {idempotencyKey:`${key}-count`,boxes,discrepancyReason:scenario.reason || null}
|
||||
await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-bad-sku`,boxes:boxes.map((row,index)=>index?row:{...row,skuCode:'WRONG-SKU'})},true)
|
||||
if (scenario.reason) await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-missing-reason`,discrepancyReason:null},true)
|
||||
await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-zero`,boxes:boxes.map((row,index)=>index?row:{...row,quantity:0})},true)
|
||||
detail = await call(pda,'POST',`${path}/cartons/count`,count)
|
||||
assert(detail.packageCount === scenario.counts.reduce((a,b)=>a+b,0), 'Actual receipt preserves declared expected carton count')
|
||||
assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity), 'PDA actual units do not overwrite SKU declared units')
|
||||
const counted = detail.cartons.filter(row=>row.status==='COUNTED')
|
||||
for (const pallet of detail.pallets) {
|
||||
const assigned = counted.filter(box => String(box.transferPalletId) === String(pallet.id));
|
||||
assert(assigned.length === pallet.cartonCount, 'Pallet count derived from assigned boxes');
|
||||
assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.length)*Number(box.width)*Number(box.height)/1000000,0)-Number(pallet.volumeCbm)) < 0.000001, 'Pallet volume derived from boxes');
|
||||
assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.weight),0)-Number(pallet.grossWeightKg)) < 0.001, 'Pallet weight derived from boxes');
|
||||
}
|
||||
assert(counted.length===scenario.actual && counted.every(row=>row.countedAt && Number(row.quantity)>0 && Number(row.length)>0), `${scenario.name} persisted box measurements and quantities`)
|
||||
fact.boxes = counted.map(row=>({cartonMark:row.cartonMark,length:row.length,width:row.width,height:row.height,weight:row.weight,quantity:row.quantity}))
|
||||
assert(detail.cartons.filter(row=>row.status==='NOT_RECEIVED').length===Math.max(0,detail.packageCount-scenario.actual),'Missing box facts retained')
|
||||
const replay = await call(pda,'POST',`${path}/cartons/count`,count)
|
||||
assert(JSON.stringify(replay.cartons)===JSON.stringify(detail.cartons),'Count replay preserves original box facts')
|
||||
await call(pda,'POST',`${path}/cartons/count`,{...count,boxes:boxes.map((row,index)=>index?row:{...row,quantity:row.quantity+1})},true)
|
||||
const label = await call(pda,'GET',`${path}/carton-label`)
|
||||
assert(counted.every(row=>label.printableHtml.includes(row.cartonMark)), 'Printable labels include every received carton')
|
||||
const form = new FormData();form.append('file',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')],{type:'image/png'}),`${key}.png`)
|
||||
const photo=await call(pda,'POST',`${path}/measurement-photos/files`,form)
|
||||
const complete={idempotencyKey:`${key}-complete`,photoFileIds:[photo.fileId]}
|
||||
detail=await call(pda,'POST',`${path}/palletize`,complete)
|
||||
assert(detail.status==='WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus==='INBOUND_COMPLETED','Completed receiving waits for customer confirmation')
|
||||
assert(detail.pallets.reduce((sum,row)=>sum+Number(row.cartonCount),0)===scenario.actual,'Pallet box total equals counted receipts')
|
||||
await call(pda,'POST',`${path}/palletize`,complete)
|
||||
const readback=await call(admin,'GET',`/api/platform/v1/last-leg/warehouse-transfers/${no}?customerId=${customerId}`)
|
||||
assert(JSON.stringify(readback.cartons)===JSON.stringify(detail.cartons),'Platform readback matches persisted PDA box facts')
|
||||
assert(readback.pallets.every(row=>row.status==='COMPLETED'),'Completed pallets persisted')
|
||||
fact.status=readback.status;fact.orderStatus=readback.orderStatus;fact.passed=true
|
||||
await save()
|
||||
}
|
||||
report.passed = report.orders.length===4 && report.orders.every(row=>row.passed) && report.checks.every(row=>row.passed)
|
||||
} catch(error) { report.error=error.message; process.exitCode=1 }
|
||||
finally {
|
||||
if(userId && process.env.TRANSFER_KEEP_PDA !== 'true') { try {await call(admin,'PUT',`/api/platform/v1/iam/users/${userId}/status`,{status:'DISABLED'});report.fixturePdaDisabled=true} catch(error) {report.cleanupError=error.message;report.passed=false;process.exitCode=1} }
|
||||
if(userId) report.fixturePdaMembershipId=userId
|
||||
await save();console.log(JSON.stringify({passed:report.passed,output,orders:report.orders.map(row=>({scenario:row.scenario,transferNo:row.transferNo,status:row.status,passed:row.passed})),checks:report.checks.length,error:report.error}))
|
||||
}
|
||||
Reference in New Issue
Block a user