diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 8187f6db7..ea1f0b0c9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -20,6 +20,8 @@ import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -191,10 +193,14 @@ class ContainerServiceTest { verify(repository, never()).insert(any()); } - @Test - @DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份") - void linksReleasedBookingAfterPreloading() { - ContainerBookingEntity container = booking("EDITING", 2); + @ParameterizedTest + @ValueSource(strings = {"WAIT_LOADING", "EDITING"}) + @DisplayName("空计划及加入入仓单后的编辑计划均允许首次关联且保持清单身份") + void linksReleasedBookingAfterPreloading(String planStatus) { + ContainerBookingEntity container = booking(planStatus, 2); + container.setSourceType("SIMULATION"); + container.setPlanId(501L); + container.setPlanNo("LP-260818-0001"); container.setContainerNo(null); container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); @@ -214,6 +220,10 @@ class ContainerServiceTest { assertThat(result).isSameAs(container); assertThat(container.getCabinetNo()).isEqualTo("柜-001"); + assertThat(container.getPlanId()).isEqualTo(501L); + assertThat(container.getPlanNo()).isEqualTo("LP-260818-0001"); + assertThat(container.getPlanStatus()).isEqualTo(planStatus); + assertThat(container.getSourceType()).isEqualTo("SIMULATION"); verify(repository, never()).hasActiveLoadingItems(11L); verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"), same(booking), eq(2), eq(9L), eq("admin")); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index d7d9ade8f..c00a5496c 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -15,6 +15,7 @@ | Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 | | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | +| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,分别支持预检、真实字段草稿和显式模拟测量到待分柜;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 | | Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 | | 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 | | Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | diff --git a/scripts/README.md b/scripts/README.md index efa34b816..36fa19d82 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -69,6 +69,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | | 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | +| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建缺失字段留空的草稿,`wait-split` 显式允许测试占位后走正式审核测量。只复用两个原有托盘,不新增主档或库位。参数、限制及重跑说明见 `real-order-xlsx-import.md` | | 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 | | 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 | | 测试环境 HTTP | `phase2-test-env-acceptance.ps1` | 入库测量与计费测试环境 API 验收,会写业务数据 | diff --git a/scripts/real-order-xlsx-import.md b/scripts/real-order-xlsx-import.md new file mode 100644 index 000000000..e0c256035 --- /dev/null +++ b/scripts/real-order-xlsx-import.md @@ -0,0 +1,60 @@ +# 源订单 Excel 测试导入 + +入口 `real-order-xlsx-test-env.mjs`,离线合同 `real-order-xlsx-test-env.test.mjs`。仅限当前授权的测试环境,不清库、不部署、不修改环境配置、不写数据库。 + +## 配置与命令 + +凭据仍只维护在用户目录的 `chongchong-global-test-acceptance.env`:`ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。不复制到源码或报告。Node 支持 `--env-file`,Python 需已有 `openpyxl`,可用桌面提供的工作区依赖路径。脚本不安装或链接依赖。 + +从后端根执行,按本机实际位置设置参数: + +```powershell +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/real-order-xlsx-test-env.mjs --input '<源XLSX绝对路径>' --python '' --output-dir '<仓库外的本批次专用目录>' --batch-id trial-001 --expected-host '<测试网关主机名>' --stage preflight +``` + +- `--sheet` 默认 `D1512开始`。 +- `--customer-name` 默认 `IT测试`,必须和登录企业一致;`--origin-warehouse-name` 默认 `义乌仓`,必须唯一存在。 +- `--expected-host` 必须与配置完全相同且以 `test-` 或 `test.` 开头,禁止生产环境。 +- `--stage preflight` 登录并查询目录、托盘和编号策略,不创建业务数据。 +- `--stage create --limit 1` 按源实际箱数、重量、体积尝试创建一票草稿,不伪造图片、尺寸或报关字段,不提交订单。按用户确认规则,源表未注明木制品时 `woodenProduct=false`;其他缺失必填项保留服务端错误,不保证所有源行能创建。 +- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只有明确接受下述模拟规则时才能使用。 +- `--limit` 是本次新增尝试数,默认 1,串行执行。前三票连续失败且没有成功时停止扩大写入。 + +## 源数据与模拟规则 + +同入仓码多行按实际箱数/毛重/体积求和,分柜明细还须与唯一预报箱数相符;同码客户、渠道、目的仓或报关方式冲突则阻断。缺失或零实际值不以预报替换。无有效入仓码的行排除,源文件保持原样。 + +报关方式“买单改退税”取最终退税,“退税改买单”取最终买单;空白或未识别文本阻断,不默认买单。 + +试价计费方式取“计费数据”后的第一个“单位”列:KGS/KG 映射 KG,CBM 保留。包税按服务端契约固定 KG;不包税单位缺失或分柜行冲突则阻断。 + +本脚本是单个已认证客户的造数入口,不能替不同客户下单。源客户及源入仓码保留在订单备注;新入仓码由系统 AUTO 生成,不改变编号策略。原船期、装柜、船节点、价格和历史费用不作为当前流程已完成事实导入,当前计价由测试库有效报价决定。 + +渠道仅去掉包税字样和快递括号后匹配同名渠道,保留原渠道文本到备注。税制必须存在。目的仓按同类型的精确编码/名称匹配,不模糊替代。私人地址缺少结构化映射、海外仓名称不能精确匹配、ZIM 等目录缺失渠道均记录为未匹配;不新增仓库或地址。多 SKU 明细无法从自由文本可靠还原,当前按一条汇总货物导入,不宣称保留逐 SKU 分布。 + +源表没有箱规和图片。按用户确认,总箱数、总货重、总体积为权威目标,长宽高允许推算。读取当前客户四档箱规,按 ADD 加边规则反算基数,使用正式人工外箱尺寸字段提交,不修改客户配置。考虑服务端逐箱四位小数向上取整,将总体积按 0.0001 m³ 整数单位分配为最多两组,每组以一个箱码代表相应箱数。草稿保存后和最终待分柜均核对总箱数、总体积,体积不一致直接失败;源体积超过服务端精度或当前规则无可行尺寸时写订单前阻断,不放宽误差掩盖差异。 + +每 30 箱一张称重图,末托为余数。源毛重解释为含包装货重,按箱数分摊后加服务端托盘皮重作为秤重,使服务端净货重与源毛重一致。不得将秤重总值和源货重混为一谈。推算箱规不是原始实测尺寸。 + +源体积可用三位小数表达时,优先以 0.001 m³ 分组,使各明细体积重和汇总体积重避免逐行向上取整差异;否则按四位精度分组。提交前同时检查明细体积重合计与批次一致,不一致记录服务端取整阻断,不提交审核或伪造总量。 + +照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。 + +托盘只允许 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。 + +## 报告与重跑 + +必须保留批次目录:`preflight.json` 为全部解析/主数据匹配结果;`state.json` 为源文件哈希、批次身份、源行号到新订单/入仓码的对应、每次写入前后的检查点、错误码和 requestId/traceId。该目录包含业务数据,仅在本机仓库外保存,不提交或公开分享。原始 Excel 自身是完整源数据依据。 + +同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`limit` 每次从尚未尝试的票继续;不是完整失败恢复器。特别注意:`create` 成功的草稿不会因切换 `wait-split` 自动补齐和推进;需单独选择尚未尝试票做完整链试跑。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。 + +源文件、客户、网关或批次 ID 改变时拒绝复用目录。并发进程通过 `run.lock` 拒绝;异常终止残留锁,人工确认旧进程已结束后才可移除精确锁文件。 + +用户清库并重新初始化客户、报价、仓库和原有托盘后,指定**新的 batch-id 和全新输出目录**重新运行同一脚本。不能复用旧状态,否则已尝试票会跳过。清库不由本脚本执行。 + +## 本地检查 + +```powershell +node --check scripts/real-order-xlsx-test-env.mjs +node --test scripts/real-order-xlsx-test-env.test.mjs scripts/script-documentation-contract.test.mjs +``` diff --git a/scripts/real-order-xlsx-test-env.mjs b/scripts/real-order-xlsx-test-env.mjs new file mode 100644 index 000000000..9605eec49 --- /dev/null +++ b/scripts/real-order-xlsx-test-env.mjs @@ -0,0 +1,479 @@ +#!/usr/bin/env node +// Explicitly authorized non-production HTTP import. Never resets a database. +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { mkdir, readFile, writeFile, rename, open } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' + +const hash = (value) => createHash('sha256').update(value).digest('hex') +const text = (value) => String(value ?? '').trim() +const round = (value, digits = 6) => Number(value.toFixed(digits)) +const positive = (value) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) > 0 +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') +const PALLETS = ['TP-001', 'YW-TP-002'] +const declarationMode = (value) => ({ 买单: 'BUY_DECLARATION', 退税: 'TAX_REFUND', + 买单改退税: 'TAX_REFUND', 退税改买单: 'BUY_DECLARATION' })[text(value)] || null + +export function channelName(value) { + return text(value).replace(/\s/g, '').replace(/[((](?:FEDEX|UPS)[))]/gi, '').replace(/不包税|包税/g, '') +} + +export function normalizeRows(rows) { + const header = rows[0].map((value) => text(value).replace(/\s/g, '')) + const required = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数'] + for (const name of required) if (!header.includes(name)) throw Error(`缺少源表列:${name}`) + const get = (row, name) => row[header.indexOf(name)] + const groups = new Map(), excluded = [] + rows.slice(1).forEach((row, offset) => { + if (row.every((value) => value === null || value === '')) return + const code = text(get(row, '进仓编号')).match(/^[A-Z]{2,8}\d{8,16}(?=\s|$)/)?.[0] + if (!code) { excluded.push({ rows: [offset + 2], reason: 'INVALID_SOURCE_CODE' }); return } + const group = groups.get(code) || [] + group.push({ row, rowNumber: offset + 2 }); groups.set(code, group) + }) + const orders = [...groups].map(([sourceCode, entries]) => { + const first = entries[0].row + const distinct = (name) => [...new Set(entries.map(({ row }) => text(get(row, name))))] + const problems = [] + for (const name of ['客户', '渠道', '亚马逊仓']) { + if (distinct(name).length !== 1) problems.push(`CONFLICT_${name}`) + } + if (new Set(entries.map(({ row }) => declarationMode(get(row, '买单/退税')))).size !== 1) problems.push('CONFLICT_买单/退税') + // Missing/zero actuals do not silently become forecast facts. + for (const name of ['实际件数', '实际毛重', '实际立方']) { + if (entries.some(({ row }) => !positive(get(row, name)))) problems.push(`MISSING_${name}`) + } + const sum = (name) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0)) + const packageCount = sum('实际件数') + if (!Number.isInteger(packageCount) || packageCount < 1) problems.push('INVALID_PACKAGE_COUNT') + if (entries.length > 1) { + const forecasts = distinct('预报件数').map((value) => Number(value.match(/^\d+/)?.[0])) + if (forecasts.length !== 1 || forecasts[0] !== packageCount) problems.push('SPLIT_COUNT_MISMATCH') + } + const sourceChannel = text(get(first, '渠道')) + const destination = text(get(first, '亚马逊仓')) + const taxMode = declarationMode(get(first, '买单/退税')) + if (!taxMode) problems.push('UNKNOWN_DECLARATION_MODE') + const pricingTaxMode = sourceChannel.includes('不包税') ? 'TAX_EXCLUDED' + : sourceChannel.includes('包税') ? 'TAX_INCLUDED' : null + if (!pricingTaxMode) problems.push('UNKNOWN_PRICING_TAX_MODE') + const chargeModes = entries.map(({ row }) => ({ KGS: 'KG', KG: 'KG', CBM: 'CBM' })[text(get(row, '单位')).toUpperCase()]) + const chargeMode = pricingTaxMode === 'TAX_INCLUDED' ? 'KG' : chargeModes[0] + if (pricingTaxMode === 'TAX_EXCLUDED' && (!chargeMode || chargeModes.some((mode) => mode !== chargeMode))) problems.push('UNKNOWN_OR_CONFLICTING_CHARGE_MODE') + const destinationType = destination.includes('私人') ? 'PRIVATE' + : /^[A-Z]{3}\d$/.test(destination) ? 'FBA' : 'OVERSEAS' + return { key: hash(sourceCode).slice(0, 24), sourceCode, rows: entries.map((entry) => entry.rowNumber), + sourceCustomer: text(get(first, '客户')), sourceChannel, channelName: channelName(sourceChannel), + destination, destinationType, pricingTaxMode, taxMode, chargeMode, + fulfillmentType: destination.includes('一件代发') ? 'ONE_CLICK_DROPSHIP' : 'STANDARD', + packageCount, weightKg: sum('实际毛重'), volumeCbm: sum('实际立方'), + cargoName: distinct('品名').filter(Boolean).join(' / '), + sku: distinct('SKU').filter(Boolean).join('\n'), shipmentId: distinct('FBA货件编号').filter(Boolean).join('\n'), + referenceId: distinct('跟踪编号').filter(Boolean).join('\n'), problems } + }) + return { orders, excluded } +} + +export function matchRoute(order, catalog) { + if (order.problems.length) throw Error(order.problems.join('|')) + if (order.destinationType === 'PRIVATE') throw Error('PRIVATE_ADDRESS_REQUIRES_EXPLICIT_MAPPING') + // Do not create work pallets to bypass the user's no-new-pallet constraint. + if (order.fulfillmentType === 'ONE_CLICK_DROPSHIP') throw Error('DROPSHIP_REQUIRES_WORK_PALLET_WORKFLOW') + const channels = catalog.channels.filter((item) => item.channelName === order.channelName + && item.pricingTaxModes?.includes(order.pricingTaxMode)) + if (channels.length !== 1) throw Error('CHANNEL_OR_TAX_MODE_NOT_UNIQUE') + const destinations = catalog.destinations.filter((item) => item.warehouseType === order.destinationType + && (item.warehouseCode === order.destination || item.warehouseName === order.destination)) + if (destinations.length !== 1) throw Error('DESTINATION_NOT_UNIQUE') + return { channel: channels[0], destination: destinations[0], receiving: catalog.receiving } +} + +export function measurementShape(order) { + if (!positive(order.volumeCbm) || !positive(order.weightKg) || !Number.isInteger(order.packageCount) + || order.packageCount < 1) throw Error('INVALID_MEASUREMENT_FACTS') + const edge = round(Math.cbrt(order.volumeCbm * 1e6 / order.packageCount), 3) + return { length: edge, width: edge, height: edge, palletCount: Math.ceil(order.packageCount / 30) } +} + +export function totalPreservingShapes(order, rules) { + measurementShape(order) + if (!Array.isArray(rules) || rules.length !== 4 || rules.some((r) => + ![r.innerLengthCm, r.innerWidthCm, r.innerHeightCm, r.edgeAdditionCm].every(positive))) throw Error('INVALID_DIMENSION_RULES') + const sorted = [...rules].sort((a, b) => a.innerLengthCm * a.innerWidthCm * a.innerHeightCm + - b.innerLengthCm * b.innerWidthCm * b.innerHeightCm) + const choose = (edge) => sorted.find((r) => Math.min(r.innerLengthCm, r.innerWidthCm, r.innerHeightCm) >= edge) || sorted.at(-1) + // Manual outer dimensions use server CEILING(4) per carton. Distribute the + // integer 0.0001 m3 remainder across two groups, not independently rounded boxes. + const scale = Math.abs(Math.round(order.volumeCbm * 1000) / 1000 - order.volumeCbm) < 0.0000001 ? 1000 : 10000 + const ticks = Math.round(order.volumeCbm * scale) + if (Math.abs(ticks / scale - order.volumeCbm) > 0.0000001) throw Error('SOURCE_VOLUME_EXCEEDS_SERVER_PRECISION') + const lower = Math.floor(ticks / order.packageCount), extra = ticks % order.packageCount + const groups = [{ count: order.packageCount - extra, ticks: lower }, { count: extra, ticks: lower + 1 }].filter((g) => g.count) + return groups.map((group) => { + if (group.ticks < 1) throw Error('VOLUME_TOO_SMALL_FOR_CARTON_COUNT') + const unitVolumeCbm = group.ticks / scale + for (const rule of sorted) { + const addition = 2 * Number(rule.edgeAdditionCm) + const edge = round(Math.cbrt((unitVolumeCbm - 0.00005) * 1e6) - addition, 3) + const raw = round((edge + addition) ** 3 / 1e6, 6) + const serverTicks = Math.ceil(raw * 10000 - 1e-8) + if (edge > 0 && choose(edge) === rule && serverTicks === Math.round(unitVolumeCbm * 10000)) { + return { length: edge, width: edge, height: edge, count: group.count, + unitVolumeCbm, edgeAdditionCm: Number(rule.edgeAdditionCm), boxType: rule.boxType } + } + } + throw Error('NO_TOTAL_PRESERVING_DIMENSIONS_FOR_CURRENT_RULES') + }) +} + +function oneValue(value, fallback, max) { + // Multiline/oversized identifiers are not silently truncated into a different real ID. + return value && !/[\r\n]/.test(value) && value.length <= max ? value : fallback +} + +export function buildPayload(order, route, customer, imageId, marker) { + const shape = measurementShape(order) + const shipmentId = oneValue(order.shipmentId, `TEST-SHIP-${marker}`, 64) + const referenceId = oneValue(order.referenceId, `TEST-REF-${marker}`, 64) + const fba = order.destinationType === 'FBA' + return { + customerName: customer, transportMode: 'SEA', tradeTerm: 'FOB', fulfillmentType: 'STANDARD', + pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: route.receiving.id, + receivingWarehouseName: route.receiving.warehouseName, + channelCode: route.channel.channelCode, channelName: route.channel.channelName, + pricingTaxMode: order.pricingTaxMode, taxMode: order.taxMode, containerMode: 'LCL', + customsInspection: false, destinationType: order.destinationType, + destinationWarehouseId: route.destination.id, destinationWarehouseName: route.destination.warehouseName, + destinationCountryCode: route.destination.countryCode, amazonWarehouseCode: fba ? route.destination.warehouseCode : null, + shipmentId: fba ? shipmentId : null, referenceId: fba ? referenceId : null, + customerRemark: `${marker} 测试导入,源入仓码=${order.sourceCode};源客户=${order.sourceCustomer};源渠道=${order.sourceChannel};箱数/重量/体积取源实际值;箱规为等体积推算,照片及缺失产品字段为测试占位;非真实测量证据`.slice(0, 1000), + cargoes: [{ cargoName: oneValue(order.cargoName, '测试导入汇总货物(详见源表)', 128), + cargoNameEn: 'TEST imported goods', skuNo: oneValue(order.sku, `TEST-SKU-${marker}`, 64), + packageType: 'CARTON', packageCount: order.packageCount, grossWeightKg: order.weightKg, + volumeCbm: order.volumeCbm, lengthCm: shape.length, widthCm: shape.width, heightCm: shape.height, + unitsPerPackage: 1, totalQuantity: order.packageCount, netWeightKg: order.weightKg, + hsCode: '0000000000', customsHsCode: '0000000000', unitDeclaredValue: 1, + declaredValue: order.packageCount, currency: 'USD', material: 'TEST_UNKNOWN', usage: '测试流程', + model: 'TEST', brand: 'TEST', productLengthCm: 1, productWidthCm: 1, productHeightCm: 1, + shipmentId: fba ? shipmentId : null, referenceId: fba ? referenceId : null, + upcNo: '000000000000', productImageFileId: imageId, dangerousGoods: false, woodenProduct: false, + cargoRemark: `${marker} 聚合源表行${order.rows.join(',')};商品数量/申报价/材质/HS/属性为测试占位,禁止用于真实报关`, + }], + } +} + +export function buildSourceDraft(order, route, customer, marker) { + // Drafts deliberately retain missing fields. No inferred dimensions, fake photo, + // customs declaration values or dangerous-goods assertions are submitted here. + // User-confirmed import rule: absent wooden-product information means false. + return { + customerName: customer, transportMode: 'SEA', fulfillmentType: order.fulfillmentType, + pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: route.receiving.id, + receivingWarehouseName: route.receiving.warehouseName, + channelCode: route.channel.channelCode, channelName: route.channel.channelName, + pricingTaxMode: order.pricingTaxMode, taxMode: order.taxMode, destinationType: order.destinationType, + destinationWarehouseId: route.destination.id, destinationWarehouseName: route.destination.warehouseName, + destinationCountryCode: route.destination.countryCode, + amazonWarehouseCode: order.destinationType === 'FBA' ? route.destination.warehouseCode : null, + customerRemark: `${marker} 源数据草稿;源入仓码=${order.sourceCode};源客户=${order.sourceCustomer};箱数/重量/体积取源实际值。缺失产品字段和实测证据待补充。`.slice(0, 1000), + cargoes: [{ cargoName: oneValue(order.cargoName, '', 128), skuNo: oneValue(order.sku, null, 64), + packageType: 'CARTON', woodenProduct: false, packageCount: order.packageCount, grossWeightKg: order.weightKg, + volumeCbm: order.volumeCbm, shipmentId: oneValue(order.shipmentId, null, 64), + referenceId: oneValue(order.referenceId, null, 64), + cargoRemark: `${marker} 源表行${order.rows.join(',')};多货物原文请核对源表` }], + } +} + +export function requireSuccess(response) { + if (response?.http !== 200 || String(response?.body?.code) !== '200') { + const error = Error(`HTTP_${response?.http}_CODE_${response?.body?.code || 'MISSING'}`) + error.evidence = { method: response?.method, path: response?.path, http: response?.http, + code: response?.body?.code, message: response?.body?.message, + requestId: response?.requestId, traceId: response?.traceId } + throw error + } + return response.body.data +} + +async function main() { + const args = new Map() + const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host']) + for (let i = 2; i < process.argv.length; i += 2) { + const name = process.argv[i].replace(/^--/, ''), value = process.argv[i + 1] + if (!allowed.has(name) || !value || value.startsWith('--')) throw Error(`无效参数:${name}`) + args.set(name, value) + } + for (const key of ['input', 'python', 'output-dir', 'batch-id', 'expected-host']) if (!args.get(key)) throw Error(`缺少 --${key}`) + const stage = args.get('stage') || 'preflight' + if (!['preflight', 'create', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/wait-split') + if (stage === 'wait-split' && args.get('allow-test-placeholders') !== 'true') throw Error('此源表缺少必填字段,模拟测量须显式允许测试占位') + const limit = Number(args.get('limit') || 1) + if (!Number.isInteger(limit) || limit < 1) throw Error('limit 必须为正整数') + const options = validateExecutionOptions(parseArguments(['--modules', 'orders,inbound-measurement', '--quiet', 'true'])) + if (new URL(options.gateway).hostname !== args.get('expected-host') || !/^test[-.]/.test(args.get('expected-host'))) throw Error('只允许明确匹配的测试域名') + const input = resolve(args.get('input')) + const bytes = await readFile(input) + const rows = JSON.parse(execFileSync(args.get('python'), ['-c', + 'import openpyxl,json,sys; w=openpyxl.load_workbook(sys.argv[1],read_only=True,data_only=True); print(json.dumps(list(w[sys.argv[2]].values),ensure_ascii=False,default=str)); w.close()', + input, args.get('sheet') || 'D1512开始'], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, + env: { ...process.env, PYTHONIOENCODING: 'utf-8' } })) + const normalized = normalizeRows(rows) + const directory = resolve(args.get('output-dir')) + await mkdir(directory, { recursive: true }) + const statePath = resolve(directory, 'state.json') + const identity = { sourceHash: hash(bytes), batchId: args.get('batch-id'), gateway: hash(options.gateway), + customer: args.get('customer-name') || 'IT测试', origin: args.get('origin-warehouse-name') || '义乌仓' } + let state + try { state = JSON.parse(await readFile(statePath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error } + if (state && JSON.stringify(state.identity) !== JSON.stringify(identity)) throw Error('批次/源文件/目标发生变化,请使用独立输出目录') + state ||= { identity, orders: {}, startedAt: new Date().toISOString() } + const lock = await open(resolve(directory, 'run.lock'), 'wx') + const save = async () => { + await writeFile(`${statePath}.tmp`, JSON.stringify(state, null, 2), 'utf8') + for (let attempt = 0; ; attempt++) { + try { await rename(`${statePath}.tmp`, statePath); break } + catch (error) { + if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt >= 5) throw error + await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))) + } + } + } + const ctx = new AcceptanceRun(options) + const api = async (method, path, body, token = ctx.token, headers = {}) => requireSuccess(await ctx.request(method, path, body, token, headers)) + try { + ctx.token = (await api('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')).accessToken + const merchant = (await api('POST', '/api/auth/login', { loginId: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, '')).accessToken + if (!ctx.token || !merchant) throw Error('LOGIN_TOKEN_MISSING') + const profile = await api('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchant) + if (profile.tenantName !== identity.customer || profile.status !== 'ACTIVE') throw Error('CUSTOMER_MISMATCH_OR_INACTIVE') + const list = async (type) => { + const result = [] + for (let page = 1; ; page++) { + const data = await api('GET', `/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`, undefined, merchant) + if (!Array.isArray(data.records) || !Number.isFinite(Number(data.total))) throw Error('WAREHOUSE_PAGE_INVALID') + result.push(...data.records) + if (result.length >= Number(data.total)) return result + if (!data.records.length || page >= 100) throw Error('WAREHOUSE_PAGE_INCOMPLETE') + } + } + const origins = (await list('DOMESTIC')).filter((item) => item.warehouseName === identity.origin) + if (origins.length !== 1) throw Error('ORIGIN_NOT_UNIQUE') + const receiving = origins[0] + const channels = await api('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, undefined, merchant) + const catalog = { receiving, channels, destinations: [...await list('FBA'), ...await list('OVERSEAS')] } + const pallets = [] + for (const code of PALLETS) { + const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`) + const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id)) + if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`) + pallets.push(found[0]) + } + const policy = await api('GET', '/api/merchant/v1/orders/first-leg/inbound-code-policy', undefined, merchant) + if (policy.mode !== 'AUTO') throw Error('INBOUND_CODE_POLICY_NOT_AUTO') + const candidates = [], rejected = [...normalized.excluded] + for (const order of normalized.orders) { + try { candidates.push({ order, route: matchRoute(order, catalog) }) } + catch (error) { rejected.push({ key: order.key, rows: order.rows, reason: error.message }) } + } + const preflight = { sourceRows: rows.length - 1, orders: normalized.orders.length, + matched: candidates.length, rejected, channelCount: channels.length, palletCodes: PALLETS, + note: '匹配仅验证目录,写入前还会用实际重量体积试价。私人仓、需工作托盘的一件代发及歧义源记录不擅自替代。' } + await writeFile(resolve(directory, 'preflight.json'), JSON.stringify(preflight, null, 2), 'utf8') + console.log(JSON.stringify({ stage, sourceRows: preflight.sourceRows, orders: preflight.orders, matched: candidates.length, rejected: rejected.length })) + await save() + if (stage === 'preflight') return + await executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) + } finally { + await lock.close() + const { unlink } = await import('node:fs/promises') + await unlink(resolve(directory, 'run.lock')) + } +} + +export async function executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) { + let attempted = 0, succeeded = 0, failed = 0, skipped = 0 + for (const { order, route } of candidates) { + // An interrupted write is not permission to create another order. Keep its last + // checkpoint for inspection. A reset database is always a NEW batch/directory. + if (state.orders[order.key]) { skipped++; continue } + if (attempted >= limit) break + const marker = `XLSX-${hash(`${state.identity.batchId}:${order.key}`).slice(0, 24)}` + const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString() } + state.orders[order.key] = entry + await save() + attempted++ + try { + const estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', { + originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode, + destinationCode: route.destination.warehouseCode, destinationType: order.destinationType, + destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName, + pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode, + actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm, + }, merchant) + if (estimate?.channelCode !== route.channel.channelCode || estimate?.pricingTaxMode !== order.pricingTaxMode) throw Error('ESTIMATE_ROUTE_MISMATCH') + let payload + if (stage === 'create') { + payload = buildSourceDraft(order, route, profile.tenantName, marker) + } else { + const config = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`) + entry.dimensionPlan = totalPreservingShapes(order, config.rules) + entry.stage = 'product-image'; await save() + const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images', + { content: PNG, type: 'image/png', name: `${marker}-TEST.png` }, merchant)) + if (!image?.fileId) throw Error('PRODUCT_IMAGE_ID_MISSING') + entry.productImageFileId = image.fileId + payload = buildPayload(order, route, profile.tenantName, image.fileId, marker) + } + entry.stage = 'create'; entry.payloadHash = hash(JSON.stringify(payload)); await save() + entry.orderId = String(await api('POST', '/api/merchant/v1/orders/first-leg', payload, merchant, + { 'Idempotency-Key': `${marker}-create` })) + if (!entry.orderId || entry.orderId === 'null' || entry.orderId === 'undefined') throw Error('ORDER_ID_MISSING') + entry.stage = 'draft-created'; await save() + const clientPath = `/api/merchant/v1/orders/first-leg/${entry.orderId}` + const draft = await api('GET', clientPath, undefined, merchant) + if (String(draft.order?.id) !== entry.orderId || !draft.order?.customerRemark?.includes(marker)) throw Error('ORDER_PROVENANCE_MISMATCH') + const cargo = draft.cargoes?.[0] + if (draft.cargoes?.length !== 1 || Number(cargo.packageCount) !== order.packageCount + || Math.abs(Number(cargo.grossWeightKg) - order.weightKg) > 0.001 + || Math.abs(Number(cargo.volumeCbm) - order.volumeCbm) > 0.00001) throw Error('DRAFT_SOURCE_FACTS_MISMATCH') + entry.orderNo = draft.order.orderNo + entry.declarationVerified = true + if (stage === 'wait-split') { + entry.stage = 'submit'; await save() + await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` }) + entry.stage = 'approve'; await save() + const platformPath = `/api/platform/v1/orders/first-leg/${entry.orderId}` + const q = `tenantId=${profile.id}` + await api('POST', `${platformPath}/approve?${q}`) + const unified = await poll(async () => api('GET', `/api/platform/v1/orders/${entry.orderId}?${q}`), + (data) => data.summary?.inboundId && ['WAIT_MEASURE', 'MEASURING'].includes(data.summary?.mainStatus)) + entry.inboundId = String(unified.summary.inboundId) + entry.stage = 'inbound-created'; await save() + await api('POST', `${platformPath}/accept?${q}`) + await api('POST', `${platformPath}/start?${q}`) + await measureOrder({ order, entry, ctx, api, pallets, tenantId: String(profile.id), save }) + } + entry.status = 'SUCCEEDED'; succeeded++ + } catch (error) { + entry.status = 'FAILED'; entry.error = error.message; entry.evidence = error.evidence; failed++ + } + entry.finishedAt = new Date().toISOString(); await save() + console.log(JSON.stringify({ attempted, succeeded, failed, skipped, rows: entry.rows, + stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, error: entry.error, evidence: entry.evidence })) + // A small pilot must expose a common deployment/data defect before multiplying it. + if (failed >= 3 && succeeded === 0) break + } + state.lastRun = { attempted, succeeded, failed, skipped, stage, finishedAt: new Date().toISOString() } + await save() + if (failed) process.exitCode = 1 +} + +async function poll(read, predicate) { + let data + for (let i = 0; i < 30; i++) { + data = await read() + if (predicate(data)) return data + if (i < 29) await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const error = Error('PROJECTION_NOT_READY') + error.evidence = { processStatus: data?.processStatus || data?.summary?.mainStatus, + measurementStatus: data?.measurementStatus, measureReviewStatus: data?.measureReviewStatus } + throw error +} + +async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) { + const inboundId = entry.inboundId, q = `tenantId=${tenantId}`, marker = entry.marker + const inboundPath = `/api/platform/inbounds/${inboundId}?${q}` + const base = `/api/platform/v1/first-leg/inbounds/${inboundId}` + const measurementPath = `${base}/measurement?${q}` + const inbound = await api('GET', inboundPath) + if (String(inbound.summary?.tenantId) !== tenantId || String(inbound.summary?.id) !== inboundId) throw Error('INBOUND_PROVENANCE_MISMATCH') + entry.inboundCode = inbound.summary.inboundCode + entry.stage = 'carton-labels'; await save() + const pdf = await ctx.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`) + if (pdf.http !== 200 || !pdf.contentType?.includes('pdf') || Buffer.from(pdf.bytes || []).subarray(0, 4).toString() !== '%PDF') throw Error('LABEL_GENERATION_FAILED') + const shape = measurementShape(order) + entry.stage = 'carton-scan'; await save() + let cartonIndex = 1 + for (const group of entry.dimensionPlan) { + const form = new FormData() + form.append('data', new Blob([JSON.stringify({ boxCode: `${entry.inboundCode}#${order.packageCount}-${cartonIndex}`, + length: group.length, width: group.width, height: group.height, + machineNo: 'TEST-XLSX-SIMULATED', measuredAt: new Date().toISOString().slice(0, 19) })], { type: 'application/json' })) + form.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png') + const scan = requireSuccess(await ctx.requestMultipart('POST', `${base}/measurement/carton-scans?${q}`, form)) + if (!scan?.itemId || !scan?.measurementId) throw Error('SCAN_IDS_MISSING') + entry.measurementId = scan.measurementId; await save() + await api('PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`, + { measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: group.count }) + group.itemId = String(scan.itemId) + cartonIndex += group.count + } + const measured = await api('GET', measurementPath) + if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS') + const draft = await api('PUT', `${base}/measurement/draft?${q}`, { + measurementId: measured.measurementId, version: Number(measured.version), + actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128), + items: entry.dimensionPlan.map((group) => { + const item = measured.items.find((value) => String(value.id) === group.itemId) + if (!item) throw Error('MEASUREMENT_GROUP_MISSING') + return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, + packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height, + intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, + outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height, + coefficientMode: 'ADD', + remark: `${marker} TEST按总体积分组反算箱规,非实测` } }), + }) + const draftCheck = await api('GET', measurementPath) + if (Math.abs(Number(draftCheck.actualVolumeCbm) - order.volumeCbm) > 0.0000001 + || Number(draftCheck.actualPackageCount) !== order.packageCount) throw Error('DRAFT_VOLUME_OR_COUNT_MISMATCH') + const itemVolumeWeight = draftCheck.items.reduce((sum, value) => sum + Number(value.volumeWeightKg), 0) + if (Math.abs(itemVolumeWeight - Number(draftCheck.volumeWeightKg)) > 0.0000001) throw Error('SERVER_ITEM_TOTAL_VOLUME_WEIGHT_ROUNDING_MISMATCH') + entry.stage = 'weights'; entry.weights = []; await save() + let netSoFar = 0 + for (let index = 0; index < shape.palletCount; index++) { + const pallet = pallets[Number.parseInt(hash(`${marker}:${index}`).slice(0, 8), 16) % pallets.length] + const upload = requireSuccess(await ctx.requestMultipart('POST', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, + { content: PNG, type: 'image/png', name: `TEST-weight-${index + 1}.png` })) + const created = await api('POST', `${base}/weight-results?${q}`, { fileId: upload.fileId, palletId: pallet.id, + ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null }) + const tare = Number(created.palletTareWeightKg) + if (created.palletTareWeightKg == null || !Number.isFinite(tare) || tare < 0) throw Error('PALLET_TARE_SNAPSHOT_MISSING') + // Source gross is packed cargo weight, NOT scale weight including the reusable pallet. + const boxes = Math.min(30, order.packageCount - index * 30) + const net = index === shape.palletCount - 1 ? round(order.weightKg - netSoFar, 3) + : round(order.weightKg * boxes / order.packageCount, 3) + const confirmed = await api('POST', `${base}/weight-results/${created.id}/confirm?${q}`, + { version: Number(created.version), adjustedWeightValue: round(net + tare, 3), adjustedWeightUnit: 'KG', + adjustmentReason: `${marker} TEST模拟称重:源货重按箱分摊,再加托盘皮重` }) + if (Math.abs(Number(confirmed.netWeightKg) - net) > 0.001) throw Error('CONFIRMED_NET_WEIGHT_MISMATCH') + netSoFar += net + entry.weights.push({ id: created.id, palletCode: pallet.palletCode, netWeightKg: net, tareWeightKg: tare }); await save() + } + entry.stage = 'measurement-submit'; await save() + await api('POST', `${base}/measurement/submit?${q}`, { measurementId: draft.measurementId, version: Number(draft.version) }) + const pending = await api('GET', inboundPath) + entry.stage = 'measurement-approve'; await save() + await api('POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`, + { measurementId: draft.measurementId, expectedVersion: Number(pending.summary?.version), reason: 'TEST源数据流程试跑' }, ctx.token, + { 'Idempotency-Key': `${marker}-measurement-approve` }) + const final = await poll(() => api('GET', measurementPath), (data) => data.processStatus === 'WAIT_SPLIT') + entry.verification = { processStatus: final.processStatus, measurementStatus: final.measurementStatus, + reviewStatus: final.measureReviewStatus, expectedPackages: order.packageCount, actualPackages: Number(final.actualPackageCount), + expectedCargoWeightKg: order.weightKg, actualNetWeightKg: Number(final.actualNetWeightKg), + sourceVolumeCbm: order.volumeCbm, measuredVolumeCbm: Number(final.actualVolumeCbm), + volumeDifferenceCbm: round(Number(final.actualVolumeCbm) - order.volumeCbm), + sourceVolumeMatched: Math.abs(Number(final.actualVolumeCbm) - order.volumeCbm) <= 0.0000001, + simulatedDimensions: entry.dimensionPlan, confirmedWeightImages: entry.weights.length } + if (final.measureReviewStatus !== 'APPROVED' || final.measurementStatus !== 'APPROVED' + || Number(final.actualPackageCount) !== order.packageCount + || Math.abs(Number(final.actualNetWeightKg) - order.weightKg) > 0.001 + || !entry.verification.sourceVolumeMatched) throw Error('FINAL_SOURCE_FACTS_MISMATCH') + entry.stage = 'WAIT_SPLIT' +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { console.error(JSON.stringify({ error: error.message, evidence: error.evidence })); process.exitCode = 1 }) +} diff --git a/scripts/real-order-xlsx-test-env.test.mjs b/scripts/real-order-xlsx-test-env.test.mjs new file mode 100644 index 000000000..e38c0130c --- /dev/null +++ b/scripts/real-order-xlsx-test-env.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDraft, buildPayload, + requireSuccess, executeCandidates, totalPreservingShapes } from './real-order-xlsx-test-env.mjs' + +const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号'] +const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1'] +const order = () => normalizeRows([header, row]).orders[0] +const catalog = { receiving: { id: '1', warehouseName: 'Origin' }, + channels: [{ channelName: '合德海卡', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }], + destinations: [{ id: '2', warehouseType: 'FBA', warehouseCode: 'ABC1', warehouseName: 'ABC1', countryCode: 'US' }] } + +test('source counts remain actual, including after forecast differs', () => { + const modified = [...row]; modified[7] = 99 + assert.equal(normalizeRows([header, modified]).orders[0].packageCount, 31) +}) +test('missing and zero actual data do not fall back to forecast', () => { + for (const missing of [null, '', 0]) { + const modified = [...row]; modified[4] = missing + assert.ok(normalizeRows([header, modified]).orders[0].problems.includes('MISSING_实际件数')) + } +}) +test('invalid source codes are excluded, not converted into orders', () => { + const modified = [...row]; modified[0] = 'container subtotal' + const actual = normalizeRows([header, modified]) + assert.equal(actual.orders.length, 0); assert.equal(actual.excluded.length, 1) +}) +test('explicit declaration changes use the stated final mode', () => { + for (const [source, target] of [['买单改退税', 'TAX_REFUND'], ['退税改买单', 'BUY_DECLARATION']]) { + const modified = [...row]; modified[8] = source + const result = normalizeRows([header, modified]).orders[0] + assert.equal(result.taxMode, target); assert.deepEqual(result.problems, []) + } +}) +test('split rows aggregate actual counts once and reconcile forecast', () => { + const a = [...row], b = [...row]; a[4] = 15; b[4] = 16 + const result = normalizeRows([header, a, b]).orders[0] + assert.equal(result.packageCount, 31); assert.deepEqual(result.rows, [2, 3]); assert.deepEqual(result.problems, []) + b[4] = 15 + assert.ok(normalizeRows([header, a, b]).orders[0].problems.includes('SPLIT_COUNT_MISMATCH')) +}) +test('conflicting channels on same inbound code block import', () => { + const b = [...row]; b[2] = '其他包税' + assert.ok(normalizeRows([header, row, b]).orders[0].problems.includes('CONFLICT_渠道')) +}) +test('channel suffix normalization never substitutes another base channel', () => { + assert.equal(channelName('合德海派包税\n(FedEx)'), '合德海派') + assert.equal(channelName('合德海卡不包税'), '合德海卡') + assert.throws(() => matchRoute({ ...order(), channelName: 'Unknown' }, catalog)) +}) +test('excluded-tax estimation uses source billing unit and never guesses missing units', () => { + const modified = [...row]; modified[2] = '合德海卡不包税' + const columns = [...header, '单位'] + const result = normalizeRows([columns, [...modified, 'CBM']]).orders[0] + assert.equal(result.chargeMode, 'CBM'); assert.deepEqual(result.problems, []) + assert.ok(normalizeRows([columns, [...modified, '']]).orders[0].problems.includes('UNKNOWN_OR_CONFLICTING_CHARGE_MODE')) +}) +test('ambiguous destinations and unavailable tax modes fail closed', () => { + assert.throws(() => matchRoute(order(), { ...catalog, destinations: [...catalog.destinations, ...catalog.destinations] })) + assert.throws(() => matchRoute({ ...order(), pricingTaxMode: 'TAX_EXCLUDED' }, catalog)) +}) +test('private addresses and dropship are explicit gaps, not random replacements', () => { + assert.throws(() => matchRoute({ ...order(), destinationType: 'PRIVATE' }, catalog), /PRIVATE_ADDRESS/) + assert.throws(() => matchRoute({ ...order(), fulfillmentType: 'ONE_CLICK_DROPSHIP' }, catalog), /WORK_PALLET/) +}) +test('logical pallet count uses ceiling of boxes divided by 30', () => { + for (const [boxes, expected] of [[1, 1], [30, 1], [31, 2], [300, 10], [1000, 34]]) { + assert.equal(measurementShape({ ...order(), packageCount: boxes }).palletCount, expected) + } +}) +test('inferred groups conserve volume after ADD and server per-carton rounding', () => { + const rules = [[30, 20, 20], [40, 30, 30], [50, 40, 40], [60, 50, 50]].map((dims, index) => + ({ boxType: String(index), innerLengthCm: dims[0], innerWidthCm: dims[1], innerHeightCm: dims[2], edgeAdditionCm: 2 })) + for (const [packageCount, volumeCbm] of [[8, 0.403], [17, 1.545], [7, 0.626], [31, 2], [300, 23.457], [1, 0.1], [1000, 98.7654]]) { + const plan = totalPreservingShapes({ ...order(), packageCount, volumeCbm }, rules) + assert.ok(plan.length <= 2) + assert.equal(plan.reduce((n, g) => n + g.count, 0), packageCount) + const ticks = plan.reduce((n, g) => { + const raw = Number(((g.length + 2 * g.edgeAdditionCm) ** 3 / 1e6).toFixed(6)) + return n + Math.ceil(raw * 10000 - 1e-8) * g.count + }, 0) + assert.equal(ticks, Math.round(volumeCbm * 10000)) + if (Number.isInteger(Math.round(volumeCbm * 1e6) / 1000)) { + const detailWeight = plan.reduce((sum, group) => sum + Math.ceil(group.unitVolumeCbm * group.count * 167 * 1000 - 1e-8), 0) + assert.equal(detailWeight, Math.ceil(volumeCbm * 167 * 1000 - 1e-8)) + } + } + assert.throws(() => totalPreservingShapes({ ...order(), volumeCbm: 0.123456 }, rules), /PRECISION/) +}) +test('source-only draft does not invent measurement or product evidence', () => { + const payload = buildSourceDraft(order(), matchRoute(order(), catalog), 'Example', 'MARKER') + assert.equal(payload.cargoes[0].packageCount, 31) + assert.equal(payload.cargoes[0].grossWeightKg, 100) + assert.equal(payload.cargoes[0].volumeCbm, 2) + assert.equal(payload.cargoes[0].woodenProduct, false) + for (const key of ['lengthCm', 'productImageFileId', 'hsCode', 'dangerousGoods']) assert.equal(key in payload.cargoes[0], false) +}) +test('simulated payload is labeled, preserving source measures without randomization', () => { + const payload = buildPayload(order(), matchRoute(order(), catalog), 'Example', 'image', 'MARKER') + assert.match(payload.customerRemark, /非真实测量证据/) + assert.equal(payload.cargoes[0].volumeCbm, 2) + assert.match(payload.cargoes[0].cargoRemark, /禁止用于真实报关/) +}) +test('HTTP business errors preserve diagnostic evidence', () => { + assert.throws(() => requireSuccess({ http: 200, path: '/example', body: { code: 'A0409' }, requestId: 'req' }), + (error) => error.evidence.code === 'A0409' && error.evidence.requestId === 'req') +}) +test('same batch never recreates an attempted order, even when interrupted', async () => { + const value = order(), state = { identity: { batchId: 'test' }, orders: { [value.key]: { status: 'STARTED' } } } + await executeCandidates({ candidates: [{ order: value, route: catalog }], limit: 1, state, save: async () => {}, + api: async () => { throw Error('must not call HTTP') } }) + assert.equal(state.lastRun.attempted, 0); assert.equal(state.lastRun.skipped, 1) +})