From 0cf4a93f9c1921890d3620b4400e77cd0d64d22f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:36:49 +0800 Subject: [PATCH] feat(admin): simplify truck payable currency --- .../trucking/api/platformTrucking.test.ts | 6 ++--- src/modules/trucking/api/platformTrucking.ts | 4 +-- .../TruckAppointmentForm.behavior.test.ts | 17 ++++++++++++ .../components/TruckAppointmentForm.test.ts | 19 ++++++++++++++ .../components/TruckAppointmentForm.vue | 26 ++++++++++--------- .../pages/TruckAppointmentCreateView.test.ts | 2 ++ .../pages/TruckAppointmentCreateView.vue | 2 +- .../pages/TruckAppointmentDetailView.test.ts | 1 + .../pages/TruckAppointmentDetailView.vue | 4 +-- .../pages/TruckAppointmentsView.test.ts | 8 +++--- .../trucking/pages/TruckAppointmentsView.vue | 1 - src/modules/trucking/types.ts | 14 ++++++---- 12 files changed, 74 insertions(+), 30 deletions(-) diff --git a/src/modules/trucking/api/platformTrucking.test.ts b/src/modules/trucking/api/platformTrucking.test.ts index 784b5f5f..108d5043 100644 --- a/src/modules/trucking/api/platformTrucking.test.ts +++ b/src/modules/trucking/api/platformTrucking.test.ts @@ -19,17 +19,17 @@ describe('truck appointment API', () => { vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } }) await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' }) await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' }) - expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 }) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0 }) await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' }) expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } }) }) it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => { vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } }) - const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, customerId: 'legacy-customer', customerName: '旧客户快照' } + const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, currency: 'EUR', receivableAmount: '999.00', customerId: 'legacy-customer', customerName: '旧客户快照' } await createTruckAppointment(payload as never) expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', { - carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, + carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0, }) }) diff --git a/src/modules/trucking/api/platformTrucking.ts b/src/modules/trucking/api/platformTrucking.ts index 9e58410c..36d4c3a1 100644 --- a/src/modules/trucking/api/platformTrucking.ts +++ b/src/modules/trucking/api/platformTrucking.ts @@ -1,5 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import { TRUCK_APPOINTMENT_SAVE_FIELDS } from '../types' +import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency } from '../types' import type { SaveTruckAppointmentRequest, TruckAppointmentPage, @@ -17,7 +17,7 @@ function operationId(action: string, id: string | number) { function dtoPayload(payload: SaveTruckAppointmentRequest) { return Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.flatMap((key) => { - const value = payload[key] + const value = key === 'currency' ? normalizeTruckAppointmentCurrency(payload[key]) : payload[key] return value === undefined ? [] : [[key, value === '' ? null : value]] })) as unknown as SaveTruckAppointmentRequest } diff --git a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts index 5047e616..b279b8a6 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts @@ -122,4 +122,21 @@ describe('TruckAppointmentForm booking-only behavior', () => { expect(emitted.containerBookingId).toBeNull() wrapper.unmount() }) + + it('switches the payable currency between RMB and USD and normalizes an illegal legacy value', async () => { + const wrapper = mount(TruckAppointmentForm, { + props: { modelValue: { ...model, payableAmount: '88.00', currency: 'EUR' as never } }, + global: { plugins: [ElementPlus] }, + }) + await flushPromises() + const currencySelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择币种')! + expect(currencySelect.props('modelValue')).toBe('RMB') + currencySelect.vm.$emit('update:modelValue', 'USD') + await flushPromises() + const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest + expect(emitted.currency).toBe('USD') + expect(wrapper.text()).toContain('USD') + expect(emitted).not.toHaveProperty('receivableAmount') + wrapper.unmount() + }) }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.test.ts b/src/modules/trucking/components/TruckAppointmentForm.test.ts index 73d32c95..d87c4d2b 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8') +const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8') describe('TruckAppointmentForm interaction contract', () => { it('requires a released booking before a real-numbered allocated container', () => { @@ -68,4 +69,22 @@ describe('TruckAppointmentForm interaction contract', () => { expect(source).toContain(':deep(.el-select__selected-item)') expect(source).toContain('text-overflow:ellipsis') }) + + it('uses payable-only fees with a fixed dynamic currency selector', () => { + expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD']") + expect(source).toContain('') + expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"') + expect(source).toContain('placeholder="请选择币种"') + expect(source).not.toContain('receivableAmount') + expect(source).not.toContain('应收拖车费') + }) + + it('uses four compact desktop columns while retaining two-column relations and wide text fields', () => { + expect(source).toContain('@media (min-width: 1200px)') + expect(source).toContain('max-width:25%; flex:0 0 25%') + expect(source).toContain('class="relation-column"') + expect(source).toContain('class="wide-column"') + expect(source).toContain('max-width:50%; flex-basis:50%') + expect(source).not.toContain('overflow-x:auto') + }) }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index a9ee6ae0..04cf933e 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -2,7 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import type { FormInstance, FormRules } from 'element-plus' import { Eye, Save } from '@lucide/vue' -import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types' +import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types' import { listSuppliers, type Supplier } from '@/modules/suppliers' import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' @@ -16,8 +16,8 @@ const relationWarning = ref('') const bookingContainersLoading = ref(false) const bookingContainersError = ref(null) const bookingDocuments = ref([]) -const defaults: Partial = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 } -function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } } +const defaults: Partial = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 } +function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } } const draft = reactive(normalizeDraft(props.modelValue)) watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true }) watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true }) @@ -42,8 +42,7 @@ const rules: FormRules = { plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }], fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')], packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)], - grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')], - currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }], + grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], } const carriers = ref([]) const containers = ref([]) @@ -160,8 +159,8 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len 重新加载选项 订舱与柜次(必填) - -
{{ bookingContainersError.message }}重新加载
只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。
+ +
{{ bookingContainersError.message }}重新加载
只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。
基本信息 @@ -184,17 +183,16 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len 费用信息 - - - + + 门店信息与备注 - + - +
查看附件引用保存
@@ -214,6 +212,10 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len :deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; } :deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)} +@media (min-width: 1200px) { + .truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; } + .truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; } +} @media (max-width: 480px) { .truck-form { padding-inline: 2px; } .relation-note { align-items:flex-start; flex-direction:column; } diff --git a/src/modules/trucking/pages/TruckAppointmentCreateView.test.ts b/src/modules/trucking/pages/TruckAppointmentCreateView.test.ts index 8d1a6a8a..2e0926c7 100644 --- a/src/modules/trucking/pages/TruckAppointmentCreateView.test.ts +++ b/src/modules/trucking/pages/TruckAppointmentCreateView.test.ts @@ -10,6 +10,8 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => { expect(source).not.toContain('customerId') expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null') expect(source).toContain('plannedLoadingAt: null') + expect(source).toContain("payableAmount: '0.00', currency: 'RMB'") + expect(source).not.toContain('receivableAmount') expect(source).toContain('createTruckAppointment({ ...form })') }) diff --git a/src/modules/trucking/pages/TruckAppointmentCreateView.vue b/src/modules/trucking/pages/TruckAppointmentCreateView.vue index 0905a527..f633bfde 100644 --- a/src/modules/trucking/pages/TruckAppointmentCreateView.vue +++ b/src/modules/trucking/pages/TruckAppointmentCreateView.vue @@ -13,7 +13,7 @@ import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue' import { createTruckAppointment } from '../api/platformTrucking' import type { SaveTruckAppointmentRequest } from '../types' const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref(null) -const form = reactive({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' }) +const form = reactive({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' }) async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }