From 81610666574d36b1f62911a93fbe45ac51384a8c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:14:38 +0800 Subject: [PATCH 01/28] fix(admin): label supplier payment ledger types --- src/modules/finance/pages/FinanceCustomerAccountsView.test.ts | 3 ++- src/modules/finance/pages/FinanceCustomerAccountsView.vue | 4 +++- src/modules/finance/status.test.ts | 2 ++ src/modules/finance/status.ts | 2 ++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts index 19054451..fcc49164 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts @@ -64,7 +64,8 @@ describe('FinanceCustomerAccountsView delivery', () => { expect(page).not.toContain('class="detail-toolbar"') expect(page).not.toContain(' { expect(financeBusinessTypeLabel('RECONCILIATION_CREATED')).toBe('生成对账单') expect(financeBusinessTypeLabel('SETTLEMENT_ISSUED')).toBe('结算单生效') expect(financeBusinessTypeLabel('PAYMENT')).toBe('付款') + expect(financeBusinessTypeLabel('SUPPLIER_DIRECT_PAID')).toBe('供应商线下直付') + expect(financeBusinessTypeLabel('SUPPLIER_ON_ACCOUNT_PAYMENT')).toBe('供应商账期付款') expect(financeBusinessTypeLabel('VOID')).toBe('作废') expect(financeBusinessTypeLabel('NEW_BUSINESS')).toBe('未知业务类型') expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单') diff --git a/src/modules/finance/status.ts b/src/modules/finance/status.ts index 9304f917..6abf7ac4 100644 --- a/src/modules/finance/status.ts +++ b/src/modules/finance/status.ts @@ -66,6 +66,8 @@ const financeBusinessTypeLabels: Record = { SETTLEMENT_ISSUED: '结算单生效', RECEIPT: '收款', PAYMENT: '付款', + SUPPLIER_DIRECT_PAID: '供应商线下直付', + SUPPLIER_ON_ACCOUNT_PAYMENT: '供应商账期付款', ADJUSTMENT: '账务调整', VOID: '作废', // The following values are returned by current Finance read models. From 62a016a2f894d3364454f9f6841648a57bdec5b6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:29:17 +0800 Subject: [PATCH 02/28] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 876c3a21..89639976 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,8 +50,9 @@ | 货柜、排柜、预装/装柜清单与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。整柜报关按 `loadingPlanId` 展示,逐资料完成,全买单柜使用 `complete-all-buy`;Backend `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 | -| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 | -| 拖车、历史报关协同、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料改从货柜详情进入,不得在旧模块复制一套;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | +| 三期订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | +| 订舱拥有的拖车预约 | `modules/trucking/api/platformTrucking.ts`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentsView.vue` | 不选择或提交客户;先选 `RELEASED` 订舱,再选其已分配且有真实 ISO 柜号的柜次,提单号和仓库由订舱冻结事实带入;订舱须有有效 `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 文件,同一真实柜最多一条有效预约。拖车附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | +| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` | | 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 | | 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` | 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 03/28] 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 } }