优化
This commit is contained in:
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import { getProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types'
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/ov
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../api/procurementCommands')>(),
|
||||
downloadProcurementFile: vi.fn(),
|
||||
@@ -46,7 +48,7 @@ function memoryStorage(): Storage { const values = new Map<string, string>(); re
|
||||
|
||||
const purchase = {
|
||||
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
|
||||
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'WAIT_PACKING' as const, version: 3,
|
||||
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'RECEIVING' as const, version: 3,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086',
|
||||
estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
|
||||
@@ -70,6 +72,7 @@ describe('overseas business mounted pages', () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
|
||||
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(downloadProcurementFile).mockReset()
|
||||
vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
|
||||
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
|
||||
vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark })
|
||||
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
|
||||
@@ -161,12 +164,13 @@ describe('overseas business mounted pages', () => {
|
||||
const wrapper = mountPage(PurchaseOrderDetailView)
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.density-detail-title').text()).toBe('OP-100')
|
||||
expect(wrapper.get('[aria-label="采购订单概览"]').text()).toContain('洛杉矶采购仓')
|
||||
expect(wrapper.findAll('.density-fields')).toHaveLength(3)
|
||||
expect(wrapper.text()).toContain('洛杉矶采购仓')
|
||||
expect(wrapper.findAll('.density-fields')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('预算单价')
|
||||
expect(wrapper.text()).toContain('两段实际交寄')
|
||||
expect(wrapper.text()).not.toContain('库存信息')
|
||||
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('收货记录')
|
||||
expect(wrapper.text()).toContain('出库单')
|
||||
expect(wrapper.text()).toContain('采购库存')
|
||||
expect(wrapper.text()).not.toContain('两段实际交寄')
|
||||
expect(wrapper.text()).not.toContain('internal-purchase-route')
|
||||
await wrapper.get('.density-back').trigger('click')
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchases' })
|
||||
@@ -177,12 +181,13 @@ describe('overseas business mounted pages', () => {
|
||||
const result = procurementDetail()
|
||||
result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }]
|
||||
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
|
||||
vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] })
|
||||
let rejectDownload!: (error: Error) => void
|
||||
vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject }))
|
||||
const wrapper = mountPage(PurchaseOrderDetailView)
|
||||
useAuthStore().permissions = ['platform:overseas-business:purchase:list']
|
||||
await flushPromises()
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === '下载')
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === 'label.pdf')
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click'); await flushPromises()
|
||||
expect(downloadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', '9007199254741001')
|
||||
@@ -194,18 +199,14 @@ describe('overseas business mounted pages', () => {
|
||||
expect(document.body.textContent).toContain('文件下载失败')
|
||||
})
|
||||
|
||||
it('renders estimate holds separately from actual payment without claiming inbound settlement', async () => {
|
||||
it('uses the shared overseas fees panel for the exact procurement customer and document', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
const result = procurementDetail()
|
||||
result.customerView.fees = [{ feeNo: 'FEE-1', purpose: 'INTERNATIONAL_ESTIMATE', name: '国际运费预估', version: 1, active: true, determination: 'POSITIVE', amount: '25.00', currency: 'CNY', originalAmount: '25.00', originalCurrency: 'CNY', exchangeRate: '1', exchangeRateMonth: '2026-10', businessDate: '2026-10-09', parentFeeNo: null, walletStatus: 'FROZEN', sourceLinked: false }]
|
||||
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
|
||||
const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已冻结,未扣款')
|
||||
expect(wrapper.text()).toContain('预估不入池')
|
||||
expect(wrapper.text()).not.toContain('订单全部入库后结算')
|
||||
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual(expect.objectContaining({ businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-100', customerId: '10001', businessId: '9007199254740993' }))
|
||||
expect(wrapper.text()).not.toContain('预估不入池')
|
||||
})
|
||||
|
||||
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
|
||||
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
|
||||
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
|
||||
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
|
||||
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
|
||||
@@ -223,7 +224,7 @@ describe('overseas business mounted pages', () => {
|
||||
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
|
||||
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
|
||||
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
|
||||
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
|
||||
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('收货记录'); expect(wrapper.text()).toContain('出库单'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
|
||||
|
||||
it('submits only the internal remark and preserves input after a version conflict', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
@@ -276,10 +277,10 @@ describe('overseas business mounted pages', () => {
|
||||
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' ? ['车辆类型'] : []), '起运地', '目的地',
|
||||
'起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址',
|
||||
])
|
||||
const routeLabels = domain === 'truck'
|
||||
? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址']
|
||||
: ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址']
|
||||
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels])
|
||||
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()
|
||||
@@ -307,7 +308,7 @@ describe('overseas business mounted pages', () => {
|
||||
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' })) })
|
||||
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); 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(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).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(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); 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(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).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(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
|
||||
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
|
||||
it('requires split contacts and both local times, rejecting reversed times', async () => {
|
||||
|
||||
@@ -133,7 +133,7 @@ onMounted(() => { void load(); void loadContext() })
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench">
|
||||
<ListPageLayout aria-label="海外费用管理">
|
||||
<ListPageLayout aria-label="海外费用明细">
|
||||
<section class="workbench-table is-page-surface">
|
||||
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
|
||||
<el-tab-pane label="费用明细" name="fees">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -9,7 +8,6 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -17,136 +15,60 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import ProcurementNodeActions from '../components/ProcurementNodeActions.vue'
|
||||
import { remainingLabels, decisionLabels } from '../procurementControlForm'
|
||||
import { getProcurementWorkflow, type ProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import ProcurementWorkflowPanel from '../components/ProcurementWorkflowPanel.vue'
|
||||
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
|
||||
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
|
||||
|
||||
import { directionLabel, regionText } from '../procurementDirection'
|
||||
import { statusLabel, money, dateTime, text } from '../procurementPresentation'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { directionLabel } from '../procurementDirection'
|
||||
import { requireReviewedProcurementFees } from '../procurementOutboundFees'
|
||||
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
|
||||
const downloading = ref<string | null>(null)
|
||||
const detail = ref<PlatformProcurement | null>(null), workflow = ref<ProcurementWorkflow | null>(null), loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null), downloading = ref('')
|
||||
const order = computed(() => detail.value?.customerView.order)
|
||||
const t = (text: string) => regionText(text, order.value?.procurementDirection)
|
||||
const feePanel = ref<InstanceType<typeof BusinessDocumentFees>>()
|
||||
async function beforeOutbound() {
|
||||
if (!feePanel.value) throw new Error('费用明细尚未加载完成,请稍后重试')
|
||||
requireReviewedProcurementFees(await feePanel.value.getFees())
|
||||
}
|
||||
const fields = computed(() => [
|
||||
['采购方向', directionLabel(order.value?.procurementDirection)],
|
||||
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['商品规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity],
|
||||
['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['有效预算总额', money(detail.value?.customerView.budget.totalAmount, detail.value?.customerView.budget.currency)],
|
||||
[t('预计到海外仓'), dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
|
||||
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
|
||||
] as const)
|
||||
const purchaseFields = computed(() => [
|
||||
['实际购买时间', dateTime(detail.value?.purchaseFacts.purchased_at)], ['实购商品', detail.value?.purchaseFacts.purchased_product_name],
|
||||
['实购数量', detail.value?.purchaseFacts.purchased_quantity], ['原登记实付总额', money(detail.value?.purchaseFacts.purchase_amount, detail.value?.purchaseFacts.purchase_currency)],
|
||||
['货款核定时间', dateTime(detail.value?.purchaseFacts.purchase_confirmed_at)], ['待处理事项', detail.value?.purchaseFacts.purchase_issue],
|
||||
] as const)
|
||||
const receiptColumns: DataTableColumn[] = [
|
||||
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
|
||||
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => t(row.leg === 'OVERSEAS' ? '海外' : '国内') },
|
||||
{ key: 'source', label: '来源箱', minWidth: 160, formatter: (row: ProcurementReceipt) => detail.value?.physical.cartons.find(box => box.id === row.sourceCartonId)?.cartonNo || '—' },
|
||||
{ key: 'goodQuantity', prop: 'goodQuantity', label: '合格件数', width: 100 }, { key: 'damagedQuantity', prop: 'damagedQuantity', label: '破损件数', width: 100 }, { key: 'extraQuantity', prop: 'extraQuantity', label: '多货件数', width: 100 },
|
||||
{ key: 'receivedAt', label: '实收时间', minWidth: 160, formatter: (row: ProcurementReceipt) => dateTime(row.receivedAt) },
|
||||
{ key: 'active', label: '记录状态', width: 100, formatter: (row: ProcurementReceipt) => row.active ? '有效' : '已更正' },
|
||||
]
|
||||
const dispositionColumns: DataTableColumn[] = [
|
||||
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => t(row.leg === 'OVERSEAS' ? '海外' : '国内') },
|
||||
{ key: 'source', label: '实收或原箱来源', minWidth: 180, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => (row.receipt_id ? detail.value?.physical.receipts.find(receipt => receipt.id === row.receipt_id)?.receiptNo : row.source_carton_id ? detail.value?.physical.cartons.find(box => box.id === row.source_carton_id)?.cartonNo : order.value?.orderNo) || '—' },
|
||||
{ key: 'kind', label: '差异', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => ({ SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' })[row.kind] },
|
||||
{ key: 'quantity', prop: 'quantity', label: '处置件数', width: 100 }, { key: 'reason', prop: 'reason', label: '处置依据', minWidth: 220 },
|
||||
{ key: 'active', label: '状态', width: 110, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.active ? '有效' : '已撤销或更正' },
|
||||
]
|
||||
const cartonColumns: DataTableColumn[] = [
|
||||
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 160 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 },
|
||||
{ key: 'measurements', label: '长 × 宽 × 高(cm)', minWidth: 220, formatter: (row: ProcurementCarton) => [row.lengthCm, row.widthCm, row.heightCm].map(value => measurement(value, 2)).join(' × ') },
|
||||
{ key: 'weight', label: '毛重(kg)', width: 120, formatter: (row: ProcurementCarton) => measurement(row.grossWeightKg, 3) },
|
||||
{ key: 'sources', label: '装货来源', minWidth: 240, formatter: (row: ProcurementCarton) => row.sources.map(source => `${detail.value?.physical.receipts.find(receipt => receipt.id === source.receiptId)?.receiptNo || '未关联实收'}:${source.quantity} 件`).join(';') },
|
||||
]
|
||||
const shipmentColumns: DataTableColumn[] = [
|
||||
{ key: 'shipmentNo', prop: 'shipmentNo', label: '交寄编号', minWidth: 180 }, { key: 'leg', label: '运输段', width: 110, formatter: (row: ProcurementShipment) => row.leg === 'OVERSEAS' ? '国际交寄' : '寄客户' },
|
||||
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 150 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 100 }, { key: 'trackingNo', prop: 'trackingNo', label: '实际运单', minWidth: 180 },
|
||||
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
|
||||
]
|
||||
const feeColumns: DataTableColumn[] = [
|
||||
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => t(feePurposes[row.purpose] || '待核对') },
|
||||
{ key: 'name', prop: 'name', label: '费用项目', minWidth: 120 }, { key: 'original', label: '原币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.originalAmount, row.originalCurrency) },
|
||||
{ key: 'amount', label: '人民币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.amount, row.currency) },
|
||||
{ key: 'rate', label: '汇率 / 月份', minWidth: 150, formatter: (row: ProcurementFee) => `${text(row.exchangeRate)} / ${text(row.exchangeRateMonth)}` },
|
||||
{ key: 'walletStatus', label: '资金状态', minWidth: 150, formatter: (row: ProcurementFee) => feeStatus(row) },
|
||||
{ key: 'sourceLinked', label: '费用来源关联', width: 130, formatter: (row: ProcurementFee) => row.purpose === 'INTERNATIONAL_ESTIMATE' ? '预估不入池' : row.purpose === 'REFUND' ? '退款不入应收' : ['ZERO', 'NOT_APPLICABLE'].includes(row.determination) ? '无需入池' : row.sourceLinked ? '已完成' : '待完成' },
|
||||
{ key: 'active', label: '版本状态', width: 100, formatter: (row: ProcurementFee) => row.active ? '有效' : '历史版本' },
|
||||
]
|
||||
const labelGroups = computed(() => detail.value ? [
|
||||
{ leg: 'OVERSEAS', title: '国际段', value: detail.value.transport.overseasLabels },
|
||||
{ leg: 'DOMESTIC', title: '寄客户段', value: detail.value.transport.domesticLabels },
|
||||
].filter(group => group.value) : [])
|
||||
const labelColumns: DataTableColumn[] = [
|
||||
{ key: 'cartonId', label: '原箱', minWidth: 150, formatter: (row: { cartonId: string }) => detail.value?.physical.cartons.find(box => box.id === row.cartonId)?.cartonNo || '未关联箱' },
|
||||
{ key: 'fileId', label: '面单文件', minWidth: 200, formatter: (row: { fileId: string }) => detail.value?.files.find(file => file.fileId === row.fileId)?.fileName || '未关联文件' },
|
||||
{ key: 'page', prop: 'page', label: '页码', width: 75 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 }, { key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 180 },
|
||||
{ key: 'appliedAt', label: '实际贴单时间', minWidth: 160, formatter: (row: { appliedAt: string | null }) => dateTime(row.appliedAt) },
|
||||
]
|
||||
const fileLabels: Record<string, string> = { PURCHASE: '购买凭证', BUDGET: '预算授权', LABEL: '快递面单', EXCEPTION: '异常证据', RECEIPT: '收货凭证', VOUCHER: '费用凭证' }
|
||||
const noteColumns: DataTableColumn[] = [
|
||||
{ key: 'note_no', prop: 'note_no', label: '说明编号', minWidth: 180 },
|
||||
{ key: 'target', label: '关联事实', minWidth: 180, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.target_type === 'PURCHASE' ? order.value?.orderNo || '—' : row.target_type === 'WAREHOUSE' ? row.original_value_json?.name || '实际仓记录' : row.target_type === 'RECEIPT' ? detail.value?.physical.receipts.find(item => item.id === row.target_id)?.receiptNo || '未关联实收' : detail.value?.transport.shipments.find(item => item.id === row.target_id)?.shipmentNo || '未关联交寄' },
|
||||
{ key: 'reason', prop: 'reason', label: '内部事实说明', minWidth: 260 },
|
||||
{ key: 'warehouseCorrection', label: '仓库更正留痕', minWidth: 190, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.corrected_value_json ? `${row.original_value_json?.name || '—'} → ${row.corrected_value_json.warehouseName || '—'}` : '—' },
|
||||
{ key: 'block', label: '阻断状态', width: 140, formatter: (row: PlatformProcurement['controls']['notes'][number]) => !row.blocking ? '仅说明' : row.resolved_at ? '已解除阻断' : '待处理阻断' },
|
||||
{ key: 'resolution_reason', prop: 'resolution_reason', label: '解除依据', minWidth: 220 },
|
||||
{ key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 220 },
|
||||
]
|
||||
const remainingColumns: DataTableColumn[] = [{ key: 'businessNo', prop: 'businessNo', label: '货物来源', minWidth: 180 }, { key: 'kind', label: '剩余类型', minWidth: 130, formatter: (row: PlatformProcurement['controls']['remaining'][number]) => remainingLabels[row.kind] }, { key: 'quantity', prop: 'quantity', label: '待处理件数', width: 130 }]
|
||||
const closureColumns: DataTableColumn[] = [{ key: 'closure_no', prop: 'closure_no', label: '关闭编号', minWidth: 180 }, { key: 'financial_conclusion', prop: 'financial_conclusion', label: '内部财务结论', minWidth: 250 }, { key: 'reason', prop: 'reason', label: '内部关闭原因', minWidth: 220 }, { key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 250 }, { key: 'created_at', label: '关闭时间', minWidth: 170, formatter: (row: PlatformProcurement['controls']['closures'][number]) => dateTime(row.created_at) }]
|
||||
const closureItemColumns: DataTableColumn[] = [
|
||||
{ key: 'source', label: '处置来源', minWidth: 180, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => row.source_kind === 'PURCHASE_REMAINDER' ? order.value?.orderNo || '—' : row.source_kind === 'TRANSIT_REMAINDER' ? detail.value?.physical.cartons.find(item => item.id === row.source_id)?.cartonNo || '未关联原箱' : detail.value?.physical.receipts.find(item => item.id === row.source_id)?.receiptNo || '未关联实收' },
|
||||
{ key: 'source_kind', label: '来源类型', width: 130, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => remainingLabels[row.source_kind] },
|
||||
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90 }, { key: 'decision', label: '实际处置', minWidth: 150, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => decisionLabels[row.decision] || '待核对' }, { key: 'destination', prop: 'destination', label: '实际去向与接收方', minWidth: 260 },
|
||||
]
|
||||
const correctionColumns: DataTableColumn[] = [
|
||||
{ key: 'correctionNo', prop: 'correctionNo', label: '更正编号', minWidth: 190 }, { key: 'rootFeeNo', prop: 'rootFeeNo', label: '原费用编号', minWidth: 170 },
|
||||
{ key: 'targetOriginalAmount', label: '正确原币总额', minWidth: 150, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetOriginalAmount, row.originalCurrency) },
|
||||
{ key: 'targetAmount', label: '目标人民币', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetAmount, 'CNY') },
|
||||
{ key: 'previousNetAmount', label: '更正前净实扣', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.previousNetAmount, 'CNY') },
|
||||
{ key: 'differenceAmount', label: '本次差额', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.differenceAmount, 'CNY') },
|
||||
{ key: 'feeNos', label: '关联实际费用或调整', minWidth: 240, formatter: (row: PlatformProcurement['feeCorrections'][number]) => row.feeNos.join(';') || '无资金差额' },
|
||||
]
|
||||
const fileColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', slotName: 'actions', label: '操作', width: 90, fixed: 'left' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 250 },
|
||||
{ key: 'category', label: '用途', width: 120, formatter: (row: ProcurementFile) => fileLabels[row.category] || '待核对' },
|
||||
{ key: 'createdAt', label: '上传时间', minWidth: 160, formatter: (row: ProcurementFile) => dateTime(row.createdAt) },
|
||||
]
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'createdAt', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['reviewHistory'][number]) => dateTime(row.createdAt) }, { key: 'operation', label: '操作', width: 120, formatter: (row: PlatformProcurement['reviewHistory'][number]) => ({ CREATE: '创建', REVIEW: '审核', UPDATE: '编辑备注' } as Record<string, string>)[row.operationType || ''] || '采购变更' }, { key: 'remark', prop: 'remark', label: '说明', minWidth: 250 }]
|
||||
const commandColumns: DataTableColumn[] = [{ key: 'created_at', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['operations'][number]) => dateTime(row.created_at) }, { key: 'reason', prop: 'reason', label: '履约操作说明', minWidth: 400 }]
|
||||
let generation = 0; let inFlight: Promise<void> | null = null; let inFlightScope = ''
|
||||
['客户', order.value?.customerName], ['采购方向', directionLabel(order.value?.procurementDirection)], ['收货仓', order.value?.actualWarehouseName || order.value?.plannedWarehouseName],
|
||||
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['规格', order.value?.productSpecification],
|
||||
['采购数量', order.value?.purchaseQuantity], ['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['预计到仓', dateTime(order.value?.estimatedArrivalAt)],
|
||||
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['收货地址', order.value?.receivingAddress],
|
||||
...(detail.value?.internalRemark ? [['内部备注', detail.value.internalRemark]] : []),
|
||||
])
|
||||
let generation = 0, inFlight: Promise<void> | null = null, inFlightScope = ''
|
||||
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
function load() {
|
||||
const currentScope = scope.value
|
||||
if (inFlight && inFlightScope === currentScope) return inFlight
|
||||
const current = ++generation; const number = purchaseId.value; const customer = customerId.value
|
||||
detail.value = null; loadError.value = null; loading.value = false
|
||||
detail.value = null; workflow.value = null; loadError.value = null; loading.value = false
|
||||
if (!number || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return Promise.resolve() }
|
||||
loading.value = true; inFlightScope = currentScope
|
||||
const promise = getOverseasPurchase(number, customer).then(result => { if (current === generation) detail.value = result }).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
|
||||
const promise = Promise.all([getOverseasPurchase(number, customer), getProcurementWorkflow(number, customer)]).then(([result, process]) => {
|
||||
if (current === generation) { detail.value = result; workflow.value = process }
|
||||
}).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
|
||||
if (current === generation) loading.value = false
|
||||
if (inFlight === promise) inFlight = null
|
||||
})
|
||||
inFlight = promise; return promise
|
||||
}
|
||||
async function download(fileId: unknown) {
|
||||
async function download(fileId: string) {
|
||||
if (!order.value || downloading.value) return
|
||||
const file = detail.value?.files.find(item => item.fileId === fileId)
|
||||
if (!file || !order.value || downloading.value) return
|
||||
downloading.value = file.fileId; operationError.value = null; const current = scope.value
|
||||
downloading.value = fileId; operationError.value = null; const current = scope.value
|
||||
try {
|
||||
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, file.fileId)
|
||||
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, fileId)
|
||||
if (scope.value !== current) return
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file?.fileName || fileId; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (error) { if (scope.value === current) operationError.value = getApiErrorDetails(error, '文件下载失败') }
|
||||
finally { downloading.value = null }
|
||||
finally { downloading.value = '' }
|
||||
}
|
||||
let activatedOnce = false
|
||||
watch(scope, () => { void load() }, { immediate: true })
|
||||
@@ -154,29 +76,14 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" />
|
||||
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单履约详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status, order.procurementDirection)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
|
||||
<template v-if="order" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ text(order.customerName) }}</dd></div><div><dt>计划采购仓</dt><dd>{{ text(order.plannedWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }}</dd></div></dl></template>
|
||||
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="workflow" :status="statusLabel(workflow.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑备注</el-button></div></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="detail-grid">
|
||||
<template v-if="detail">
|
||||
<ProcurementNodeActions :detail="detail" :customer-id="customerId" @changed="load" />
|
||||
<section><h3>订单及预算</h3><dl class="density-fields"><div v-for="field in fields" :key="field[0]" :class="{ wide: ['下单收货地址', '内部备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>购买事实</h3><dl class="density-fields"><div v-for="field in purchaseFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>履约进度</h3><dl class="density-fields"><div v-for="step in detail.customerView.progress" :key="step.key"><dt>{{ step.title }}</dt><dd>{{ step.completedAt ? dateTime(step.completedAt) : '未完成' }}</dd></div></dl></section>
|
||||
<section><h3>分次实收记录</h3><div class="workbench-table"><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></div></section>
|
||||
<section><h3>差异处置记录</h3><div class="workbench-table"><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></div></section>
|
||||
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><div class="workbench-table"><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></div></section>
|
||||
<section v-if="detail.transport.domesticCargo.length"><h3>{{ t('国内包装与可寄货物核对') }}</h3><div class="workbench-table"><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></div></section>
|
||||
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版<InlineHelp title="面单核对说明"><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p></InlineHelp></h3><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><div class="workbench-table"><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></div></section>
|
||||
<section><h3>两段实际交寄</h3><div class="workbench-table"><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></div></section>
|
||||
<section><h3>费用与资金结果<InlineHelp title="费用与资金说明"><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></div></section>
|
||||
<section><h3>费用更正记录<InlineHelp title="费用更正说明"><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></div></section>
|
||||
<section><h3>事实说明与阻断</h3><div class="workbench-table"><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></div></section>
|
||||
<section><h3>剩余货物去向核对</h3><div class="workbench-table"><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></div></section>
|
||||
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><div class="workbench-table"><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /></div><div class="workbench-table"><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></div></section>
|
||||
<section><h3>本单文件</h3><div class="workbench-table"><DataTable :data="detail.files" :columns="fileColumns" row-key="fileId" empty-text="暂无文件"><template #actions="{ row }"><el-button class="density-action" :loading="downloading === row.fileId" :disabled="!!downloading" @click="download(row.fileId)">下载</el-button></template></DataTable></div></section>
|
||||
<section><h3>操作流水</h3><div class="workbench-table"><DataTable :data="detail.reviewHistory" :columns="historyColumns" empty-text="暂无基础操作记录" /></div><div class="workbench-table"><DataTable :data="detail.operations" :columns="commandColumns" empty-text="暂无履约操作记录" /></div></section>
|
||||
<template v-if="detail && order && workflow">
|
||||
<section><h3>采购信息</h3><dl class="density-fields"><div v-for="field in fields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
|
||||
<ProcurementWorkflowPanel :detail="detail" :workflow="workflow" :customer-id="customerId" :downloading="downloading" :before-outbound="beforeOutbound" @changed="load" @download="download" />
|
||||
<BusinessDocumentFees ref="feePanel" business-type="OVERSEAS_PROCUREMENT" :business-no="order.orderNo" :customer-id="customerId" :business-id="order.id" />
|
||||
</template>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
@@ -186,6 +93,4 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.detail-grid section { min-width: 0; }
|
||||
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
|
||||
.detail-grid p { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.density-fields > .wide { grid-column: span 2; }
|
||||
</style>
|
||||
|
||||
@@ -28,10 +28,10 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
|
||||
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label: ({ WAIT_OVERSEAS_RECEIPT: '待起运仓收货', OVERSEAS_RECEIVING: '起运地收货中', WAIT_OVERSEAS_SHIPMENT: '待国际交寄', DOMESTIC_RECEIVING: '目的地收货中', WAIT_CHARGES: '待目的地核费' } as Record<string, string>)[value] || label })) },
|
||||
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
|
||||
@@ -76,7 +76,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
|
||||
|
||||
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
|
||||
<template #toolbar-start><h1>采购订单</h1></template>
|
||||
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function mountDetail(manage = true) {
|
||||
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
|
||||
auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])]
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true } } })
|
||||
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true, ElSelect: true, ElOption: true } } })
|
||||
}
|
||||
async function click(wrapper: VueWrapper, label: string) {
|
||||
const button = wrapper.findAll('button').find(item => item.text() === label)
|
||||
@@ -43,10 +43,14 @@ async function click(wrapper: VueWrapper, label: string) {
|
||||
await button!.trigger('click'); await flushPromises()
|
||||
}
|
||||
async function confirm(wrapper: VueWrapper) {
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
const form = wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' })
|
||||
if (form.exists() && form.props('model').decision === 'APPROVE') form.props('model').warehouseId = '9'
|
||||
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
}
|
||||
const formModel = (wrapper: VueWrapper) => wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' }).props('model')
|
||||
|
||||
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn().mockResolvedValue({ records: [{ id: '9', warehouseCode: 'WH-9', warehouseName: '海外仓' }] }) }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -78,21 +82,12 @@ describe('truck review and fulfillment', () => {
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-trucks' })
|
||||
})
|
||||
|
||||
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
|
||||
it('loads customer scoped documents and moves approval directly to dispatch', async () => {
|
||||
let current = order()
|
||||
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
|
||||
const next = { ...current, version: current.version + 1 }
|
||||
switch (operation.command) {
|
||||
case 'review': next.status = 'WAITING_INBOUND'; break
|
||||
case 'receive-pallets':
|
||||
next.status = 'WAITING_CONSOLIDATION'; next.palletCount = 2; next.receivedPalletCount = 2
|
||||
next.pallets = [1, 2].map(sequenceNo => ({ id: `900719925474100${sequenceNo}`, sequenceNo, palletNo: `P-${sequenceNo}`, source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: '2026-10-20T10:00:00' }))
|
||||
break
|
||||
case 'allocate-pallets': next.status = 'ALLOCATED'; next.allocatedPalletCount = 2; next.pallets = next.pallets.map(row => ({ ...row, status: 'ALLOCATED' })); break
|
||||
case 'prepare-dispatch': next.status = 'PENDING_DISPATCH'; break
|
||||
case 'dispatch': next.status = 'DISPATCHED'; break
|
||||
case 'start-transit': next.status = 'IN_TRANSIT'; break
|
||||
case 'complete': next.status = 'COMPLETED'; break
|
||||
case 'review': next.status = 'PENDING_DISPATCH'; break
|
||||
}
|
||||
next.histories = [...current.histories, { operationType: operation.command === 'review' ? 'REVIEW' : operation.command.toUpperCase().replaceAll('-', '_'), fromStatus: current.status, toStatus: next.status, operatorId: '操作员7', remark: operation.payload.remark || null, createdAt: '2026-10-20T10:00:00' }]
|
||||
current = next; return next
|
||||
@@ -100,79 +95,61 @@ describe('truck review and fulfillment', () => {
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(getUsTruckOrder).toHaveBeenCalledWith('TR-TEST', '9007199254741999')
|
||||
expect(wrapper.text()).toContain('起运仓地址'); expect(wrapper.text()).toContain('审核与操作记录')
|
||||
await click(wrapper, '审核'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('登记实收'); expect(wrapper.text()).toContain('资料核对通过')
|
||||
await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper)
|
||||
await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper)
|
||||
for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) }
|
||||
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
|
||||
expect(current.status).toBe('COMPLETED')
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7])
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } })
|
||||
await click(wrapper, '审核并生成排车'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('排车审核'); expect(wrapper.text()).toContain('资料核对通过')
|
||||
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘')
|
||||
expect(current.status).toBe('PENDING_DISPATCH')
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1])
|
||||
})
|
||||
|
||||
it('requires a rejection reason and displays the resulting rejected state', async () => {
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
|
||||
formModel(wrapper).decision = 'REJECT'; await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写拒绝或取消原因')
|
||||
formModel(wrapper).remark = '取送时间需重新确认'
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 }))
|
||||
await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) }))
|
||||
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
|
||||
|
||||
})
|
||||
|
||||
it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => {
|
||||
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
|
||||
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'PENDING_DISPATCH', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
|
||||
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('订单版本冲突')
|
||||
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
|
||||
wrapper.findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await click(wrapper, '审核'); await confirm(wrapper)
|
||||
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await click(wrapper, '审核并生成排车'); await confirm(wrapper)
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original)
|
||||
expect(wrapper.text()).toContain('登记实收')
|
||||
expect(wrapper.text()).toContain('排车审核')
|
||||
})
|
||||
|
||||
it('uses delivery as the last flow node and omits point aliases', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'COMPLETED' }))
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(wrapper.findAll('.truck-flow-label').map(node => node.text())).toEqual(['待审核', '排车审核', '取货', '派送中', '送达'])
|
||||
expect(wrapper.findAll('.truck-flow li.done')).toHaveLength(5)
|
||||
expect(wrapper.text()).not.toMatch(/B点|C点|B\/C/)
|
||||
})
|
||||
|
||||
it('locks duplicate submission until the original response arrives', async () => {
|
||||
let finish!: (value: LocalTransportDetail) => void
|
||||
vi.mocked(executeUsTruckCommand).mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车'); await confirm(wrapper); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
expect(wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
for (const button of wrapper.find('.detail-page-layout__header').findAll('button')) expect(button.attributes('disabled')).toBeDefined()
|
||||
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
|
||||
expect(wrapper.text()).toContain('登记实收')
|
||||
finish(order({ status: 'PENDING_DISPATCH', version: 2 })); await flushPromises()
|
||||
expect(wrapper.text()).toContain('排车审核')
|
||||
})
|
||||
|
||||
it('validates cumulative receipt totals without submitting invalid input', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'RECEIVING', palletCount: 3, receivedPalletCount: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '登记实收')
|
||||
formModel(wrapper).actualPalletCount = 1; await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('不能少于已实收托数')
|
||||
Object.assign(formModel(wrapper), { actualPalletCount: 3, packageCount: -1 }); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('hides obsolete pallet actions after approval and keeps the standalone order read-only', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PENDING_DISPATCH', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
|
||||
it('requires received pallets and permits partial allocation using their string IDs', async () => {
|
||||
const pallet = { id: '9007199254741007', sequenceNo: 1, palletNo: 'P-1', source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: null }
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', palletCount: 2, receivedPalletCount: 2, pallets: [pallet] }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '分配托盘'); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
formModel(wrapper).palletIds = [pallet.id]
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'PARTIALLY_ALLOCATED', version: 2, allocatedPalletCount: 1, pallets: [{ ...pallet, status: 'ALLOCATED' }] }))
|
||||
await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('部分分配'); expect(wrapper.text()).not.toContain('确认待派车')
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'allocate-pallets', payload: expect.objectContaining({ palletIds: [pallet.id] }) }))
|
||||
})
|
||||
|
||||
it.each(['APPROVE', 'REJECT'] as const)('reviews pallet overage with %s', async decision => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PALLET_OVERAGE_REVIEW', palletCount: 2, receivedPalletCount: 3 }))
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '超托审核')
|
||||
Object.assign(formModel(wrapper), { decision, remark: '已核对超收货物' }); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review-pallet-overage', payload: expect.objectContaining({ decision }) }))
|
||||
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘'); expect(wrapper.text()).not.toContain('超托审核')
|
||||
})
|
||||
|
||||
it('requires cancellation reason and submits only the cancellation status', async () => {
|
||||
@@ -185,7 +162,7 @@ describe('truck review and fulfillment', () => {
|
||||
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
|
||||
const wrapper = mountDetail(false); await flushPromises()
|
||||
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ArrowLeft, Check, MapPin } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
@@ -16,7 +16,7 @@ import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import TruckOrderActions from '../components/TruckOrderActions.vue'
|
||||
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import { truckStatusLabels } from '../truckWorkflow'
|
||||
import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistory } from '../types'
|
||||
import type { LocalTransportDetail, OverseasBusinessHistory } from '../types'
|
||||
|
||||
const route = useRoute(), router = useRouter()
|
||||
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
@@ -24,33 +24,43 @@ const customerId = computed(() => typeof route.query.customerId === 'string' ? r
|
||||
const detail = ref<LocalTransportDetail | null>(null), loading = ref(false), saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let requestSequence = 0
|
||||
|
||||
const flowSteps = ['待审核', '排车审核', '取货', '派送中', '送达']
|
||||
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||
const statusLabel = (value: string | null) => value ? truckStatusLabels[value as keyof typeof truckStatusLabels] || value : '—'
|
||||
const flowIndex = computed(() => {
|
||||
const status = detail.value?.status
|
||||
if (status === 'PENDING_REVIEW') return 0
|
||||
if (status === 'IN_TRANSIT') return 3
|
||||
if (status === 'COMPLETED') return 4
|
||||
if (status === 'CANCELLED' || status === 'REJECTED') return 0
|
||||
return 1
|
||||
})
|
||||
const fields = computed(() => detail.value ? [
|
||||
['客户编号', detail.value.customerId], ['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
|
||||
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
|
||||
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
|
||||
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
|
||||
['申报件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
|
||||
['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
|
||||
['取货地', detail.value.origin], ['取货地址', detail.value.originAddress],
|
||||
['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone],
|
||||
['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress],
|
||||
['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone],
|
||||
['件数', detail.value.packageCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
|
||||
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
|
||||
['已确认托数', detail.value.palletCount], ['累计实收托数', detail.value.receivedPalletCount], ['已分配托数', detail.value.allocatedPalletCount],
|
||||
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
|
||||
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
|
||||
] : [])
|
||||
const palletLabels: Record<string, string> = { EXPECTED: '待实收', PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' }
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘编号', minWidth: 180 },
|
||||
{ key: 'status', label: '状态', width: 130, formatter: (row: LocalTransportPallet) => palletLabels[row.status] || row.status },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '实收件数', width: 120 },
|
||||
{ key: 'receivedAt', label: '实收时间', minWidth: 170, formatter: (row: LocalTransportPallet) => dateTime(row.receivedAt) },
|
||||
]
|
||||
const operations: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', RECEIVE_PALLETS: '登记实收', REVIEW_PALLET_OVERAGE: '超托审核', ALLOCATE_PALLETS: '分配托盘', PREPARE_DISPATCH: '确认待派车', DISPATCH: '确认已派车', START_TRANSIT: '确认发运', COMPLETE: '确认完成', CANCEL: '取消订单' }
|
||||
const operations: Record<string, string> = {
|
||||
CREATE: '创建', UPDATE: '编辑', REVIEW: '审核', TRANSPORT_ASSIGN: '接入排车',
|
||||
RECEIVE_PALLETS: '取货', START_TRANSIT: '派送中', COMPLETE: '送达', LOCAL_TRUCK_DEPART: '取货完成', LOCAL_TRUCK_COMPLETE: '送达', CANCEL: '取消',
|
||||
DISPATCH: '接入排车', PREPARE_DISPATCH: '接入排车',
|
||||
}
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
|
||||
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
|
||||
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
|
||||
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
|
||||
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 }, { key: 'remark', prop: 'remark', label: '审核意见/操作说明', minWidth: 240 },
|
||||
{ key: 'createdAt', label: '时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
|
||||
{ key: 'operationType', label: '操作', minWidth: 120, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
|
||||
{ key: 'fromStatus', label: '原状态', width: 120, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
|
||||
{ key: 'toStatus', label: '新状态', width: 120, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
|
||||
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 120 },
|
||||
{ key: 'remark', prop: 'remark', label: '说明', minWidth: 220 },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
if (saving.value) return
|
||||
const sequence = ++requestSequence
|
||||
@@ -84,11 +94,21 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="loading" :rows="8" animated />
|
||||
<template v-else-if="detail">
|
||||
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
|
||||
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
|
||||
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /><p class="truck-detail-note">取货和送达照片请在排车任务中上传。</p></section>
|
||||
|
||||
<section class="truck-detail-section truck-flow-section" aria-label="运输流程">
|
||||
<div class="truck-section-heading"><h2>运输流程</h2><span class="truck-flow-caption">审核通过后由排车单推进取货、派送和送达</span></div>
|
||||
<ol class="truck-flow">
|
||||
<li v-for="(step, index) in flowSteps" :key="step" :class="{ active: index === flowIndex, done: index < flowIndex || detail.status === 'COMPLETED' }">
|
||||
<span class="truck-flow-marker"><Check v-if="index < flowIndex || detail.status === 'COMPLETED'" :size="14" /><MapPin v-else-if="index === 2 || index === 4" :size="14" /><span v-else>{{ index + 1 }}</span></span>
|
||||
<span class="truck-flow-label">{{ step }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2><span class="truck-flow-caption">基础信息与货量</span></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section>
|
||||
<section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
|
||||
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
|
||||
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
|
||||
<section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2><span class="truck-flow-caption">保留关键节点</span></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
|
||||
</template>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
@@ -99,4 +119,25 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
.truck-detail-header .status-badge { flex-shrink: 0; }
|
||||
.truck-detail-actions { flex-wrap: wrap; }
|
||||
.truck-detail-section { margin-bottom: var(--admin-space-6); }
|
||||
.truck-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.truck-section-heading h2 { margin: 0; font-size: 16px; color: var(--cc-text); }
|
||||
.truck-flow-caption, .truck-detail-note { color: var(--cc-muted); font-size: 12px; }
|
||||
.truck-detail-note { margin: 10px 0 0; }
|
||||
.truck-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.truck-flow li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; color: var(--cc-muted); text-align: center; }
|
||||
.truck-flow li::before { content: ''; position: absolute; top: 15px; left: -50%; width: 100%; height: 1px; background: var(--cc-border); }
|
||||
.truck-flow li:first-child::before { display: none; }
|
||||
.truck-flow li.done::before, .truck-flow li.active::before { background: var(--el-color-primary); }
|
||||
.truck-flow-marker { position: relative; z-index: 1; display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--cc-border); border-radius: 50%; background: var(--cc-surface); color: var(--cc-muted); font-size: 12px; }
|
||||
.truck-flow li.done .truck-flow-marker, .truck-flow li.active .truck-flow-marker { border-color: var(--el-color-primary); color: var(--el-color-primary); }
|
||||
.truck-flow li.active .truck-flow-marker { background: var(--el-color-primary); color: #fff; box-shadow: 0 0 0 4px color-mix(in srgb, var(--el-color-primary) 14%, transparent); }
|
||||
.truck-flow-label { font-size: 12px; white-space: nowrap; }
|
||||
.truck-flow li.active .truck-flow-label { color: var(--el-color-primary); font-weight: 600; }
|
||||
.truck-detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); overflow: hidden; background: var(--cc-border); }
|
||||
.truck-detail-fact { min-width: 0; display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; padding: 11px 14px; background: var(--cc-surface); }
|
||||
.truck-detail-fact dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.truck-detail-fact dd { min-width: 0; margin: 0; color: var(--cc-text); overflow-wrap: anywhere; font-size: 13px; }
|
||||
.truck-detail-fact:last-child { grid-column: span 2; }
|
||||
@media (max-width: 1100px) { .truck-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 760px) { .truck-flow { overflow-x: auto; grid-template-columns: repeat(5, 100px); padding-bottom: 4px; } .truck-detail-grid { grid-template-columns: 1fr; } .truck-detail-fact:last-child { grid-column: auto; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user