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(/
+
+
+
+
+
+ 运输信息
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 费用与时效
+
+
+
+
+
+
+
undefined)" />
+
+
+
+
+
+
+
+
+
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 }
+}