From 17580fb0a22ddf7dbf2576fc8fc4ab94a31cb4fd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 15:50:39 +0800 Subject: [PATCH] feat(transport): unify client truck and looseboard demand forms --- src/modules/cross-border/PROTOTYPE_MATRIX.md | 13 ++ .../cross-border/businessOrders.test.mjs | 11 +- .../components/BusinessOrdersView.vue | 174 ++---------------- .../components/TransportDemandForm.vue | 87 +++++++++ .../cross-border/transportDemandForm.test.mjs | 61 ++++++ .../cross-border/transportDemandForm.ts | 106 +++++++++++ 6 files changed, 285 insertions(+), 167 deletions(-) create mode 100644 src/modules/cross-border/components/TransportDemandForm.vue create mode 100644 src/modules/cross-border/transportDemandForm.test.mjs create mode 100644 src/modules/cross-border/transportDemandForm.ts diff --git a/src/modules/cross-border/PROTOTYPE_MATRIX.md b/src/modules/cross-border/PROTOTYPE_MATRIX.md index 2d2319b..0742710 100644 --- a/src/modules/cross-border/PROTOTYPE_MATRIX.md +++ b/src/modules/cross-border/PROTOTYPE_MATRIX.md @@ -19,3 +19,16 @@ |---|---|---|---| | 美本土散板 | 新增 | 保持现有独立路由和表单容器;同步商品、预计托数、四项联系信息及必填的当地取送时间;删除运输方式、柜型和下单安排项 | 自动测试与人工视觉分开;待人工截图确认 | | 美本土散板 | 列表、详情 | 保持现有独立路由;展示同一保存值和真实承运安排,不访问运力池 | 待人工截图确认 | + + +## Admin / Client 统一运输下单表单(2026-10-19) + +依据:当前对话明确要求“最多 Admin 添加客户选项,Client 从登录客户下单”,覆盖此前两端独立排版结论。保留应用壳层、正式路由及权限;业务表单统一为同一字段清单、排列、控件、默认值、校验及提交转换。 + +| 页面 | 操作 | 承载与现有依据 | 本轮统一结论 | 验收 | +|---|---|---|---|---| +| 本土卡车 | Client 新增、Admin 新增/编辑 | 既有独立页面;原客户端截图及当前统一确认 | 运输信息双列,车辆必选;两端联系人/电话及详细地址左右对应;货物件重体;费用币种与两个分钟时间;备注;底部取消/提交。Admin 新增前置客户选择 | 自动合同及行为测试;待人工 1920×1080、100% 截图 | +| 本土散板 | Client 新增、Admin 新增/编辑 | 既有独立页面;当前统一确认 | 与卡车共用表单;保留商品名、预计托数和件重体,移除车型;其余相同。Admin 新增前置客户选择 | 同上 | +| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 | + +统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。 diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index 0e5215b..6279cc0 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -7,6 +7,9 @@ import ts from 'typescript' const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') +const demandComponent = readFileSync(new URL('./components/TransportDemandForm.vue', import.meta.url), 'utf8') +const demandLogic = ts.transpileModule(readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '') +const demandHelpers = runInNewContext(`${demandLogic}\n({ createTransportDemandForm, transportDemandRules, transportDemandError, buildTransportDemand })`) const procurementApi = readFileSync(new URL('./api/procurement.ts', import.meta.url), 'utf8') const procurementView = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8') @@ -106,7 +109,7 @@ test('preserves the existing transport responsive rules without adding a narrow- assert.match(view, /queryFields/) assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/) assert.match(view, /\.form-card :deep\(\.el-form-item\) \{ display: block; \}/) - assert.match(view, /\.budget-form-item :deep\(\.el-form-item__content\) \{ display: flex; flex-wrap: nowrap; \}/) + assert.match(demandComponent, /\.transport-demand-budget \{ display: grid; width: 100%/) }) test('uses the shared dense order-list and detail visual language', () => { @@ -121,18 +124,18 @@ function transportHarness(kind = 'trucking', failFirst = false) { const script = view.match(/ + + + + diff --git a/src/modules/cross-border/transportDemandForm.test.mjs b/src/modules/cross-border/transportDemandForm.test.mjs new file mode 100644 index 0000000..15de2de --- /dev/null +++ b/src/modules/cross-border/transportDemandForm.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8') +const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '') +const { createTransportDemandForm, transportDemandError, buildTransportDemand } = runInNewContext(`${code}\n({ createTransportDemandForm, transportDemandError, buildTransportDemand })`) +const valid = () => ({ ...createTransportDemandForm(), vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' +1-555-0100 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' +1-555-0101 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12' }) + +test('initial form leaves contacts and times blank, zeroes cargo and distinguishes blank budget from zero', () => { + const form = createTransportDemandForm() + for (const key of ['originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(form[key], '') + for (const key of ['estimatedPalletCount', 'packageCount', 'totalWeightKg', 'totalVolumeCbm']) assert.equal(form[key], 0) + assert.equal(form.budgetAmount, undefined) + assert.equal(form.currency, 'USD') + assert.equal(buildTransportDemand('truck', valid()).budgetAmount, undefined) + assert.equal(buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }).budgetAmount, 0) +}) + +for (const kind of ['truck', 'looseboard']) { + test(`${kind} requires both route contacts and both service times before building a payload`, () => { + for (const field of ['origin', 'destination', 'originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) { + const form = { ...valid(), [field]: '' } + assert.ok(transportDemandError(kind, form), field) + assert.throws(() => buildTransportDemand(kind, form), undefined, field) + } + }) + test(`${kind} rejects negative cargo, fractional counts, invalid budgets and reversed times`, () => { + for (const field of ['packageCount', 'totalWeightKg', 'totalVolumeCbm', 'budgetAmount']) { + assert.ok(transportDemandError(kind, { ...valid(), [field]: -1 }), field) + assert.ok(transportDemandError(kind, { ...valid(), [field]: Number.NaN }), field) + } + assert.ok(transportDemandError(kind, { ...valid(), packageCount: 1.5 })) + assert.ok(transportDemandError(kind, { ...valid(), deliveryAt: '2026-10-20T09:29:00' })) + assert.ok(transportDemandError(kind, { ...valid(), currency: 'EUR' })) + }) + test(`${kind} sends minute wall-clock values, trims contacts, and excludes customer and old fields`, () => { + const payload = buildTransportDemand(kind, { ...valid(), customerId: 'forged', tenantId: 'forged', transportMode: 'OLD', containerType: '40HQ' }) + assert.equal(payload.pickupAt, '2026-10-20T09:30:00') + assert.equal(payload.deliveryAt, '2026-10-20T15:45:00') + assert.equal(payload.origin, 'LA') + assert.equal(payload.originContactName, '发货人') + assert.equal(payload.destinationContactPhone, '+1-555-0101') + for (const key of ['customerId', 'tenantId', 'transportMode', 'transportType', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, key), false, key) + assert.equal(transportDemandError(kind, { ...valid(), deliveryAt: '2026-10-20T09:30:01' }), undefined) + }) +} + +test('truck accepts only the two vehicle types and does not submit looseboard cargo forecasts', () => { + assert.ok(transportDemandError('truck', { ...valid(), vehicleType: 'PALLET_99' })) + const truck = buildTransportDemand('truck', { ...valid(), productName: 'cargo', estimatedPalletCount: 3 }) + assert.equal(Object.hasOwn(truck, 'productName'), false) + assert.equal(Object.hasOwn(truck, 'estimatedPalletCount'), false) + const loose = buildTransportDemand('looseboard', { ...valid(), vehicleType: '', productName: ' cargo ', estimatedPalletCount: 3, packageCount: 8 }) + assert.equal(Object.hasOwn(loose, 'vehicleType'), false) + assert.equal(loose.productName, 'cargo') + assert.equal(loose.estimatedPalletCount, 3) + assert.equal(loose.packageCount, 8) +}) diff --git a/src/modules/cross-border/transportDemandForm.ts b/src/modules/cross-border/transportDemandForm.ts new file mode 100644 index 0000000..91d5f31 --- /dev/null +++ b/src/modules/cross-border/transportDemandForm.ts @@ -0,0 +1,106 @@ +import type { FormRules } from 'element-plus' + +export type TransportDemandKind = 'truck' | 'looseboard' +export interface TransportDemandForm { + vehicleType: '' | 'PALLET_15' | 'PALLET_30' + origin: string + destination: string + originAddress: string + destinationAddress: string + originContactName: string + originContactPhone: string + destinationContactName: string + destinationContactPhone: string + productName: string + estimatedPalletCount: number + packageCount: number + totalWeightKg: number + totalVolumeCbm: number + budgetAmount: number | undefined + currency: 'USD' | 'CNY' + pickupAt: string + deliveryAt: string + remark: string +} + +export function createTransportDemandForm(): TransportDemandForm { + return { + vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '', + originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '', + productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0, + budgetAmount: undefined, currency: 'USD', pickupAt: '', deliveryAt: '', remark: '', + } +} + +const requiredText = { + origin: '起运地', destination: '目的地', originContactName: '起运地联系人', + originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话', +} as const + +export function localMinuteInput(value: unknown) { + return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : '' +} + +export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules { + const rules: FormRules = {} + for (const [field, label] of Object.entries(requiredText)) { + rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }] + } + if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }] + for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) { + if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue + rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }] + } + rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }] + rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }] + rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, { + validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change', + }] + return rules +} + +export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined { + if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型' + for (const [field, label] of Object.entries(requiredText)) { + if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}` + } + for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) { + if (!form[field]) return `请选择${label}` + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}` + } + if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间' + for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) { + if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字` + } + if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数' + if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0' + if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种' + return undefined +} + +function commonPayload(form: TransportDemandForm) { + return { + origin: form.origin.trim(), destination: form.destination.trim(), + originAddress: form.originAddress.trim() || undefined, destinationAddress: form.destinationAddress.trim() || undefined, + originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(), + destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(), + packageCount: form.packageCount, totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm, + budgetAmount: form.budgetAmount ?? undefined, currency: form.currency, + pickupAt: localMinuteInput(form.pickupAt), deliveryAt: localMinuteInput(form.deliveryAt), remark: form.remark.trim() || undefined, + } +} + +type CommonPayload = ReturnType +type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' } +type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number } + +export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload +export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload +export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) { + const failure = transportDemandError(kind, form) + if (failure) throw new Error(failure) + const common = commonPayload(form) + return kind === 'truck' + ? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' } + : { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount } +}