diff --git a/src/modules/overseas-business/PROTOTYPE_MATRIX.md b/src/modules/overseas-business/PROTOTYPE_MATRIX.md index 59274465..0a4238f7 100644 --- a/src/modules/overseas-business/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-business/PROTOTYPE_MATRIX.md @@ -60,3 +60,16 @@ | 本土散板 | 列表 | 原 `page-overseas-looseboard` 独立列表与左侧操作 | 展示两端联系信息、商品名、预计托数及取送时间;内部未知安排显示“—”;池来源保持承运摘要。待人工截图确认 | 本次不增加详情、审核、删除或文件预览操作;不更改现有承运安排只读弹框。 + + +## 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/overseas-business/components/TransportDemandForm.vue b/src/modules/overseas-business/components/TransportDemandForm.vue new file mode 100644 index 00000000..8fb89b40 --- /dev/null +++ b/src/modules/overseas-business/components/TransportDemandForm.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts index e0585165..21bc6827 100644 --- a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts +++ b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts @@ -270,6 +270,30 @@ describe('overseas business mounted pages', () => { expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: null }) expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchase-detail', params: { purchaseId: 'OP-100' }, query: { customerId: '10001' } }) }) + it.each(['truck', 'looseboard'] as const)('requires an explicit customer and sends only the unified business payload for %s', async domain => { + const wrapper = mountPage(TransportOrderFormView, { domain }); await flushPromises() + const model = wrapper.findComponent({ name: 'ElForm' }).props('model') + expect(model.totalWeightKg).toBe(0); expect(model.totalVolumeCbm).toBe(0) + expect(model.budgetAmount).toBeUndefined(); expect(model.currency).toBe('USD') + const labels = wrapper.findAll('.el-form-item__label').map(item => item.text()) + expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual([ + '客户', ...(domain === 'truck' ? ['车辆类型'] : []), '起运地', '目的地', + '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址', + ]) + Object.assign(model, { vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 0, currency: 'CNY' }) + const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')! + await submit.trigger('click'); await flushPromises() + expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled() + model.customerId = '10001' + await submit.trigger('click'); await flushPromises() + const command = domain === 'truck' ? vi.mocked(createUsTruckOrder) : vi.mocked(createLocalLooseboardOrder) + expect(command).toHaveBeenCalledOnce() + const [customer, payload] = command.mock.calls[0]! + expect(customer).toBe('10001') + expect(payload).toEqual(expect.objectContaining({ origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', totalWeightKg: 0, totalVolumeCbm: 0, budgetAmount: 0, currency: 'CNY', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' })) + for (const field of ['customerId', 'tenantId', 'orderNo', 'status', 'transportMode', 'containerType']) expect(payload).not.toHaveProperty(field) + }) + it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() }) it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') }) it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) }) @@ -281,7 +305,7 @@ describe('overseas business mounted pages', () => { it('requires split contacts and both local times, rejecting reversed times', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises() const model = wrapper.findComponent({ name: 'ElForm' }).props('model') - Object.assign(model, { customerId: '10001', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, totalWeightKg: '80', totalVolumeCbm: '1.8', deliveryAt: '2026-10-20T11:30:00' }) + Object.assign(model, { customerId: '10001', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, totalWeightKg: 80, totalVolumeCbm: 1.8, deliveryAt: '2026-10-20T11:30:00' }) await wrapper.vm.$nextTick() const save = wrapper.findAll('button').find(button => button.text().includes('提交订单'))! await save.trigger('click'); await flushPromises() @@ -297,9 +321,9 @@ describe('overseas business mounted pages', () => { expect(payload).not.toHaveProperty('palletRequired') expect(payload).not.toHaveProperty('transportType') }) - it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.findAll('input[placeholder="城市 / 州 / 详细地址"]')[1]; await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) + it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() }) - it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '1' : '联系人'); const dates = wrapper.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-20T09:30:00'); dates[1].vm.$emit('update:modelValue', '2026-10-20T15:45:00'); const origin = wrapper.findAll('input[placeholder="城市 / 州 / 详细地址"]')[0]; const destination = wrapper.findAll('input[placeholder="城市 / 州 / 详细地址"]')[1]; await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() }) + it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '1' : '联系人'); const dates = wrapper.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-20T09:30:00'); dates[1].vm.$emit('update:modelValue', '2026-10-20T15:45:00'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() }) it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => { routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' } vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 }) diff --git a/src/modules/overseas-business/pages/TransportOrderFormView.vue b/src/modules/overseas-business/pages/TransportOrderFormView.vue index 2c08f83a..164e669a 100644 --- a/src/modules/overseas-business/pages/TransportOrderFormView.vue +++ b/src/modules/overseas-business/pages/TransportOrderFormView.vue @@ -4,8 +4,8 @@ import { TransportRouteDialog, routeLocation } from '@/modules/overseas-master-d import '../overseasBusinessDensity.css' import { computed, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { ArrowLeft, Save } from '@lucide/vue' -import { ElMessage, type FormInstance, type FormRules } from 'element-plus' +import { ArrowLeft } from '@lucide/vue' +import { ElMessage, type FormInstance } from 'element-plus' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DetailPageLayout from '@/shared/components/DetailPageLayout.vue' @@ -18,8 +18,10 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness' import { overseasBusinessPermissions } from '../permissions' import FleetArrangementSummary from '../components/FleetArrangementSummary.vue' +import TransportDemandForm from '../components/TransportDemandForm.vue' +import { createTransportDemandForm, transportDemandError, buildTransportDemand, type TransportDemandForm as DemandFormModel } from '../transportDemandForm' import type { FleetArrangement } from '../fleetArrangement' -import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest, TruckVehicleType } from '../types' +import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types' const props = defineProps<{ domain: 'truck' | 'looseboard' }>() const routeOpen = ref(false) @@ -30,45 +32,17 @@ const editing = computed(() => Boolean(orderId.value)); const isLooseboard = com const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车'); const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks') const requiredPermission = computed(() => isLooseboard.value ? (editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate) : (editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate)) type FormStatus = LocalTransportStatus | LocalLooseboardStatus -interface TransportFormModel { - customerId: string; orderNo: string; productName: string; estimatedPalletCount: number - origin: string; destination: string; originAddress: string | null; destinationAddress: string | null - packageCount: number - vehicleType: TruckVehicleType | ''; originContactName: string; originContactPhone: string - destinationContactName: string; destinationContactPhone: string; pickupAt: string; deliveryAt: string - totalWeightKg: string | null; totalVolumeCbm: string | null; budgetAmount: string | null; currency: string - remark: string | null; status: FormStatus; version: number; idempotencyKey: string +interface TransportFormModel extends DemandFormModel { + customerId: string; orderNo: string; status: FormStatus; version: number; idempotencyKey: string } function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` } -const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', productName: '', estimatedPalletCount: 0, origin: '', destination: '', originAddress: null, destinationAddress: null, vehicleType: '', originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '', pickupAt: '', deliveryAt: '', packageCount: 0, totalWeightKg: isLooseboard.value ? '0' : null, totalVolumeCbm: isLooseboard.value ? '0' : null, budgetAmount: null, currency: 'USD', remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() }) +const initial = (): TransportFormModel => ({ ...createTransportDemandForm(), customerId: '', orderNo: '', status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() }) const form = reactive(initial()); const loading = ref(false); const saving = ref(false) const fleetArrangement = ref(null) const loadError = ref(null); const submitError = ref(null) const customers = ref([]); const customersLoading = ref(false); const customersError = ref('') -const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' }) -function demandRequired(label: string, trigger = 'blur') { - return { required: true, validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => callback(String(value || '').trim() ? undefined : new Error(`请填写${label}`)), trigger } -} -const rules: FormRules = { - origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }], - packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }], totalWeightKg: [requiredDecimal('总重量')], totalVolumeCbm: [requiredDecimal('总体积')], - vehicleType: [{ required: true, validator: (_rule, value, callback) => callback(isLooseboard.value || value ? undefined : new Error('请选择车辆类型')), trigger: 'change' }], - originContactName: [demandRequired('起运地联系人')], originContactPhone: [demandRequired('起运地联系电话')], - destinationContactName: [demandRequired('目的地联系人')], destinationContactPhone: [demandRequired('目的地联系电话')], - pickupAt: [{ required: true, validator: (_rule, value, callback) => callback(!value ? new Error('请选择取货时间') : undefined), trigger: 'change' }], - estimatedPalletCount: [{ type: 'number', min: 0, required: true, message: '预计托数不能小于 0', trigger: 'change' }], - deliveryAt: [demandRequired('送货时间', 'change'), { - validator: (_rule, value, callback) => callback(value && form.pickupAt && value < form.pickupAt ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change', - }], - budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }], -} -const truckStatusLabels: Record = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' } -const looseboardStatusLabels: Record = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } -const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus]) const editableStatus = computed(() => !fleetArrangement.value && (!editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW'))) const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission }) -function optional(value: string | null) { return value?.trim() || null } -function amount(value: string | null) { return value === null || value === '' ? null : Number(value) } async function loadCustomers() { if (editing.value) return customersLoading.value = true; customersError.value = '' @@ -85,11 +59,11 @@ async function load() { if (isLooseboard.value) { const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value) fleetArrangement.value = detail.fleetArrangement - Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark, status: detail.status, version: detail.version }) + Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version }) } else { const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value) fleetArrangement.value = detail.fleetArrangement - Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, vehicleType: detail.vehicleType || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, remark: detail.remark, status: detail.status, version: detail.version }) + Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', vehicleType: detail.vehicleType || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, remark: detail.remark || '', status: detail.status, version: detail.version }) } captureBaseline() } catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false } @@ -97,20 +71,17 @@ async function load() { async function save() { if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return } if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return } - if (isLooseboard.value && form.pickupAt && form.deliveryAt && form.deliveryAt < form.pickupAt) { submitError.value = { message: '送达时间不能早于取货时间' }; return } - for (const [value, label] of [[form.originContactName, '起运地联系人'], [form.originContactPhone, '起运地联系电话'], [form.destinationContactName, '目的地联系人'], [form.destinationContactPhone, '目的地联系电话'], [form.pickupAt, '取货时间'], [form.deliveryAt, '送货时间']]) { - if (!value.trim()) { submitError.value = { message: `请填写${label}` }; return } - } + const failure = transportDemandError(isLooseboard.value ? 'looseboard' : 'truck', form) + if (failure) { submitError.value = { message: failure }; return } if (!await formRef.value?.validate().catch(() => false)) return - const common = { origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, remark: optional(form.remark) } saving.value = true; submitError.value = null try { if (isLooseboard.value) { - const loose = { ...common, productName: optional(form.productName), estimatedPalletCount: form.estimatedPalletCount, originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(), destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(), pickupAt: form.pickupAt, deliveryAt: form.deliveryAt } + const loose = buildTransportDemand('looseboard', form) if (editing.value) await updateLocalLooseboardOrder(orderId.value, routeCustomerId.value, { ...loose, version: form.version } satisfies UpdateLocalLooseboardOrderRequest) else await createLocalLooseboardOrder(form.customerId, { ...loose, idempotencyKey: form.idempotencyKey } satisfies CreateLocalLooseboardOrderRequest) } else { - const truck = { ...common, vehicleType: form.vehicleType as TruckVehicleType, originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(), destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(), pickupAt: form.pickupAt, deliveryAt: form.deliveryAt } + const truck = buildTransportDemand('truck', form) if (editing.value) await updateUsTruckOrder(orderId.value, routeCustomerId.value, { ...truck, version: form.version } satisfies UpdateLocalTransportOrderRequest) else await createUsTruckOrder(form.customerId, { ...truck, idempotencyKey: form.idempotencyKey } satisfies CreateLocalTransportOrderRequest) } @@ -126,52 +97,18 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu -
- {{ customersError }} +
- - - 查看路线
取消{{ editing ? '保存修改' : '提交订单' }}
+ + + + 查看路线 +
diff --git a/src/modules/overseas-business/transportDemandForm.ts b/src/modules/overseas-business/transportDemandForm.ts new file mode 100644 index 00000000..91d5f316 --- /dev/null +++ b/src/modules/overseas-business/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 } +}