Compare commits

..
Author SHA1 Message Date
liuyihe 1c2dd8797a 优化 2026-11-03 11:43:50 +08:00
liuyihe f8408b8076 优化 2026-11-02 17:15:37 +08:00
ouyangxq 7704532e8f 修复财务页面竞态并整理采购与运输展示 2026-10-30 18:50:53 +08:00
liuyihe 19a618c9d2 优化 2026-10-30 18:47:19 +08:00
liuyihe 51d0877a45 优化 2026-10-30 18:15:26 +08:00
78 changed files with 1632 additions and 366 deletions
@@ -60,7 +60,7 @@ function setFee(overrides: Partial<FinanceFeeSourceDetail> = {}) {
vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 }) vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 })
vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current) vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current)
} }
async function mountPage(kind: 'arap' | 'statements') { async function mountPage(kind: 'arap' | 'statements', cachedWorkspace = false) {
const router = createRouter({ history: createMemoryHistory(), routes: [ const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView }, { path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView }, { path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
@@ -69,7 +69,10 @@ async function mountPage(kind: 'arap' | 'statements') {
] }) ] })
await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements') await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements')
await router.isReady() await router.isReady()
const wrapper = mount(kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) const component = cachedWorkspace
? { template: '<router-view v-slot="{ Component, route }"><keep-alive><component :is="Component" :key="route.name" /></keep-alive></router-view>' }
: kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView
const wrapper = mount(component, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises() await flushPromises()
return { wrapper, router } return { wrapper, router }
} }
@@ -99,17 +102,6 @@ async function fillCreation(dialog: VueWrapper, selected = false) {
await flushPromises() await flushPromises()
await dialog.get('textarea').setValue('测试备注') await dialog.get('textarea').setValue('测试备注')
} }
async function openAdjustment() {
const { wrapper, router } = await mountPage('statements')
await rowClick(wrapper, '调整')
return { wrapper, router, dialog: activeDialog(wrapper) }
}
async function fillAdjustment(dialog: VueWrapper) {
await dialog.get('textarea[placeholder="请输入整体调整原因"]').setValue(' 整体调整 ')
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 88.12)
await dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').setValue(' 行说明 ')
}
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks() vi.resetAllMocks()
@@ -233,6 +225,30 @@ describe('fee source and statement native dialogs', () => {
await click(dialog, '确定') await click(dialog, '确定')
expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] })) expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] }))
}) })
it.each([false, true])('opens selected statement generation directly from arap with a cached statement page=%s', async cached => {
setFee({ reviewStatus: 'APPROVED' })
const { wrapper, router } = await mountPage(cached ? 'statements' : 'arap', true)
const cachedPage = cached ? wrapper.getComponent(FinanceCustomerStatementsView).element : null
if (cached) {
await click(wrapper, '生成对账单')
await click(activeDialog(wrapper), '取消')
await router.push({ name: 'finance-customer-arap' })
await flushPromises()
}
await wrapper.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
await click(wrapper, '生成所选对账单')
const page = wrapper.getComponent(FinanceCustomerStatementsView)
if (cachedPage) expect(page.element).toBe(cachedPage)
const dialog = activeDialog(wrapper)
expect(dialog.props('title')).toBe('生成客户对账单')
expect(dialog.getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('SELECTED')
expect(dialog.findAllComponents({ name: 'ElSelect' })[0]!.props('modelValue')).toBe(String(customer.id))
expect(dialog.findAllComponents({ name: 'ElDatePicker' }).map(item => item.props('modelValue'))).toEqual(['2026-10-01', '2026-10-01'])
expect(dialog.get('.selected-source-summary').text()).toContain('已选 1 笔,合计 100.00 CNY')
expect(router.currentRoute.value.query).toEqual({})
expect(api.createReconciliationStatement).not.toHaveBeenCalled()
})
it('preserves customer and period validation before creating', async () => { it('preserves customer and period validation before creating', async () => {
const { dialog } = await openCreate() const { dialog } = await openCreate()
await click(dialog, '确定') await click(dialog, '确定')
@@ -271,48 +287,14 @@ describe('fee source and statement native dialogs', () => {
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注') expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注')
expect(dialog.props('modelValue')).toBe(true) expect(dialog.props('modelValue')).toBe(true)
}) })
it('keeps all adjustment rows in the main dialog flow with plain statement facts', async () => { it.each(['APPROVED', 'ISSUED', 'PAID'])('does not offer adjustment or void after statement approval: %s', async status => {
vi.mocked(api.getReconciliationStatement).mockResolvedValue({ ...detail, lines: Array.from({ length: 13 }, (_, index) => ({ ...detail.lines[0]!, statementLineId: 'line-' + index, feeItemName: '费用' + (index + 1) })) }) vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }])
const { dialog } = await openAdjustment() const { wrapper } = await mountPage('statements')
expect(dialog.props('width')).toContain('900px') const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(dialog.props('title')).toContain(statement.billNo) expect(labels).toContain('详情')
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false) expect(labels).not.toContain('调整')
expect(dialog.getComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined() expect(labels).not.toContain('作废')
expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13)
expect(dialog.get('.el-table__body-wrapper').text()).toContain('费用13')
})
it('saves only checked lines with full IDs and the freshly loaded version', async () => {
const { dialog } = await openAdjustment()
await fillAdjustment(dialog)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await click(dialog, '确定')
expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action: 'ADJUST', reason: '整体调整', expectedVersion: 8, adjustmentLines: [{ statementLineId: '9223372036854775011', adjustedAmount: 88.12, reason: '行说明' }] })
})
it('requires an overall reason, selection and each selected line reason', async () => {
const { dialog } = await openAdjustment()
await click(dialog, '确定')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled() expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
await dialog.get('textarea').setValue('整体调整')
await click(dialog, '确定')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
await click(dialog, '确定')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
})
it('locks adjustment rows and retains their state with visible failure diagnostics', async () => {
const pending = deferred<FinanceReconciliationDetail>()
vi.mocked(api.actionReconciliationStatement).mockReturnValue(pending.promise)
const { dialog } = await openAdjustment()
await fillAdjustment(dialog)
await click(dialog, '确定')
expect(dialog.props('showClose')).toBe(false)
expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined()
pending.reject(failure())
await flushPromises()
expect(dialog.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝操作', requestId: 'finance-dialog-test' })
expect((dialog.get('.el-table__body-wrapper input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true)
expect((dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').element as HTMLInputElement).value).toBe(' 行说明 ')
expect(dialog.props('modelValue')).toBe(true)
}) })
it.each([ it.each([
['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'], ['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'],
@@ -576,7 +576,7 @@ onBeforeUnmount(clearVoucherPreview)
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr> <tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section> </tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" /> <el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div> <el-alert v-if="refundError" :title="refundError.message" type="error" :closable="false" />
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody> <section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr> <tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr> <tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
@@ -1,8 +1,9 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { defineComponent, h, KeepAlive, type VNode } from 'vue'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -11,7 +12,7 @@ import { ApiResponseError } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam' import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance' import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types' import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue' import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
@@ -53,7 +54,7 @@ const eligibleFeeSource: FinanceFeeSourceSummary = {
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15', sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15',
} }
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') { async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements', cached = false) {
const pinia = createPinia(); setActivePinia(pinia) const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin() useAccessPreviewStore().initializeAfterLogin()
@@ -63,7 +64,10 @@ async function mountPage(permissions = ['platform:finance:customer-statement:lis
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } }, { path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
] }) ] })
await router.push(initialPath); await router.isReady() await router.push(initialPath); await router.isReady()
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) const cachedPage = defineComponent({ setup: () => () => h(RouterView, {}, {
default: ({ Component }: { Component: VNode }) => h(KeepAlive, {}, { default: () => Component }),
}) })
const wrapper = mount(cached ? cachedPage : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises() await flushPromises()
return { wrapper, router } return { wrapper, router }
} }
@@ -72,6 +76,29 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals()) afterAll(() => vi.unstubAllGlobals())
describe('FinanceCustomerStatementsView', () => { describe('FinanceCustomerStatementsView', () => {
it('omits reopening voided and rejected statements and ignores stale reopen commands', async () => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status: 'VOIDED' }, { ...statement, id: '502', status: 'REJECTED' }])
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:void'])
const page = wrapper.vm as unknown as { handleStatementAction(command: string, row: FinanceStatementReadModel): void }
expect(wrapper.text()).not.toContain('重开')
page.handleStatementAction('reopen', { ...statement, status: 'VOIDED' })
await flushPromises()
expect(actionReconciliationStatement).not.toHaveBeenCalled()
})
it('keeps statement rows visible and shows only the business error after a failed action', async () => {
const draft = { ...statement, status: 'DRAFT' }
vi.mocked(listFinanceStatements).mockResolvedValue([draft])
vi.mocked(actionReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('费用已生成新的对账单', 'FIN_409', 409, null, 'REQUEST-FAIL', 'TRACE-FAIL'))
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create'])
const page = wrapper.vm as unknown as { handleStatementAction(command: string, row: FinanceStatementReadModel): void }
page.handleStatementAction('submit', draft)
await flushPromises()
expect(wrapper.text()).toContain('费用已生成新的对账单')
expect(wrapper.text()).toContain('REC-501')
expect(wrapper.text()).not.toMatch(/REQUEST-FAIL|TRACE-FAIL|客户对账单加载失败/)
})
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage()) vi.stubGlobal('localStorage', memoryStorage())
@@ -121,6 +148,25 @@ describe('FinanceCustomerStatementsView', () => {
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true) expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
}) })
it('shows overseas procurement as the source type in statement detail fee lines', async () => {
vi.mocked(getReconciliationStatement).mockResolvedValue({
...detail,
lines: ['开箱费', '提柜-额外费用'].map((feeItemName, index) => ({
...detail.lines[0]!, statementLineId: `procurement-line-${index}`, sourceType: 'OVERSEAS_PROCUREMENT_FEE',
sourceNo: 'CG-TEST-001', feeItemName,
})),
})
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501')
const table = wrapper.get('[aria-label="客户对账单账单明细"]')
const rows = table.findAll('.el-table__body tbody tr')
expect(rows).toHaveLength(2)
expect(rows.map(row => row.findAll('td')[1]!.text())).toEqual(['海外采购费用', '海外采购费用'])
expect(table.text()).toContain('CG-TEST-001')
expect(table.text()).toContain('开箱费')
expect(table.text()).toContain('提柜-额外费用')
expect(table.text()).not.toMatch(/未知来源类型|OVERSEAS_PROCUREMENT_FEE/)
})
it('collapses advanced criteria without losing values and resets hidden filters', async () => { it('collapses advanced criteria without losing values and resets hidden filters', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> } const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
@@ -160,31 +206,33 @@ describe('FinanceCustomerStatementsView', () => {
expect(source).toContain('<RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)"') expect(source).toContain('<RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)"')
}) })
it('keeps three available row actions visible with primary and danger semantics', async () => { it.each(['APPROVED', 'ISSUED', 'PAID'])('hides adjustment and void for reviewed statement %s even with write permissions', async status => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status, customerConfirmationStatus: 'PENDING_CONFIRM' }])
const { wrapper } = await mountPage([ const { wrapper } = await mountPage([
'platform:finance:customer-statement:list', 'platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void',
'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void',
]) ])
const actionButtons = [...document.querySelectorAll('button')].filter((button) => ['详情', '调整', '作废'].includes(button.textContent?.trim() || '')) const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
const detailButton = actionButtons.find((button) => button.textContent?.trim() === '详情') expect(labels).toContain('详情')
const adjustButton = actionButtons.find((button) => button.textContent?.trim() === '调整') expect(labels).not.toContain('调整')
const voidButton = actionButtons.find((button) => button.textContent?.trim() === '作废') expect(labels).not.toContain('作废')
expect(new Set(actionButtons.map((button) => button.textContent?.trim()))).toEqual(new Set(['详情', '调整', '作废']))
expect(detailButton?.classList).toContain('el-button--small')
expect(detailButton?.classList).toContain('density-action')
expect(detailButton?.getAttribute('data-tone')).toBe('neutral')
expect(adjustButton?.classList).toContain('density-action')
expect(voidButton?.getAttribute('data-tone')).toBe('danger')
const vm = wrapper.vm as unknown as { actionBusy: boolean; adjustBusy: boolean }
vm.actionBusy = true
vm.adjustBusy = true
await flushPromises()
expect(adjustButton?.hasAttribute('disabled')).toBe(true)
expect(voidButton?.hasAttribute('disabled')).toBe(true)
}) })
it.each(['DRAFT', 'PENDING_REVIEW'])('keeps void before approval with the independent permission: %s', async status => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status }])
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:void'])
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text().trim() === '作废')!
expect(button.attributes('data-tone')).toBe('danger')
const vm = wrapper.vm as unknown as { actionBusy: boolean }
vm.actionBusy = true
await flushPromises()
expect(button.attributes('disabled')).toBeDefined()
})
it('does not offer void without permission even before approval', async () => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status: 'DRAFT' }])
const { wrapper } = await mountPage()
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).not.toContain('作废')
})
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => { it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> } const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
@@ -224,10 +272,6 @@ describe('FinanceCustomerStatementsView', () => {
expect(text).toContain('2026-08-14 03:27') expect(text).toContain('2026-08-14 03:27')
expect(text).not.toContain('2026-08-14T03:27:34Z') expect(text).not.toContain('2026-08-14T03:27:34Z')
await (wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void> }).openAdjustment(internalStatement)
await flushPromises()
expect(document.body.textContent).toContain('调整客户对账单')
expect(document.body.textContent).toContain('运费')
}) })
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => { it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
@@ -373,6 +417,67 @@ describe('FinanceCustomerStatementsView', () => {
expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } }) expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
}) })
const selectedCreatePermissions = ['platform:finance:customer-statement:list', 'platform:finance:customer-arap:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
const selectedCreatePath = '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15'
type SourcePage = Awaited<ReturnType<typeof listFinanceFeeSources>>
it.each(['success', 'failure'] as const)('ignores a late %s after leaving a reactivated statement page', async (outcome) => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as {
createOpen: boolean; createSourcesError: unknown; createSourcesLoading: boolean
}
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void, reject!: (reason: Error) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise((yes, no) => { resolve = yes; reject = no }))
await router.push(selectedCreatePath); await flushPromises()
expect(listFinanceFeeSources).toHaveBeenCalledTimes(1)
const replace = vi.spyOn(router, 'replace')
await router.push('/downloads'); await flushPromises()
if (outcome === 'success') resolve({ records: [eligibleFeeSource], total: 2, pages: 2 })
else reject(new Error('旧候选查询失败'))
await flushPromises()
expect(router.currentRoute.value.name).toBe('download-center')
expect(replace).not.toHaveBeenCalled()
expect(pageVm.createOpen).toBe(false)
expect(pageVm.createSourcesError).toBeNull()
expect(pageVm.createSourcesLoading).toBe(false)
expect(listFinanceFeeSources).toHaveBeenCalledTimes(1)
})
it('opens the current selection after reactivation without letting an older response reopen it', async () => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as {
createOpen: boolean; createForm: { feeSourceIds: string[] }; createSourceOptions: FinanceFeeSourceSummary[]
}
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes }))
await router.push(selectedCreatePath); await flushPromises()
await router.push('/downloads'); await flushPromises()
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [{ ...eligibleFeeSource, feeSourceId: '43' }], total: 1, pages: 1 })
await router.push(selectedCreatePath.replace('feeSourceIds=42', 'feeSourceIds=43')); await flushPromises()
expect(pageVm.createOpen).toBe(true)
expect(pageVm.createForm.feeSourceIds).toEqual(['43'])
const replace = vi.spyOn(router, 'replace')
resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises()
expect(replace).not.toHaveBeenCalled()
expect(pageVm.createForm.feeSourceIds).toEqual(['43'])
expect(pageVm.createSourceOptions.map(item => item.feeSourceId)).toEqual(['43'])
})
it('does not open or navigate after the statement page is unmounted during candidate loading', async () => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes }))
await router.push(selectedCreatePath); await flushPromises()
const replace = vi.spyOn(router, 'replace')
wrapper.unmount()
resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises()
expect(replace).not.toHaveBeenCalled()
expect(document.body.textContent).not.toContain('对账单生成设置')
})
it('keeps the generation dialog open with a customer-friendly message when no billable fees exist', async () => { it('keeps the generation dialog open with a customer-friendly message when no billable fees exist', async () => {
const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list'] const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
const { wrapper } = await mountPage(permissions) const { wrapper } = await mountPage(permissions)
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import '../financeDensity.css' import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue' import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
@@ -29,15 +29,6 @@ import { actionReconciliationStatement, createReconciliationStatement, getReconc
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status' import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types' import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
statementLineId: string
feeItemName: string
originalAmount: number
adjustedAmount: number
reason: string
}
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & { type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
periodStart: string periodStart: string
periodEnd: string periodEnd: string
@@ -61,6 +52,7 @@ const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const loading = ref(false) const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null) const error = ref<ApiErrorDetails | null>(null)
const actionError = ref('')
const customerError = ref<ApiErrorDetails | null>(null) const customerError = ref<ApiErrorDetails | null>(null)
const selectedId = ref('') const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null) const detail = ref<FinanceReconciliationDetail | null>(null)
@@ -85,12 +77,9 @@ const createForm = ref({
remark: '', remark: '',
}) })
let createSourceLoadSequence = 0 let createSourceLoadSequence = 0
const adjustOpen = ref(false) let selectedCreateGeneration = 0
const adjustBusy = ref(false) let pageActive = true
const adjustError = ref<ApiErrorDetails | null>(null) let initialLoadCompleted = false
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
const canView = computed(() => access.can('platform:finance:customer-statement:list')) const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list')) const canListCustomers = computed(() => access.can('platform:customer:list'))
@@ -100,7 +89,6 @@ const canCreate = computed(() => access.can('platform:finance:reconciliation:cre
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE')) const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review')) const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue')) const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
const canVoid = computed(() => access.can('platform:finance:reconciliation:void')) const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list')) const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value) const canGenerate = computed(() => canCreate.value && canListCustomers.value)
@@ -276,6 +264,7 @@ async function load(model?: QueryModel) {
detail.value = null detail.value = null
detailCache.clear() detailCache.clear()
error.value = null error.value = null
actionError.value = ''
loading.value = true loading.value = true
try { try {
const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined) const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined)
@@ -407,6 +396,7 @@ function defaultCreateForm() {
function openCreate() { function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单') if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
invalidateCreateLoads()
createForm.value = defaultCreateForm() createForm.value = defaultCreateForm()
createSourceOptions.value = [] createSourceOptions.value = []
createSourcesError.value = null createSourcesError.value = null
@@ -440,6 +430,7 @@ async function loadEligibleCreateSources(preferredIds: string[] = createForm.val
businessDateFrom: createForm.value.periodStart, businessDateFrom: createForm.value.periodStart,
businessDateTo: createForm.value.periodEnd, businessDateTo: createForm.value.periodEnd,
}) })
if (sequence !== createSourceLoadSequence) return
records.push(...result.records) records.push(...result.records)
pages = Math.max(1, result.pages) pages = Math.max(1, result.pages)
currentPage += 1 currentPage += 1
@@ -467,7 +458,9 @@ function queryText(value: unknown) {
} }
async function openSelectedCreateFromRoute() { async function openSelectedCreateFromRoute() {
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return if (!pageActive || route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
const generation = ++selectedCreateGeneration
const sourcePath = route.fullPath
if (!canGenerate.value || !canSelectSources.value) { if (!canGenerate.value || !canSelectSources.value) {
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单') ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
return return
@@ -484,8 +477,12 @@ async function openSelectedCreateFromRoute() {
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用') ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
return return
} }
createError.value = null
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' } createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
await loadEligibleCreateSources(feeSourceIds) await loadEligibleCreateSources(feeSourceIds)
// 带入候选只属于当前激活页面及本次路由,旧响应不能重新打开弹框或覆盖用户导航。
if (!pageActive || generation !== selectedCreateGeneration || route.fullPath !== sourcePath
|| !canGenerate.value || !canSelectSources.value) return
createOpen.value = true createOpen.value = true
await router.replace({ name: 'finance-customer-statements' }) await router.replace({ name: 'finance-customer-statements' })
} }
@@ -521,32 +518,21 @@ async function createStatement() {
} }
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) { async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
const title = ({ REJECT: '驳回对账单', VOID: '作废对账单', REOPEN: '重开对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作' const title = ({ REJECT: '驳回对账单', VOID: '作废对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作'
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName) const reason = ['REJECT', 'VOID'].includes(actionName)
? await ElMessageBox.prompt('请输入操作原因', `${title} · ${row.billNo || '—'}`, { customClass: 'admin-density-confirm admin-workbench', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null) ? await ElMessageBox.prompt('请输入操作原因', `${title} · ${row.billNo || '—'}`, { customClass: 'admin-density-confirm admin-workbench', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
: undefined : undefined
if (reason === null) return if (reason === null) return
actionError.value = ''
actionBusy.value = true actionBusy.value = true
try { try {
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) }) await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
ElMessage.success('对账单状态已更新') ElMessage.success('对账单状态已更新')
await load(appliedFilters.value) await load(appliedFilters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') } } catch (cause) { actionError.value = getApiErrorMessage(cause, '对账单操作失败') }
finally { actionBusy.value = false } finally { actionBusy.value = false }
} }
async function openAdjustment(row: FinanceStatementReadModel) {
adjustError.value = null
adjustBusy.value = true
try {
const current = await fetchDetail(row.id, true)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
adjustOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
function statementActions(row: FinanceStatementReadModel): RowActionItem[] { function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
return [ return [
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value }, { key: 'detail', label: '详情', priority: 300, disabled: loading.value },
@@ -554,52 +540,54 @@ function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value }, { key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value }, { key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value }, { key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
{ key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value }, { key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
] ]
} }
function handleStatementAction(command: string, row: FinanceStatementReadModel) { function handleStatementAction(command: string, row: FinanceStatementReadModel) {
if (!statementActions(row).some(item => item.key === command && item.visible !== false)) return
if (command === 'detail') void openDetail(row) if (command === 'detail') void openDetail(row)
else if (command === 'adjust') void openAdjustment(row)
else { else {
const actions: Record<string, ReconciliationActionPayload['action']> = { const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID', submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', void: 'VOID',
} }
const action = actions[command] const action = actions[command]
if (action) void performAction(row, action) if (action) void performAction(row, action)
} }
} }
async function submitAdjustment() { function invalidateCreateLoads() {
if (adjustBusy.value) return selectedCreateGeneration += 1
const statement = adjustStatement.value createSourceLoadSequence += 1
const selected = adjustLines.value.filter((line) => line.selected) createSourcesLoading.value = false
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细') }
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
adjustBusy.value = true function deactivateCreateLoads() {
adjustError.value = null pageActive = false
try { invalidateCreateLoads()
await actionReconciliationStatement(statement.id, {
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load(appliedFilters.value)
await openDetail(statement)
} catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value }
finally { adjustBusy.value = false }
} }
onMounted(async () => { onMounted(async () => {
if (canListCustomers.value) await loadCustomers() await (canListCustomers.value ? loadCustomers() : undefined)
initialLoadCompleted = true
await openSelectedCreateFromRoute() await openSelectedCreateFromRoute()
}) })
onActivated(() => {
pageActive = true
if (initialLoadCompleted) void openSelectedCreateFromRoute()
})
onDeactivated(deactivateCreateLoads)
onBeforeUnmount(deactivateCreateLoads)
watch(() => route.name, (name) => {
if (name !== 'finance-customer-statements') invalidateCreateLoads()
}, { flush: 'sync' })
watch( watch(
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd], () => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
([open, mode]) => { ([open, mode], [wasOpen]) => {
if (!open || mode !== 'SELECTED') return if (!open) {
if (wasOpen === true) invalidateCreateLoads()
return
}
if (mode !== 'SELECTED') return
void loadEligibleCreateSources() void loadEligibleCreateSources()
}, },
) )
@@ -700,7 +688,7 @@ watch(detailRouteId, (statementId) => {
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail"> <section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-start><h1>客户对账单</h1></template> <template #toolbar-start><h1>客户对账单</h1></template>
<template #filters><QueryToolbar id="statement-query-filters" v-model="filters" ariaLabel="客户对账单筛选" :fields="visibleFields" :available-fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" :search-on-reset="false" @search="load" @reset="resetFilters"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="statement-query-filters" @click="showAdvancedFilters = !showAdvancedFilters"><SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}</el-button></template></QueryToolbar></template> <template #filters><QueryToolbar id="statement-query-filters" v-model="filters" ariaLabel="客户对账单筛选" :fields="visibleFields" :available-fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" :search-on-reset="false" @search="load" @reset="resetFilters"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="statement-query-filters" @click="showAdvancedFilters = !showAdvancedFilters"><SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}</el-button></template></QueryToolbar></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template> <template #notices><el-alert v-if="actionError" :title="actionError" type="error" :closable="false" role="alert" /><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
<template #toolbar-actions> <template #toolbar-actions>
@@ -744,16 +732,7 @@ watch(detailRouteId, (statementId) => {
</section> </section>
</AppDialog> </AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustOpen" :title="`调整客户对账单 · ${adjustStatement?.billNo || '—'}`" width="min(900px, 96vw)" fullscreenable :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<OperationErrorToast :details="adjustError" />
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="adjustBusy">
<section v-if="adjustStatement" class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
<div class="workbench-evidence"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table></div>
<div class="workbench-table"><el-table :data="adjustLines" size="small" border stripe row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
</div></section>
</el-form>
</AppDialog>
</AdminPage> </AdminPage>
</template> </template>
@@ -761,10 +740,8 @@ watch(detailRouteId, (statementId) => {
.finance-statements-page { min-width: 0; } .finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; } :deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; } .selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; }
.statement-create-table, .statement-dialog-facts { table-layout: fixed; } .statement-create-table { table-layout: fixed; }
.statement-create-table td:not([colspan]) { width: 50%; } .statement-create-table td:not([colspan]) { width: 50%; }
.statement-dialog-facts th { width: var(--admin-label-width); }
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
.statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; } .statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); } .statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); } .statement-outstanding { color: var(--el-color-warning-dark-2); }
@@ -90,6 +90,7 @@ const receiptValidationError = ref<string | null>(null)
const receiptError = ref<ApiErrorDetails | null>(null) const receiptError = ref<ApiErrorDetails | null>(null)
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' }) const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
const actionBusy = ref(false) const actionBusy = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
const exportSubmitting = ref(false) const exportSubmitting = ref(false)
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create') const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
@@ -344,6 +345,7 @@ watch(() => createForm.value.supplierId, () => {
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId).catch(reportCreateSourceError) if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId).catch(reportCreateSourceError)
}) })
// 待客户确认的对账单生成应收前,提醒操作员确认后再继续。
async function submitCreate() { async function submitCreate() {
if (createBusy.value) return if (createBusy.value) return
const customerId = String(createForm.value.customerId || '') const customerId = String(createForm.value.customerId || '')
@@ -353,6 +355,14 @@ async function submitCreate() {
createBusy.value = true createBusy.value = true
error.value = null error.value = null
try { try {
const statement = statementOptions.value.find((item) => item.id === createForm.value.reconciliationStatementId)
if (props.kind === 'receivable' && statement?.customerConfirmationStatus === 'PENDING_CONFIRMATION') {
const confirmed = await ElMessageBox.confirm('客户暂未确认!', '生成应收结算单', {
customClass: 'admin-density-confirm admin-workbench', type: 'warning',
confirmButtonText: '继续生成', cancelButtonText: '取消',
}).then(() => true).catch(() => false)
if (!confirmed) return
}
if (props.kind === 'receivable') await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' }) if (props.kind === 'receivable') await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
else await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' }) else await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
createOpen.value = false createOpen.value = false
@@ -459,12 +469,19 @@ async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
}).then((result) => result.value.trim()).catch(() => null) }).then((result) => result.value.trim()).catch(() => null)
if (reason === null) return if (reason === null) return
actionBusy.value = true actionBusy.value = true
actionError.value = null
try { try {
await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version }) await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version })
ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开') ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开')
if (detailMode.value) await loadDetail(row.id) if (detailMode.value) await loadDetail(row.id)
else await load() else await load()
} catch (cause) { error.value = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`) } } catch (cause) {
const details = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`)
if (props.kind === 'receivable' && action === 'REOPEN' && details.message === '原对账单已生成其他有效应收结算单') {
details.message = '已有新的应收单,无法重开'
}
actionError.value = details
}
finally { actionBusy.value = false } finally { actionBusy.value = false }
} }
@@ -524,6 +541,7 @@ onMounted(() => {
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" /> <OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
<OperationErrorToast :message="receiptValidationError" /> <OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" /> <OperationErrorToast :details="receiptError" />
<OperationErrorToast :details="actionError" />
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" /> <el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface" :aria-label="`${title}详情`"> <DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface" :aria-label="`${title}详情`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" aria-label="返回列表" @click="backToList"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.settlementNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div><div class="finance-detail-actions"> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" aria-label="返回列表" @click="backToList"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.settlementNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div><div class="finance-detail-actions">
@@ -674,7 +692,7 @@ onMounted(() => {
</template> </template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate"> <AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3> <section class="density-dialog-section workbench-evidence" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
<el-form class="density-form" label-position="left" label-width="120px" :disabled="createBusy"><table class="density-table density-table--editable"><tbody> <el-form class="density-form" label-position="left" label-width="120px" :disabled="createBusy"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr> <tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr> <tr><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr>
@@ -95,7 +95,7 @@ describe('finance G3/G4 page operations', () => {
vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 }) vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 })
}) })
it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => { it('keeps issued statement history readable and ignores stale adjustment or void commands', async () => {
const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust']) const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
const router = createRouter({ history: createMemoryHistory(), routes: [ const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView }, { path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
@@ -105,26 +105,17 @@ describe('finance G3/G4 page operations', () => {
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises() await flushPromises()
const vm = wrapper.vm as unknown as { const vm = wrapper.vm as unknown as {
openAdjustment(row: FinanceStatementReadModel): Promise<void> openDetail(row: FinanceStatementReadModel): Promise<void>
submitAdjustment(): Promise<void> handleStatementAction(command: string, row: FinanceStatementReadModel): void
adjustReason: string
adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }>
} }
vm.handleStatementAction('adjust', statement)
await vm.openAdjustment(statement) vm.handleStatementAction('void', statement)
vm.adjustReason = '账期复核调整' await flushPromises()
vm.adjustLines[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正' expect(actionReconciliationStatement).not.toHaveBeenCalled()
vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正' await vm.openDetail(statement)
await vm.submitAdjustment() await flushPromises()
expect(getReconciliationStatement).toHaveBeenCalledWith('501') expect(getReconciliationStatement).toHaveBeenCalledWith('501')
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { expect(wrapper.text()).toContain(statement.billNo)
action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3,
adjustmentLines: [
{ statementLineId: '601', adjustedAmount: 90, reason: '运费更正' },
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
],
})
}, 15_000) }, 15_000)
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => { it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
@@ -101,16 +101,14 @@ describe('Finance G3/G4 views', () => {
expect(actionReconciliationStatement).not.toHaveBeenCalled() expect(actionReconciliationStatement).not.toHaveBeenCalled()
}) })
it('submits every selected adjustment line with the detail version', async () => { it('keeps an issued statement read-only with legacy adjustment permission', async () => {
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust']) const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void'])
const vm = wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void>; submitAdjustment(): Promise<void>; adjustReason: string; adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> } const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
await vm.openAdjustment(statement) expect(labels).toContain('详情')
vm.adjustReason = '月末核对更正' expect(labels).not.toContain('调整')
vm.adjustLines.forEach((line, index) => { line.selected = true; line.adjustedAmount = index === 0 ? 90 : 180; line.reason = `明细${index + 1}更正` }) expect(labels).not.toContain('作废')
await vm.submitAdjustment() expect(actionReconciliationStatement).not.toHaveBeenCalled()
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { action: 'ADJUST', reason: '月末核对更正', expectedVersion: 3, adjustmentLines: [{ statementLineId: '601', adjustedAmount: 90, reason: '明细1更正' }, { statementLineId: '602', adjustedAmount: 180, reason: '明细2更正' }] })
}) })
it('creates a payable settlement and registers a voucher-backed payment', async () => { it('creates a payable settlement and registers a voucher-backed payment', async () => {
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' }) const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' })
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { customerId: string; supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } } const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { customerId: string; supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } }
@@ -1,8 +1,51 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam'
import { getReceivableStatistics } from '../api/finance'
import type { ReceivableStatisticsResult } from '../types'
import FinanceReceivableStatisticsView from './FinanceReceivableStatisticsView.vue'
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({ getReceivableStatistics: vi.fn() }))
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8') const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8')
const statistics: ReceivableStatisticsResult = {
statisticsMonth: '2026-10', currency: 'CNY', totalReceivable: '2600', totalReceived: '2550', totalOutstanding: '50',
totalCustomers: 2, page: 1, pageSize: 20,
records: [
{ customerId: '10001', customerName: '客户A', currency: 'CNY', totalReceivable: '2400', totalReceived: '2400', totalOutstanding: '0' },
{ customerId: '10002', customerName: '客户 10002', currency: 'CNY', totalReceivable: '200', totalReceived: '150', totalOutstanding: '50' },
],
}
async function mountPage() {
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/receivable-statistics', name: 'finance-receivable-statistics', component: FinanceReceivableStatisticsView },
{ path: '/other', name: 'other-page', component: { template: '<div>其他页面</div>' } },
] })
await router.push('/finance/receivable-statistics')
await router.isReady()
const wrapper = mount(FinanceReceivableStatisticsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return { wrapper, router }
}
enableAutoUnmount(afterEach)
beforeEach(() => {
can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10002', tenantName: '客户B', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getReceivableStatistics).mockResolvedValue(statistics)
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('receivable statistics page', () => { describe('receivable statistics page', () => {
it('renders only the approved CNY derived metrics and current-filter actions', () => { it('renders only the approved CNY derived metrics and current-filter actions', () => {
@@ -25,4 +68,47 @@ describe('receivable statistics page', () => {
expect(page).toContain('当前月份暂无应收数据') expect(page).toContain('当前月份暂无应收数据')
expect(page).toContain(':loading="loading"') expect(page).toContain(':loading="loading"')
}) })
it('prints an isolated report with all five columns, the loaded month and server totals', async () => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const { wrapper } = await mountPage()
const report = document.body.querySelector(':scope > .receivable-statistics-print')!
expect(report).not.toBeNull()
expect(report.querySelector('h1')?.textContent).toBe('应收款统计表')
expect(report.textContent).toContain('统计月份:2026-10')
expect([...report.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual(['应收单位', '币种', '应收总额(CNY)', '已收合计(CNY)', '余额未收(CNY)'])
expect([...report.querySelectorAll('tbody tr')].map(row => [...row.querySelectorAll('td')].map(cell => cell.textContent))).toEqual([
['客户A', 'CNY', '2,400.00', '2,400.00', '0.00'],
['客户B', 'CNY', '200.00', '150.00', '50.00'],
])
expect([...report.querySelectorAll('tfoot td')].map(cell => cell.textContent)).toEqual(['2,600.00', '2,550.00', '50.00'])
expect(report.querySelector('button, input, .finance-metric-grid, .data-table__pagination')).toBeNull()
expect(page).toContain(':global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }')
expect(page).toContain('width: 100%; border-collapse: collapse; table-layout: fixed;')
await wrapper.findAll('button').find(button => button.text() === '打印')!.trigger('click')
expect(print).toHaveBeenCalledOnce()
})
it('removes the print report when a cached statistics page is no longer the active route', async () => {
const { router } = await mountPage()
expect(document.querySelector('.receivable-statistics-print')).not.toBeNull()
await router.push('/other')
await flushPromises()
expect(document.querySelector('.receivable-statistics-print')).toBeNull()
await router.push('/finance/receivable-statistics')
await flushPromises()
expect(document.querySelector('.receivable-statistics-print')).not.toBeNull()
})
it.each(['loading', 'error', 'no-permission'])('does not print unavailable statistics: %s', async state => {
if (state === 'loading') vi.mocked(getReceivableStatistics).mockReturnValueOnce(new Promise(() => {}))
if (state === 'error') vi.mocked(getReceivableStatistics).mockRejectedValueOnce(new Error('不可用'))
if (state === 'no-permission') can.mockReturnValue(false)
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { printCurrent(): void }
expect(document.querySelector('.receivable-statistics-print')).toBeNull()
vm.printCurrent()
expect(print).not.toHaveBeenCalled()
})
}) })
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue' import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -21,6 +21,7 @@ import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../typ
const now = new Date() const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter() const router = useRouter()
const month = ref(defaultMonth) const month = ref(defaultMonth)
const customerId = ref('') const customerId = ref('')
@@ -33,6 +34,7 @@ const error = ref<ApiErrorDetails | null>(null)
const result = ref<ReceivableStatisticsResult | null>(null) const result = ref<ReceivableStatisticsResult | null>(null)
const canView = computed(() => access.can('platform:finance:receivable-statistics:list')) const canView = computed(() => access.can('platform:finance:receivable-statistics:list'))
const canPrint = computed(() => route.name === 'finance-receivable-statistics' && canView.value && !loading.value && !error.value && Boolean(result.value))
const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list')) const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list'))
const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? []) const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? [])
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName]))) const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName])))
@@ -65,7 +67,7 @@ async function loadCustomers() {
function search() { page.value = 1; void load() } function search() { page.value = 1; void load() }
function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() } function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() }
function printCurrent() { window.print() } function printCurrent() { if (canPrint.value) window.print() }
function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` } function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` }
function displayCustomerName(value: unknown) { function displayCustomerName(value: unknown) {
const row = value as ReceivableStatisticsRow const row = value as ReceivableStatisticsRow
@@ -116,12 +118,30 @@ onMounted(() => { void loadCustomers(); void load() })
</section></template> </section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template> <template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template> <template #toolbar-actions><div class="report-actions"><el-button :disabled="!canPrint" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template> <template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template> <template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
</DataTable></div> </DataTable></div>
</template> </template>
</AdminPage> </AdminPage>
<Teleport to="body">
<section v-if="canPrint && result" class="receivable-statistics-print" aria-label="应收款统计打印报表">
<header>
<h1>应收款统计表</h1>
<p>统计月份:{{ result.statisticsMonth }} · 币种:CNY</p>
</header>
<table>
<colgroup><col class="print-customer-column" /><col class="print-currency-column" /><col /><col /><col /></colgroup>
<thead><tr><th scope="col">应收单位</th><th scope="col">币种</th><th scope="col">应收总额(CNY)</th><th scope="col">已收合计(CNY)</th><th scope="col">余额未收(CNY)</th></tr></thead>
<tbody>
<tr v-for="row in rows" :key="statisticsRowKey(row)"><td>{{ displayCustomerName(row) }}</td><td>{{ row.currency }}</td><td>{{ money(row.totalReceivable) }}</td><td>{{ money(row.totalReceived) }}</td><td>{{ money(row.totalOutstanding) }}</td></tr>
<tr v-if="!rows.length"><td colspan="5">当前月份暂无应收数据</td></tr>
</tbody>
<tfoot><tr><th scope="row" colspan="2">统计汇总</th><td>{{ money(result.totalReceivable) }}</td><td>{{ money(result.totalReceived) }}</td><td>{{ money(result.totalOutstanding) }}</td></tr></tfoot>
</table>
<p>数据第 {{ result.page }} 页 · 共 {{ result.totalCustomers }} 家应收单位</p>
</section>
</Teleport>
</template> </template>
<style scoped> <style scoped>
@@ -133,10 +153,21 @@ onMounted(() => { void loadCustomers(); void load() })
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; } .query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; } .outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; } .statistics-table { overflow: hidden; }
.receivable-statistics-print { display: none; }
@media print { @media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; } :global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; } :global(body:has(> .receivable-statistics-print)) { min-width: 0; height: auto; margin: 0; overflow: visible; background: #fff; }
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; } .receivable-statistics-print { display: block; width: 100%; color: #000; font: 10pt/1.5 Arial, 'Microsoft YaHei', sans-serif; }
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; } .receivable-statistics-print h1 { margin: 0 0 8pt; text-align: center; font-size: 18pt; }
.receivable-statistics-print p { margin: 8pt 0; }
.receivable-statistics-print table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.receivable-statistics-print col { width: 18%; }
.receivable-statistics-print .print-customer-column { width: 38%; }
.receivable-statistics-print .print-currency-column { width: 8%; }
.receivable-statistics-print th, .receivable-statistics-print td { padding: 5pt; border: 1px solid #999; text-align: left; overflow-wrap: anywhere; }
.receivable-statistics-print tbody td:nth-child(n+3), .receivable-statistics-print thead th:nth-child(n+3), .receivable-statistics-print tfoot td { text-align: right; font-variant-numeric: tabular-nums; }
.receivable-statistics-print thead { display: table-header-group; }
.receivable-statistics-print tfoot { display: table-row-group; font-weight: 700; }
.receivable-statistics-print tr { break-inside: avoid; }
} }
</style> </style>
@@ -2,7 +2,7 @@ import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http' import { ApiResponseError } from '@/shared/api/http'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference' import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
@@ -157,6 +157,21 @@ describe('formal settlement native dialogs', () => {
await click(dialog, '确定') await click(dialog, '确定')
expect(api.createReceivableSettlement).toHaveBeenCalledWith({ reconciliationStatementId: statement.id, currency: 'CNY' }) expect(api.createReceivableSettlement).toHaveBeenCalledWith({ reconciliationStatementId: statement.id, currency: 'CNY' })
}) })
it.each([true, false])('warns before creating receivables from an unconfirmed statement (continue=%s)', async (proceed) => {
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, customerConfirmationStatus: 'PENDING_CONFIRMATION' }])
const confirmation = vi.spyOn(ElMessageBox, 'confirm')
if (proceed) confirmation.mockResolvedValue('confirm')
else confirmation.mockRejectedValue('cancel')
const { dialog } = await openCreate('receivable')
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
await flushPromises()
selects[1]!.vm.$emit('update:modelValue', statement.id)
await click(dialog, '确定')
expect(confirmation).toHaveBeenCalledWith('客户暂未确认!', '生成应收结算单', expect.objectContaining({ type: 'warning' }))
expect(api.createReceivableSettlement).toHaveBeenCalledTimes(proceed ? 1 : 0)
if (!proceed) expect(dialog.props('modelValue')).toBe(true)
})
it('keeps active suppliers and excludes directly paid or locked payable sources', async () => { it('keeps active suppliers and excludes directly paid or locked payable sources', async () => {
const { dialog } = await openCreate('payable') const { dialog } = await openCreate('payable')
const selects = dialog.findAllComponents({ name: 'ElSelect' }) const selects = dialog.findAllComponents({ name: 'ElSelect' })
@@ -295,4 +310,46 @@ describe('formal settlement native dialogs', () => {
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench', inputPattern: /\S+/ })) expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench', inputPattern: /\S+/ }))
expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version }) expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version })
}) })
it('shows a concise reopen conflict message and retains both the voided and replacement receivables', async () => {
const voided = { ...ar, status: 'VOIDED' }
const replacement = { ...ar, id: '9223372036854775012', settlementNo: 'AR202610200002' }
vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([voided, replacement])
vi.mocked(api.actionFinanceSettlement).mockRejectedValue(new ApiResponseError(
'原对账单已生成其他有效应收结算单', 'FIN_409', 409, null, 'reopen-test-request', 'reopen-test-trace',
))
vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试重开', action: 'confirm' as const }))
const message = vi.spyOn(ElMessage, 'error')
const wrapper = await mountPage('receivable')
for (let attempt = 1; attempt <= 2; attempt += 1) {
await click(wrapper, '重开', '.el-table__body-wrapper button')
expect(message).toHaveBeenCalledTimes(attempt)
expect(message).toHaveBeenLastCalledWith({ message: '已有新的应收单,无法重开', showClose: true, duration: 4500 })
}
expect(api.actionFinanceSettlement).toHaveBeenLastCalledWith(ar.id, { action: 'REOPEN', reason: '测试重开', expectedVersion: ar.version })
expect(errorDetails(wrapper)).toMatchObject({ message: '已有新的应收单,无法重开', code: 'FIN_409', requestId: 'reopen-test-request', traceId: 'reopen-test-trace' })
const list = wrapper.get('.el-table__body-wrapper')
expect(list.text()).toContain(voided.settlementNo)
expect(list.text()).toContain(replacement.settlementNo)
expect(wrapper.text()).not.toContain('加载失败')
expect(wrapper.text()).not.toContain('reopen-test-request')
expect(wrapper.text()).not.toContain('reopen-test-trace')
})
it.each([
['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'],
['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'],
] as const)('retains the %s list and original diagnostics when %s fails', async (kind, action, status) => {
const current = kind === 'receivable' ? ar : ap
if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }])
else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }])
vi.mocked(api.actionFinanceSettlement).mockRejectedValue(failure())
vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试原因', action: 'confirm' as const }))
const wrapper = await mountPage(kind)
await click(wrapper, action === 'VOID' ? '作废' : '重开', '.el-table__body-wrapper button')
expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', code: 'TEST_REJECTED', requestId: 'settlement-test-request' })
expect(wrapper.get('.el-table__body-wrapper').text()).toContain(current.settlementNo)
expect(wrapper.text()).not.toContain('加载失败')
})
}) })
+15
View File
@@ -52,6 +52,21 @@ describe('finance status presentation', () => {
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作') expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
}) })
it.each([
['OVERSEAS_INBOUND_FEE', '海外入仓费用'],
['OVERSEAS_PROCUREMENT_FEE', '海外采购费用'],
['OVERSEAS_TRUCK_FEE', '本土卡车费用'],
['OVERSEAS_LOOSEBOARD_FEE', '本土散板费用'],
['OVERSEAS_WAREHOUSE_FEE', '海外仓服务费'],
['OVERSEAS_CONTAINER_FEE', '海外柜子费用'],
['OVERSEAS_ORDER_FEE', '尾程订单费用'],
['OVERSEAS_DELIVERY_FEE', '尾程派送费用'],
['OVERSEAS_RELABEL_FEE', '换标费用'],
])('labels the current overseas fee source %s consistently', (code, label) => {
expect(financeSourceTypeLabel(code)).toBe(label)
expect(financeSourceTypeLabel(` ${code.toLowerCase()} `)).toBe(label)
})
it('uses review-status values for the customer AR/AP review filter', () => { it('uses review-status values for the customer AR/AP review filter', () => {
expect(financeArapStatusOptions).toEqual([ expect(financeArapStatusOptions).toEqual([
{ label: '草稿', value: 'DRAFT' }, { label: '草稿', value: 'DRAFT' },
+5
View File
@@ -60,6 +60,11 @@ export function financeStatusTone(status: unknown): FinanceStatusTone {
const financeSourceTypeLabels: Record<string, string> = { const financeSourceTypeLabels: Record<string, string> = {
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用', INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
OVERSEAS_INBOUND_FEE: '海外入仓费用', OVERSEAS_PROCUREMENT_FEE: '海外采购费用',
OVERSEAS_TRUCK_FEE: '本土卡车费用', OVERSEAS_LOOSEBOARD_FEE: '本土散板费用',
OVERSEAS_WAREHOUSE_FEE: '海外仓服务费', OVERSEAS_CONTAINER_FEE: '海外柜子费用',
OVERSEAS_ORDER_FEE: '尾程订单费用', OVERSEAS_DELIVERY_FEE: '尾程派送费用',
OVERSEAS_RELABEL_FEE: '换标费用',
} }
const financeBusinessTypeLabels: Record<string, string> = { const financeBusinessTypeLabels: Record<string, string> = {
RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_CREATED: '生成对账单',
@@ -1,5 +1,16 @@
# 海外业务原型核对矩阵 # 海外业务原型核对矩阵
## 2026-11-02 海外自动仓租计算过程
依据:本轮用户确认“退货余一箱仍计一托、沿用现有阶梯”,以及“海外自动计算的仓租展示费用计算过程”。沿用既有费用详情、审核和调价弹框,按 Admin 高密度规范的正文分区与表格规则展示,不改变入口、权限及资金动作。
| 直接消费者 | 操作与现有承载 | 本次实现与核对 | 验收 |
|---|---|---|---|
| OverseasFeesView | 海外费用管理的费用详情弹框 | StorageRentFeeDetails 展示冻结快照的计租基数、原库龄、已计费日、逐档天数×基数×单价、小计及汇率换算 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
| BusinessOrderFees | 退货、废弃、换标等业务单费用详情弹框 | 复用相同计算过程;旧比例费用明确保留原计费口径,缺少依据不猜算 | 定向回归通过;待人工截图确认 |
| StorageRentFeeActions | 业务单仓租审核、调价弹框 | 原计算过程保持可见;调整金额不改写原快照 | 定向回归通过;待人工截图确认 |
| StorageRentPanel | 仓租库存的原批次详情弹框 | 出库计租基数优先采用费用快照,新整托费用不再展示件数分摊的零点几托 | 定向回归通过;待人工截图确认 |
采购样品履约的当前操作与页面承载以[采购订单样品履约流程变更需求第 10.6 节](../../../../../docs/海外仓业务/采购订单样品履约流程变更需求.md#106-平台页面实施核对矩阵)为准。下表采购旧五分区和通用编辑仅保留原型定位;当前详情使用履约聚合数据,独立编辑只维护内部备注,节点写入使用各自权限和命令。 采购样品履约的当前操作与页面承载以[采购订单样品履约流程变更需求第 10.6 节](../../../../../docs/海外仓业务/采购订单样品履约流程变更需求.md#106-平台页面实施核对矩阵)为准。下表采购旧五分区和通用编辑仅保留原型定位;当前详情使用履约聚合数据,独立编辑只维护内部备注,节点写入使用各自权限和命令。
本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。 本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。
@@ -2,8 +2,15 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface FeePage<T> { records: T[]; page: number; size: number; total: number; pages: number } export interface FeePage<T> { records: T[]; page: number; size: number; total: number; pages: number }
export type OverseasFeeDirection = 'RECEIVABLE' | 'PAYABLE' export type OverseasFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export interface StorageRentCalculation {
sourceBusinessNo: string | null; batchNo: string | null; ruleCode: 'RETURN_PALLET' | 'RETURN_SHARE' | 'TRANSIT'
billingQuantity: number; billingUnit: 'PALLET' | 'CBM'; storageDays: number; previouslyChargedDays: number
tiers: Array<{ startDay: number; endDay: number; days: number; unitPrice: number; amount: number }>
calculatedAmount: number
}
export interface OverseasFee { export interface OverseasFee {
storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null; storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null;
calculations?: StorageRentCalculation[]
lines: Array<{ lotId: string; inboundAt: string; quantity: number; days: number; calculatedAmount: number; lines: Array<{ lotId: string; inboundAt: string; quantity: number; days: number; calculatedAmount: number;
sourceBusinessType?: string | null; sourceBusinessNo?: string | null; batchNo?: string | null }> } | null sourceBusinessType?: string | null; sourceBusinessNo?: string | null; batchNo?: string | null }> } | null
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string
@@ -155,3 +155,8 @@ export async function updateLocalLooseboardOrder(orderNo: string, customerId: st
const response = await http.put<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) const response = await http.put<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
return looseboardDetail(unwrapApiResponse(response)) return looseboardDetail(unwrapApiResponse(response))
} }
export async function reviewLocalLooseboardOrder(orderNo: string, customerId: string, payload: {version: number; decision: 'APPROVE' | 'REJECT'; idempotencyKey: string; remark?: string}) {
const response = await http.post<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } })
return looseboardDetail(unwrapApiResponse(response))
}
@@ -15,6 +15,7 @@ export interface StorageRentLot {
settledAmount?: number; chargeCount?: number; settledChargeCount?: number settledAmount?: number; chargeCount?: number; settledChargeCount?: number
} }
export interface StorageRentCharge { export interface StorageRentCharge {
billingQuantity?: number | null; billingUnit?: string | null
id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number
originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string
createdAt: string; outboundAt: string createdAt: string; outboundAt: string
@@ -188,17 +188,17 @@ describe('单据费用操作', () => {
await flushPromises(); await click(dialog, '保存') await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled() expect(api.createOverseasFee).not.toHaveBeenCalled()
}) })
it('blocks automatic storage rent from manual addition', async () => { it('allows manual storage rent using the normal fee submission', async () => {
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }]) vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }])
const wrapper = await mountPage() const wrapper = await mountPage()
await click(wrapper, '新增费用') await click(wrapper, '新增费用')
const dialog = activeDialog(wrapper) const dialog = activeDialog(wrapper)
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]! const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(0) expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(1)
select.vm.$emit('update:modelValue', 'FEE_TYPE_077') select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10) dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
await flushPromises(); await click(dialog, '保存') await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled() expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ feeItemCode: 'FEE_TYPE_077', amount: 10 }), expect.any(String))
}) })
it('includes verified related orders but rejects another customer and keeps adjustment on the original order', async () => { it('includes verified related orders but rejects another customer and keeps adjustment on the original order', async () => {
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' } const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
@@ -270,6 +270,20 @@ describe('单据费用操作', () => {
expect(dialog.text()).toContain('审核状态') expect(dialog.text()).toContain('审核状态')
expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false) expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false)
}) })
it.each([['ADJUST', '调整'], ['VOID', '作废']] as const)('does not execute %s after the fee was approved elsewhere', async (action, label) => {
const pending = { ...fee, allowedActions: [action] }
const approved = { ...fee, reviewStatus: 'APPROVED', allowedActions: ['ADJUST', 'VOID'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValueOnce({ records: [pending], total: 1, page: 1, size: 20, pages: 1 })
.mockResolvedValue({ records: [approved], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(approved)
const wrapper = await mountPage()
await click(wrapper, label, '.el-table__body-wrapper button')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(false)
const buttons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(buttons).not.toContain('调整')
expect(buttons).not.toContain('作废')
})
it('voids with the freshly loaded version and a required reason', async () => { it('voids with the freshly loaded version and a required reason', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] }) vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] })
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLocalLooseboardOrder, reviewLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import type { LocalLooseboardDetail, LocalLooseboardOrder } from '../types'
const open = defineModel<boolean>({default: false})
const props = defineProps<{order: LocalLooseboardOrder | null}>()
const emit = defineEmits<{saved: []}>()
const detail = ref<LocalLooseboardDetail | null>(null), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const decision = ref<'APPROVE' | 'REJECT'>('APPROVE'), remark = ref('')
let generation = 0
async function load() {
if (!props.order) return
const current=++generation; loading.value=true; detail.value=null; error.value=null
try { const value=await getLocalLooseboardOrder(props.order.orderNo,props.order.customerId); if(current===generation)detail.value=value }
catch(cause) { if(current===generation)error.value=getApiErrorDetails(cause,'散板订单读取失败') }
finally { if(current===generation)loading.value=false }
}
async function submit() {
if (!detail.value || busy.value) return
if (decision.value==='REJECT' && !remark.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
busy.value=true; error.value=null
try {
await reviewLocalLooseboardOrder(detail.value.orderNo,detail.value.customerId,{version:detail.value.version,decision:decision.value,remark:remark.value.trim() || undefined,idempotencyKey:crypto.randomUUID()})
ElMessage.success(decision.value==='APPROVE'?'审核通过,已生成关联收货任务':'订单已驳回'); open.value=false; emit('saved')
} catch(cause) { error.value=getApiErrorDetails(cause,'散板审核失败'); await load().catch(()=>undefined); error.value=getApiErrorDetails(cause,'散板审核失败') }
finally { busy.value=false }
}
watch(open,value=>{if(value){decision.value='APPROVE';remark.value='';void load()}else generation++})
</script>
<template><AppDialog v-model="open" title="审核本土散板订单" width="680px" class="admin-density-dialog admin-workbench" :confirm-loading="busy" :confirm-disabled="loading || !detail || detail.status !== 'PENDING_ACCEPTANCE'" lock-while-submitting @confirm="submit">
<LoadErrorState v-if="error" title="审核暂未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form v-if="detail" label-position="top" :disabled="busy" class="density-form">
<el-form-item label="订单号">{{detail.orderNo}}</el-form-item>
<el-form-item label="入仓方式">{{detail.deliveryMode==='WAREHOUSE_PICKUP'?'仓库上门取货':detail.deliveryMode==='DELIVERY_TO_WAREHOUSE'?'送货到仓':'未填写,需先编辑补齐'}}</el-form-item>
<el-form-item label="审核结果"><el-radio-group v-model="decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item>
<el-form-item label="审核意见" :required="decision==='REJECT'"><el-input v-model="remark" type="textarea" maxlength="1000" :rows="3" /></el-form-item>
</el-form>
</AppDialog></template>
@@ -32,8 +32,7 @@ let optionSequence = 0
let savedFingerprint = '', requestKey = '' let savedFingerprint = '', requestKey = ''
let openedBusinessKey = '' let openedBusinessKey = ''
const businessKey = () => props.business ? `${props.business.businessType}:${props.business.businessId}:${props.business.customerId}` : '' const businessKey = () => props.business ? `${props.business.businessType}:${props.business.businessId}:${props.business.customerId}` : ''
const automaticRentCode = 'FEE_TYPE_077' const manualItems = computed(() => props.items)
const manualItems = computed(() => props.items.filter(item => item.feeCode !== automaticRentCode))
const hasExistingItem = computed(() => !!props.fee && manualItems.value.some(item => item.feeCode === props.fee?.feeItemCode)) const hasExistingItem = computed(() => !!props.fee && manualItems.value.some(item => item.feeCode === props.fee?.feeItemCode))
async function loadOptions(keyword = '') { async function loadOptions(keyword = '') {
if (props.business) return if (props.business) return
@@ -90,7 +89,6 @@ const sourceTypes: Record<string, string> = {
const selectableTypes = Object.keys(sourceTypes) const selectableTypes = Object.keys(sourceTypes)
async function save() { async function save() {
if (!access.can(feePermissions.edit) || saving.value || optionsLoading.value || !await formRef.value?.validate().catch(() => false)) return if (!access.can(feePermissions.edit) || saving.value || optionsLoading.value || !await formRef.value?.validate().catch(() => false)) return
if (form.feeItemCode === automaticRentCode) { ElMessage.error('仓租自动计算,请勿手动添加'); return }
const order = selected.value const order = selected.value
if (!order) { ElMessage.error('请重新选择业务订单'); return } if (!order) { ElMessage.error('请重新选择业务订单'); return }
if (existingLoading.value || existingError.value) { ElMessage.warning(existingError.value || '正在核对已有费用'); return } if (existingLoading.value || existingError.value) { ElMessage.warning(existingError.value || '正在核对已有费用'); return }
@@ -132,7 +130,7 @@ async function save() {
<div class="fee-editor-grid"> <div class="fee-editor-grid">
<el-form-item v-if="!business" label="业务类型" prop="businessType"><el-select v-model="form.businessType" :disabled="!!fee" @change="changeBusinessType"><el-option v-for="type in selectableTypes" :key="type" :value="type" :label="businessLabels[type]" /></el-select></el-form-item> <el-form-item v-if="!business" label="业务类型" prop="businessType"><el-select v-model="form.businessType" :disabled="!!fee" @change="changeBusinessType"><el-option v-for="type in selectableTypes" :key="type" :value="type" :label="businessLabels[type]" /></el-select></el-form-item>
<el-form-item v-if="!business" label="业务订单" prop="businessId"><el-select v-model="form.businessId" filterable remote :remote-method="loadOptions" :loading="optionsLoading" :disabled="!!fee" placeholder="搜索订单号"><el-option v-for="row in options" :key="row.businessId" :value="row.businessId" :label="`${row.businessNo} · ${row.customerName}`" /></el-select></el-form-item> <el-form-item v-if="!business" label="业务订单" prop="businessId"><el-select v-model="form.businessId" filterable remote :remote-method="loadOptions" :loading="optionsLoading" :disabled="!!fee" placeholder="搜索订单号"><el-option v-for="row in options" :key="row.businessId" :value="row.businessId" :label="`${row.businessNo} · ${row.customerName}`" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && fee.feeItemCode !== automaticRentCode && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item> <el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item> <el-form-item label="币种"><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :max="999999999.99" :precision="2" :controls="false" /></el-form-item> <el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :max="999999999.99" :precision="2" :controls="false" /></el-form-item>
<el-form-item v-if="(props.fee?.direction || 'RECEIVABLE') === 'RECEIVABLE'" label="结算方式"><el-select v-model="form.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item> <el-form-item v-if="(props.fee?.direction || 'RECEIVABLE') === 'RECEIVABLE'" label="结算方式"><el-select v-model="form.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item>
@@ -24,7 +24,7 @@ function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-c
} }
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) }) beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) })
describe('simplified procurement operations', () => { describe('simplified procurement operations', () => {
it('allows a rejected purchase to be resubmitted without uploading photos and clears the old reason after success', async () => { it('allows a rejected purchase to be resubmitted without uploading photos and hides the old reason after success', async () => {
const wrapper = render() const wrapper = render()
const rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [ const rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [
{ id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' }, { id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' },
@@ -39,8 +39,32 @@ describe('simplified procurement operations', () => {
expect(uploadProcurementFile).not.toHaveBeenCalled() expect(uploadProcurementFile).not.toHaveBeenCalled()
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) }) expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) })
expect(wrapper.emitted('changed')).toHaveLength(1) expect(wrapper.emitted('changed')).toHaveLength(1)
await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5, rejectionReason: null, photosNeedUpdate: false } }) await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5 } })
expect(wrapper.text()).not.toContain('客户驳回原因') expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('请核对')
await wrapper.setProps({ workflow: { ...rejected, status: 'CONFIRMED', version: 6 } })
expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('请核对')
})
it('hides the previous rejection after receipt photos are updated and shows a new rejection again', async () => {
const wrapper = render()
const rejected = { ...workflow(), rejectionReason: '照片不清晰', photosNeedUpdate: true, receipts: [
{ id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' },
{ id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' },
] }
await wrapper.setProps({ workflow: rejected })
expect(wrapper.text()).toContain('照片不清晰')
await wrapper.setProps({ workflow: { ...rejected, version: 5, rejectionReason: null, photosNeedUpdate: true } })
expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('照片不清晰')
expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(true)
await wrapper.setProps({ workflow: { ...rejected, version: 6, rejectionReason: null, photosNeedUpdate: false } })
expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(false)
await wrapper.setProps({ workflow: { ...rejected, version: 7, rejectionReason: '请补拍完整商品' } })
expect(wrapper.text()).toContain('请补拍完整商品')
}) })
it('accepts the first and subsequent receipt batches using the remaining purchase quantity', async () => { it('accepts the first and subsequent receipt batches using the remaining purchase quantity', async () => {
const wrapper = render() const wrapper = render()
@@ -101,14 +101,14 @@ const fileName = (id: string) => props.detail.files.find(file => file.fileId ===
</script> </script>
<template> <template>
<section class="procurement-workflow"> <section class="procurement-workflow">
<el-alert v-if="workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show-icon /> <el-alert v-if="workflow.status === 'RECEIVING' && workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show-icon />
<div class="density-actions"> <div class="density-actions">
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template> <template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template>
<el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button> <el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button>
</div> </div>
<el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text> <el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text>
<dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl> <dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl>
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-if="row.id && workflow.status === 'RECEIVING' && workflow.rejectionReason && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable> <h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-if="row.id && workflow.status === 'RECEIVING' && (workflow.rejectionReason || workflow.photosNeedUpdate) && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><template v-if="row.status === 'IN_TRANSIT'"><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('edit-outbound', row as ProcurementOutbound)">编辑</el-button><el-button :disabled="!can('ship-customer')" link type="danger" @click="begin('cancel', row as ProcurementOutbound)">取消</el-button><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button></template><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable> <h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><template v-if="row.status === 'IN_TRANSIT'"><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('edit-outbound', row as ProcurementOutbound)">编辑</el-button><el-button :disabled="!can('ship-customer')" link type="danger" @click="begin('cancel', row as ProcurementOutbound)">取消</el-button><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button></template><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="save"> <AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="save">
<el-form :model="form" class="density-form density-form--outlined" label-position="top" :disabled="saving"> <el-form :model="form" class="density-form density-form--outlined" label-position="top" :disabled="saving">
@@ -0,0 +1,52 @@
import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { OverseasFee, StorageRentCalculation } from '../api/overseasFees'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
const { default: ElementPlus } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
enableAutoUnmount(afterEach)
beforeEach(() => vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }))
afterEach(() => vi.unstubAllGlobals())
const calculation: StorageRentCalculation = {
sourceBusinessNo: 'TH202611020001', batchNo: 'RB1', ruleCode: 'RETURN_PALLET', billingQuantity: 1, billingUnit: 'PALLET',
storageDays: 40, previouslyChargedDays: 10, calculatedAmount: 50,
tiers: [{ startDay: 11, endDay: 30, days: 20, unitPrice: 1.5, amount: 30 }, { startDay: 31, endDay: 40, days: 10, unitPrice: 2, amount: 20 }],
}
function fee(calculations?: StorageRentCalculation[]) {
return { originalCurrency: 'USD', currency: 'CNY', originalAmount: 50, amount: 500, exchangeRate: 10,
storageRent: { outboundNo: 'OUT1', outboundAt: '2026-11-02T12:00:00Z', calculatedAmount: 50, chargedAmount: null,
lines: [{ lotId: '1', inboundAt: '2026-09-24T12:00:00Z', quantity: 1, days: 40, calculatedAmount: 50 }], calculations },
} as OverseasFee
}
it('explains whole pallet tiers and skips previously charged days in every fee detail consumer', async () => {
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([calculation]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('余一箱仍计一托')
expect(wrapper.text()).toContain('此前已计费10 天')
expect(wrapper.text()).toContain('20 天 × 1 托 × USD 1.5 = USD 30')
expect(wrapper.text()).toContain('10 天 × 1 托 × USD 2 = USD 20')
expect(wrapper.text()).toContain('USD 50.00 × 10 = CNY 500.00')
expect(wrapper.text()).toContain('本批次计算金额:USD 50')
})
it('shows transit volume and free tier calculations with the saved currency', async () => {
const transit: StorageRentCalculation = { ...calculation, ruleCode: 'TRANSIT', billingUnit: 'CBM', billingQuantity: 0.8,
storageDays: 10, previouslyChargedDays: 0, calculatedAmount: 1.92,
tiers: [{ startDay: 1, endDay: 7, days: 7, unitPrice: 0, amount: 0 }, { startDay: 8, endDay: 10, days: 3, unitPrice: 0.8, amount: 1.92 }] }
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([transit]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('7 天 × 0.8 CBM × USD 0 = USD 0')
expect(wrapper.text()).toContain('3 天 × 0.8 CBM × USD 0.8 = USD 1.92')
})
it('distinguishes historical fractional pricing from new whole pallet pricing', async () => {
const historical: StorageRentCalculation = { ...calculation, ruleCode: 'RETURN_SHARE', billingQuantity: 0.5625,
storageDays: 1, previouslyChargedDays: 0, calculatedAmount: 0.84375,
tiers: [{ startDay: 1, endDay: 1, days: 1, unitPrice: 1.5, amount: 0.84375 }] }
const wrapper = mount(StorageRentFeeDetails, { props: { fee: fee([historical]) }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('历史费用:按原托份额计费')
expect(wrapper.text()).toContain('1 天 × 0.5625 托 × USD 1.5 = USD 0.84375')
await wrapper.setProps({ fee: fee([]) })
expect(wrapper.text()).toContain('未保存完整计算依据')
expect(wrapper.text()).not.toContain('1 天 × 0.5625')
})
@@ -4,6 +4,8 @@ import type { OverseasFee } from '../api/overseasFees'
import { feeMoney } from '../overseasFeePresentation' import { feeMoney } from '../overseasFeePresentation'
import { storageRentSourceType, storageRentTime } from '../storageRentPresentation' import { storageRentSourceType, storageRentTime } from '../storageRentPresentation'
defineProps<{ fee: OverseasFee }>() defineProps<{ fee: OverseasFee }>()
const number = (value: number) => new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 12 }).format(Number(value))
const unit = (value: string) => value === 'PALLET' ? '托' : 'CBM'
</script> </script>
<template> <template>
<section v-if="fee.storageRent" aria-label="仓租计费明细"> <section v-if="fee.storageRent" aria-label="仓租计费明细">
@@ -13,6 +15,7 @@ defineProps<{ fee: OverseasFee }>()
<el-descriptions-item label="原计算金额">{{ feeMoney(fee.storageRent.calculatedAmount, fee.originalCurrency) }}</el-descriptions-item> <el-descriptions-item label="原计算金额">{{ feeMoney(fee.storageRent.calculatedAmount, fee.originalCurrency) }}</el-descriptions-item>
<el-descriptions-item label="应扣金额">{{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item> <el-descriptions-item label="应扣金额">{{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item>
<el-descriptions-item label="实扣金额">{{ feeMoney(fee.storageRent.chargedAmount, fee.currency) }}</el-descriptions-item> <el-descriptions-item label="实扣金额">{{ feeMoney(fee.storageRent.chargedAmount, fee.currency) }}</el-descriptions-item>
<el-descriptions-item v-if="fee.exchangeRate != null" label="结算换算">{{ feeMoney(fee.originalAmount, fee.originalCurrency) }} × {{ number(fee.exchangeRate) }} = {{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-table :data="fee.storageRent.lines" border empty-text="暂无计租批次"> <el-table :data="fee.storageRent.lines" border empty-text="暂无计租批次">
<el-table-column label="来源类型" min-width="130"><template #default="{ row }">{{ storageRentSourceType(row.sourceBusinessType) }}</template></el-table-column> <el-table-column label="来源类型" min-width="130"><template #default="{ row }">{{ storageRentSourceType(row.sourceBusinessType) }}</template></el-table-column>
@@ -20,8 +23,29 @@ defineProps<{ fee: OverseasFee }>()
<el-table-column label="入库批次" min-width="160"><template #default="{ row }">{{ row.batchNo || '—' }}</template></el-table-column> <el-table-column label="入库批次" min-width="160"><template #default="{ row }">{{ row.batchNo || '—' }}</template></el-table-column>
<el-table-column label="入库时间" min-width="170"><template #default="{ row }">{{ storageRentTime(row.inboundAt) }}</template></el-table-column> <el-table-column label="入库时间" min-width="170"><template #default="{ row }">{{ storageRentTime(row.inboundAt) }}</template></el-table-column>
<el-table-column prop="quantity" label="本次出库数量" width="130" /> <el-table-column prop="quantity" label="本次出库数量" width="130" />
<el-table-column prop="days" label="计租天数" width="100" /> <el-table-column prop="days" label="库龄天数" width="100" />
<el-table-column label="原计算仓租" min-width="130"><template #default="{ row }">{{ feeMoney(row.calculatedAmount, fee.originalCurrency) }}</template></el-table-column> <el-table-column label="原计算仓租" min-width="130"><template #default="{ row }">{{ feeMoney(row.calculatedAmount, fee.originalCurrency) }}</template></el-table-column>
</el-table> </el-table>
<h4>仓租计算过程</h4>
<template v-if="fee.storageRent.calculations?.length">
<div v-for="(calculation, index) in fee.storageRent.calculations" :key="index">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="来源单号">{{ calculation.sourceBusinessNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="入库批次">{{ calculation.batchNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="计费口径">{{ calculation.ruleCode === 'RETURN_PALLET' ? '按原托整托计费,余一箱仍计一托' : calculation.ruleCode === 'RETURN_SHARE' ? '历史费用:按原托份额计费' : '按本次出库体积计费' }}</el-descriptions-item>
<el-descriptions-item label="计租数量">{{ number(calculation.billingQuantity) }} {{ unit(calculation.billingUnit) }}</el-descriptions-item>
<el-descriptions-item label="累计库龄">{{ calculation.storageDays }} 天</el-descriptions-item>
<el-descriptions-item label="此前已计费">{{ calculation.previouslyChargedDays }} 天</el-descriptions-item>
</el-descriptions>
<el-table :data="calculation.tiers" border empty-text="本计租日已计费,本次不重复收费" aria-label="仓租阶梯计算">
<el-table-column label="计费区间" min-width="130"><template #default="{ row }">第 {{ row.startDay }}–{{ row.endDay }} 天</template></el-table-column>
<el-table-column label="单价" min-width="160"><template #default="{ row }">{{ fee.originalCurrency }} {{ number(row.unitPrice) }}/{{ unit(calculation.billingUnit) }}/天</template></el-table-column>
<el-table-column label="计算过程" min-width="300"><template #default="{ row }">{{ row.days }} 天 × {{ number(calculation.billingQuantity) }} {{ unit(calculation.billingUnit) }} × {{ fee.originalCurrency }} {{ number(row.unitPrice) }} = {{ fee.originalCurrency }} {{ number(row.amount) }}</template></el-table-column>
</el-table>
<p>本批次计算金额:{{ fee.originalCurrency }} {{ number(calculation.calculatedAmount) }}</p>
</div>
<p>各阶梯按原始精度累加,仓租合计四舍五入至 2 位小数;计价采用生成费用时保存的报价。</p>
</template>
<p v-else>该笔费用未保存完整计算依据,暂无法展示计算过程。</p>
</section> </section>
</template> </template>
@@ -28,6 +28,19 @@ beforeEach(() => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('仓租列表服务端契约', () => { describe('仓租列表服务端契约', () => {
it('uses the whole pallet billing basis from the charge snapshot when only one box leaves', async () => {
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: { ...lot, businessType: 'RETURN_ORDER', billingUnit: 'PALLET', originalQuantity: 16 },
charges: [{ id: '3', outboundNo: 'OUT1', businessNo: 'CD1', quantity: 1, storageDays: 1, originalAmount: 1.5,
currency: 'USD', settlementAmount: 15, currencyRate: 10, status: 'PENDING_REVIEW', createdAt: '', outboundAt: '', billingQuantity: 1, billingUnit: 'PALLET' }] })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text().trim() === '详情')!.trigger('click')
await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('本次计租基数')
expect(dialog.textContent).toContain('1 托')
expect(dialog.textContent).not.toContain('0.0625 托')
})
it('shows storage age and remaining quantity and traces each half pallet without financial actions', async () => { it('shows storage age and remaining quantity and traces each half pallet without financial actions', async () => {
const returned = { ...lot, businessType: 'RETURN_ORDER', businessNo: 'TH1', quantityUnit: 'PIECE', originalQuantity: 6, remainingQuantity: 2, billingQuantity: 1.5, billingUnit: 'PALLET', status: 'IN_STOCK', generatedAmounts: [{ currency: 'USD', amount: 1.5 }], settledAmount: 10, chargeCount: 2, settledChargeCount: 2 } const returned = { ...lot, businessType: 'RETURN_ORDER', businessNo: 'TH1', quantityUnit: 'PIECE', originalQuantity: 6, remainingQuantity: 2, billingQuantity: 1.5, billingUnit: 'PALLET', status: 'IN_STOCK', generatedAmounts: [{ currency: 'USD', amount: 1.5 }], settledAmount: 10, chargeCount: 2, settledChargeCount: 2 }
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [returned], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [returned], total: 1, page: 1, size: 20, pages: 1 })
@@ -39,7 +39,7 @@ const chargeColumns = computed<DataTableColumn[]>(() => [
{ prop: 'outboundNo', label: '出库批次', minWidth: 170 }, { prop: 'outboundNo', label: '出库批次', minWidth: 170 },
{ prop: 'businessNo', label: '出库业务单号', minWidth: 170 }, { prop: 'businessNo', label: '出库业务单号', minWidth: 170 },
{ prop: 'quantity', label: '出库数量', width: 105, align: 'right', formatter: (row: { quantity: number }) => `${row.quantity} ${unitLabels[detail.value?.lot.quantityUnit || ''] || ''}`.trim() }, { prop: 'storageDays', label: '计租天数', width: 100, align: 'right' }, { prop: 'quantity', label: '出库数量', width: 105, align: 'right', formatter: (row: { quantity: number }) => `${row.quantity} ${unitLabels[detail.value?.lot.quantityUnit || ''] || ''}`.trim() }, { prop: 'storageDays', label: '计租天数', width: 100, align: 'right' },
{ key: 'billingShare', label: '本次计租份额', width: 120, formatter: (row: StorageRentCharge) => detail.value ? storageRentBillingShare(row.quantity, detail.value.lot) : '—' }, { key: 'billingShare', label: '本次计租基数', width: 120, formatter: (row: StorageRentCharge) => detail.value ? storageRentBillingShare(row.quantity, detail.value.lot, row) : '—' },
{ prop: 'outboundAt', label: '出库时间', minWidth: 165, formatter: (row: { outboundAt: string }) => storageRentTime(row.outboundAt) }, { prop: 'outboundAt', label: '出库时间', minWidth: 165, formatter: (row: { outboundAt: string }) => storageRentTime(row.outboundAt) },
]) ])
let sequence = 0 let sequence = 0
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, ref } from 'vue' import { computed, ref, onMounted, watch } from 'vue'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import type { FormInstance } from 'element-plus' import type { FormInstance } from 'element-plus'
import FormSection from '@/shared/components/FormSection.vue' import FormSection from '@/shared/components/FormSection.vue'
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm' import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
@@ -16,7 +17,20 @@ const props = withDefaults(defineProps<{
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false }) }>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
const emit = defineEmits<{ submit: []; cancel: [] }>() const emit = defineEmits<{ submit: []; cancel: [] }>()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const warehouses = ref<PlatformWarehouseResource[]>([]), warehouseError = ref('')
async function loadWarehouses() {
if (props.kind !== 'looseboard') return
try { warehouses.value = (await listPlatformWarehouses({page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE'})).records; warehouseError.value = '' }
catch (cause) { warehouseError.value = cause instanceof Error ? cause.message : '仓库读取失败' }
}
function chooseWarehouse() {
const warehouse = warehouses.value.find(row => String(row.id) === String(props.model.operatingWarehouseId))
props.model.deliveryContactName = warehouse?.contactName || ''; props.model.deliveryContactPhone = warehouse?.contactPhone || ''
}
onMounted(loadWarehouses)
watch([() => props.model.operatingWarehouseId, warehouses], chooseWarehouse, { immediate: true })
const rules = computed(() => transportDemandRules(props.kind, props.model)) const rules = computed(() => transportDemandRules(props.kind, props.model))
const isTruck = computed(() => props.kind === 'truck')
defineExpose({ defineExpose({
validate: () => formRef.value?.validate() ?? Promise.resolve(false), validate: () => formRef.value?.validate() ?? Promise.resolve(false),
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields), clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
@@ -26,26 +40,40 @@ defineExpose({
<template> <template>
<el-form ref="formRef" class="transport-demand-form admin-density admin-workbench density-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting"> <el-form ref="formRef" class="transport-demand-form admin-density admin-workbench density-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<slot name="customer" /> <slot name="customer" />
<FormSection v-if="kind === 'looseboard'" class="density-form-section" title="入仓安排">
<div class="transport-demand-grid">
<el-form-item v-if="kind === 'looseboard' && model.destinationType === 'PRIVATE'" label="目的地邮编" prop="destinationPostalCode"><el-input v-model.trim="model.destinationPostalCode" maxlength="32" /></el-form-item>
<el-form-item class="transport-demand-wide" label="送货方式" prop="deliveryMode"><el-radio-group v-model="model.deliveryMode"><el-radio-button value="DELIVERY_TO_WAREHOUSE">送货到仓</el-radio-button><el-radio-button value="WAREHOUSE_PICKUP">仓库上门取货</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="收货仓库" prop="operatingWarehouseId"><el-select v-model="model.operatingWarehouseId" filterable placeholder="请选择收货仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select><span v-if="warehouseError" role="alert">{{ warehouseError }} <el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<template v-if="model.deliveryMode === 'DELIVERY_TO_WAREHOUSE'">
<el-form-item label="到仓联系人" prop="deliveryContactName"><el-input v-model="model.deliveryContactName" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
<el-form-item label="到仓联系电话" prop="deliveryContactPhone"><el-input v-model="model.deliveryContactPhone" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
<el-form-item label="预计到货时间" prop="estimatedArrivalAt"><el-date-picker v-model="model.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item>
<el-form-item label="物流跟踪号" prop="trackingNo"><el-input v-model.trim="model.trackingNo" maxlength="255" /></el-form-item>
</template>
</div>
</FormSection>
<FormSection class="density-form-section" title="运输信息"> <FormSection class="density-form-section" title="运输信息">
<div class="transport-demand-grid"> <div class="transport-demand-grid">
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType"> <el-form-item v-if="isTruck" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select> <el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
</el-form-item> </el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item> <el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item> <el-form-item :label="isTruck ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item> <el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item> <el-form-item :label="isTruck ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item> <el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item> <el-form-item :label="isTruck ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货地址' : '起运详细地址'"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item> <el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点取货地址' : '起运详细地址'" prop="originAddress"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item> <el-form-item :label="isTruck ? 'C点送达地址' : '目的详细地址'" prop="destinationAddress"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
</div> </div>
</FormSection> </FormSection>
<FormSection class="density-form-section" title="货物信息"> <FormSection class="density-form-section" title="货物信息">
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'"> <div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item> <el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item> <el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'truck'" label="托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="1" :precision="0" controls-position="right" /></el-form-item> <el-form-item v-if="isTruck" label="托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="1" :precision="0" controls-position="right" /></el-form-item>
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item> <el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item> <el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item> <el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
@@ -58,8 +86,8 @@ defineExpose({
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select> <el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
</div></el-form-item> </div></el-form-item>
<div /> <div />
<el-form-item :label="kind === 'truck' ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item> <el-form-item :label="isTruck ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item> <el-form-item :label="isTruck ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item> <el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</div> </div>
</FormSection> </FormSection>
@@ -3,7 +3,6 @@ import '@/styles/adminWorkbench.css'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -105,12 +104,12 @@ async function submit() {
</div> </div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit"> <AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
<template v-if="active"> <template v-if="active">
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p> <p v-if="active !== 'review' && active !== 'arrange'">{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" /> <el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div> <el-alert v-if="error" :title="error.message" type="error" :closable="false" role="alert" />
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit"> <el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item> <el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="选择 A 海外仓" filterable><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item> <el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="" filterable><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<template v-if="active === 'receive-pallets'"> <template v-if="active === 'receive-pallets'">
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item> <el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item>
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item> <el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item>
@@ -48,7 +48,8 @@ describe('overseas business real contract pages', () => {
const purchaseEdit = read('pages/PurchaseOrderEditView.vue'); const transportList = read('pages/TransportOrdersView.vue'); const transportForm = read('pages/TransportOrderFormView.vue') const purchaseEdit = read('pages/PurchaseOrderEditView.vue'); const transportList = read('pages/TransportOrdersView.vue'); const transportForm = read('pages/TransportOrderFormView.vue')
expect(purchaseEdit).toContain('useUnsavedWorkRegistration') expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
expect(transportForm).toContain('useUnsavedWorkRegistration') expect(transportForm).toContain('useUnsavedWorkRegistration')
expect(transportList).toContain("row.status === 'PENDING_ACCEPTANCE'") expect(transportList).not.toContain("isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE'")
expect(transportList).toContain("!isLooseboard.value && !row.fleetArrangement && row.status === 'PENDING_REVIEW'")
expect(transportList).toContain("row.status === 'PENDING_REVIEW'") expect(transportList).toContain("row.status === 'PENDING_REVIEW'")
expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'") expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'")
expect(transportForm).toContain("form.status === 'PENDING_REVIEW'") expect(transportForm).toContain("form.status === 'PENDING_REVIEW'")
@@ -13,6 +13,17 @@ const fee = (overrides: Partial<OverseasFee> = {}): OverseasFee => ({
}) })
describe('海外费用动作资格', () => { describe('海外费用动作资格', () => {
it.each(['DRAFT', 'PENDING'])('审核前按服务端资格和独立权限展示调整、作废:%s', reviewStatus => {
const pending = fee({ reviewStatus, allowedActions: ['ADJUST', 'VOID', 'APPROVE', 'REJECT'] })
expect(feeActions(pending, () => true).map(row => row.key)).toEqual(['DETAIL', 'ADJUST', 'VOID', 'REVIEW'])
expect(feeActions(pending, permission => permission === feePermissions.edit).map(row => row.key)).toEqual(['DETAIL', 'ADJUST'])
expect(feeActions(pending, permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'VOID', 'REVIEW'])
expect(feeActions(fee({ reviewStatus }), () => true).map(row => row.key)).toEqual(['DETAIL'])
})
it.each(['BILL', 'WALLET'])('审核通过后即使旧白名单仍包含调整、作废也不展示:%s', collectionMode => {
const approved = fee({ reviewStatus: 'APPROVED', collectionMode, walletStatus: collectionMode === 'WALLET' ? 'SETTLED' : 'NOT_REQUIRED', allowedActions: ['ADJUST', 'VOID'] })
expect(feeActions(approved, () => true).map(row => row.key)).toEqual(['DETAIL'])
})
it('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => { it('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => {
expect(feeTime('2026-10-23T10:43:25Z')).toBe(feeTime('2026-10-23T18:43:25+08:00')) expect(feeTime('2026-10-23T10:43:25Z')).toBe(feeTime('2026-10-23T18:43:25+08:00'))
const date = new Date('2026-10-23T10:43:25Z') const date = new Date('2026-10-23T10:43:25Z')
@@ -79,6 +79,7 @@ export function feeActions(fee: OverseasFee, can: (permission: string) => boolea
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus) const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
const canReview = can(feePermissions.review) const canReview = can(feePermissions.review)
const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action)) const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action))
.filter(action => !['ADJUST', 'VOID'].includes(action) || ['DRAFT', 'PENDING'].includes(fee.reviewStatus))
.filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action)) .filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action))
.filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit)) .filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit))
.map(action => ({ key: action, label: actionLabels[action]!, priority: 20, .map(action => ({ key: action, label: actionLabels[action]!, priority: 20,
@@ -1,7 +1,7 @@
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue' import { reactive, ref } from 'vue'
import { ApiResponseError } from '@/shared/api/http' import { ApiResponseError } from '@/shared/api/http'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue' import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness' import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
@@ -36,6 +36,22 @@ beforeEach(() => {
}) })
describe('looseboard document fees', () => { describe('looseboard document fees', () => {
it('refreshes the current status when returning to a cached document tab', async () => {
const visible = ref(true)
vi.mocked(getLocalLooseboardOrder).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND' }))
.mockResolvedValueOnce(order({ status: 'PENDING_DISPATCH' }))
const wrapper = mount({ components: { LooseboardOrderDetailView }, setup: () => ({ visible }),
template: '<KeepAlive><LooseboardOrderDetailView v-if="visible" /></KeepAlive>',
}, { global: { plugins: [ElementPlus], stubs: { BusinessDocumentFees: true, FleetArrangementSummary: true, DataTable: true } } })
await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledTimes(1)
expect(wrapper.find('.status-badge').text()).toBe('待入仓')
visible.value = false; await flushPromises()
visible.value = true; await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledTimes(2)
expect(wrapper.find('.status-badge').text()).toBe('待派车')
})
it('loads the customer scoped document and passes its exact identity to fees', async () => { it('loads the customer scoped document and passes its exact identity to fees', async () => {
const wrapper = mountDetail(); await flushPromises() const wrapper = mountDetail(); await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999') expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999')
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue' import { computed, onActivated, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue' import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
@@ -14,6 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness' import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue' import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue' import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TransferPickupProgressPanel from '@/modules/overseas-warehouse/components/TransferPickupProgressPanel.vue'
import { poolOrderStatusLabels } from '../fleetArrangement' import { poolOrderStatusLabels } from '../fleetArrangement'
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types' import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
@@ -58,6 +59,12 @@ async function load() {
finally { if (sequence === requestSequence) loading.value = false } finally { if (sequence === requestSequence) loading.value = false }
} }
watch([orderNo, customerId], load, { immediate: true }) watch([orderNo, customerId], load, { immediate: true })
// 首次由路由加载;缓存标签重新打开时读取PDA和排车已推进的最新状态。
let activatedOnce = false
onActivated(() => {
if (activatedOnce) void load()
activatedOnce = true
})
</script> </script>
<template> <template>
@@ -73,6 +80,7 @@ watch([orderNo, customerId], load, { immediate: true })
<el-skeleton v-else-if="loading" :rows="8" animated /> <el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail"> <template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section> <section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section v-if="detail.deliveryMode === 'WAREHOUSE_PICKUP' && detail.transferNo" class="looseboard-detail-section"><el-alert title="上门取货需在对应取货排车的作业节点上传取货及到仓资料。" type="info" :closable="false" /><TransferPickupProgressPanel :transfer-no="detail.transferNo" :customer-id="detail.customerId" /></section>
<section class="looseboard-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 class="looseboard-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 class="looseboard-detail-section"><BusinessDocumentFees business-type="LOCAL_LOOSEBOARD" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section> <section class="looseboard-detail-section"><BusinessDocumentFees business-type="LOCAL_LOOSEBOARD" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section> <section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
@@ -238,6 +238,27 @@ describe('overseas business mounted pages', () => {
}) })
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.each([
{ status: 'REJECTED' as const, label: '已拒绝' },
{ status: 'CANCELLED' as const, label: '已取消' },
])('filters procurement orders by $status and resets to all statuses', async ({ status, label }) => {
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView)
await flushPromises()
const statusSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部状态')!
expect(statusSelect.findAllComponents({ name: 'ElOption' }).map(option => ({ value: option.props('value'), label: option.props('label') }))).toEqual(expect.arrayContaining([
{ value: 'REJECTED', label: '已拒绝' }, { value: 'CANCELLED', label: '已取消' },
]))
statusSelect.vm.$emit('update:modelValue', status)
await wrapper.vm.$nextTick()
await wrapper.findAll('button').find(button => button.text() === '查询')!.trigger('click')
await flushPromises()
expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
expect(wrapper.find('.el-table__body .el-tag').text()).toBe(label)
await wrapper.findAll('button').find(button => button.text() === '重置')!.trigger('click')
await flushPromises()
expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ status: '' }))
})
it('reviews pending procurement from the list, submits rejection as remark, and omits edit', async () => { it('reviews pending procurement from the list, submits rejection as remark, and omits edit', async () => {
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status: 'PENDING_REVIEW' }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status: 'PENDING_REVIEW' }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView); await flushPromises() const wrapper = mountPage(PurchaseOrdersView); await flushPromises()
@@ -386,7 +407,7 @@ describe('overseas business mounted pages', () => {
? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址'] ? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址']
: ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址'] : ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址']
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels]) expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels])
Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' }) Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originAddress: ' Pickup street ', destinationAddress: ' Delivery street ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')! const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises() await submit.trigger('click'); await flushPromises()
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled() expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
@@ -402,19 +423,22 @@ describe('overseas business mounted pages', () => {
}) })
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() }) it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('opens the requested truck review from the list using its customer scope', async () => { it('shows a truck detail entry instead of review and edit and keeps its customer scope', async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises() const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status') const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const review = wrapper.findAll('button').find(button => button.text() === '审核') const actions = wrapper.findAll('.el-table__body-wrapper button')
expect(review).toBeDefined(); await review!.trigger('click'); await flushPromises() expect(actions.map(button => button.text())).toContain('详情')
expect(actions.map(button => button.text())).not.toContain('审核')
expect(actions.map(button => button.text())).not.toContain('编辑')
await actions.find(button => button.text() === '详情')!.trigger('click'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } }) expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } })
}) })
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') }) it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) }) 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('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('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 model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 2, budgetAmount: 100 }); 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('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 model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 2, budgetAmount: 100, originAddress: 'Pickup street', destinationAddress: 'Delivery street' }); 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 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('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 () => { it('requires split contacts and both local times, rejecting reversed times', async () => {
@@ -60,7 +60,7 @@ async function load() {
if (isLooseboard.value) { if (isLooseboard.value) {
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value) const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version }) Object.assign(form, { deliveryMode: detail.deliveryMode || 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: detail.operatingWarehouseId || '', destinationType: detail.destinationType || 'PRIVATE', destinationPostalCode: detail.destinationPostalCode || '', deliveryContactName: detail.deliveryContactName || '', deliveryContactPhone: detail.deliveryContactPhone || '', estimatedArrivalAt: detail.estimatedArrivalAt || '', trackingNo: detail.trackingNo || '', customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version })
} else { } else {
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value) const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement fleetArrangement.value = detail.fleetArrangement
@@ -5,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import RowActions from '@/shared/components/RowActions.vue' import RowActions from '@/shared/components/RowActions.vue'
import type { DataTableColumn } from '@/shared/components/DataTable.vue'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness' import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions' import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalLooseboardOrder } from '../types' import type { LocalLooseboardOrder, LocalTransportOrder } from '../types'
import TransportOrdersView from './TransportOrdersView.vue' import TransportOrdersView from './TransportOrdersView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { query: {} } })) const routing = vi.hoisted(() => ({ push: vi.fn(), route: { query: {} } }))
@@ -30,7 +31,7 @@ function mountList(domain: 'truck' | 'looseboard', edit = false) {
auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])] auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])]
useAccessPreviewStore().initializeAfterLogin() useAccessPreviewStore().initializeAfterLogin()
return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, ElementPlus], stubs: { return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, ElementPlus], stubs: {
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>' }, DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.id"><span v-for="column in columns" :key="column.key" :data-column="column.key">{{ column.prop ? row[column.prop] : \'\' }}</span><slot name="actions" :row="row" /></div></div>' },
AppDialog: true, AppDialog: true,
} } }) } } })
} }
@@ -52,7 +53,7 @@ describe('transport document entry', () => {
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-detail', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } }) expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-detail', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
}) })
it('preserves the permitted looseboard edit entry alongside its detail', async () => { it('lets authorized users complete missing receiving information before review', async () => {
const wrapper = mountList('looseboard', true); await flushPromises() const wrapper = mountList('looseboard', true); await flushPromises()
const actions = wrapper.findComponent(RowActions) const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('编辑') expect(actions.text()).toContain('编辑')
@@ -65,4 +66,34 @@ describe('transport document entry', () => {
expect(listUsTruckOrders).toHaveBeenCalledOnce() expect(listUsTruckOrders).toHaveBeenCalledOnce()
expect(listLocalLooseboardOrders).not.toHaveBeenCalled() expect(listLocalLooseboardOrders).not.toHaveBeenCalled()
}) })
it('displays truck pallet counts instead of the unused package count', async () => {
const truck: LocalTransportOrder = {
id: '9007199254741001', customerId: '9007199254741999', orderNo: 'TR-TEST', fleetArrangement: null,
vehicleType: 'PALLET_15', origin: 'LA', destination: 'ONT', palletCount: 3, packageCount: 0,
receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0,
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
originContactName: null, originContactPhone: null, destinationContactName: null, destinationContactPhone: null,
pickupAt: null, deliveryAt: null, status: 'PENDING_REVIEW', version: 1, createdAt: '', updatedAt: '',
}
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [truck], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = mountList('truck'); await flushPromises()
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as DataTableColumn[]
expect(columns.find(column => column.key === 'palletCount')).toMatchObject({ label: '托数', prop: 'palletCount' })
expect(columns.some(column => column.key === 'packageCount')).toBe(false)
expect(wrapper.get('[data-column="palletCount"]').text()).toBe('3')
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('详情')
expect(actions.text()).not.toContain('审核')
expect(actions.text()).not.toContain('编辑')
})
it('preserves looseboard package counts and forecast pallet counts', async () => {
const wrapper = mountList('looseboard'); await flushPromises()
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as DataTableColumn[]
expect(columns.find(column => column.key === 'packageCount')).toMatchObject({ label: '件数', prop: 'packageCount' })
expect(columns.find(column => column.key === 'estimatedPalletCount')).toMatchObject({ label: '预计托数', prop: 'estimatedPalletCount' })
expect(wrapper.get('[data-column="packageCount"]').text()).toBe('20')
expect(wrapper.get('[data-column="estimatedPalletCount"]').text()).toBe('2')
})
}) })
@@ -15,6 +15,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { truckStatusLabels } from '../truckWorkflow' import { truckStatusLabels } from '../truckWorkflow'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue' import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import LooseboardReviewDialog from '../components/LooseboardReviewDialog.vue'
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement' import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -23,6 +24,7 @@ import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types' import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>() const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const reviewOpen = ref(false), reviewOrder = ref<LocalLooseboardOrder | null>(null)
const routeOpen = ref(false) const routeOpen = ref(false)
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([]) const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
function openRoute(row: LocalTransportOrder | LocalLooseboardOrder) { function openRoute(row: LocalTransportOrder | LocalLooseboardOrder) {
@@ -34,7 +36,7 @@ const route = useRoute(); const router = useRouter(); const access = useAccessPr
const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车') const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query') const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate) const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
const editPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.truckEdit) const editPermission = computed(() => overseasBusinessPermissions.truckEdit)
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null) const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0) const page = ref(1); const pageSize = ref(20); const total = ref(0)
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null) const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
@@ -60,7 +62,7 @@ const truckColumns: DataTableColumn[] = [
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 },
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` }, { key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) }, { key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) },
{ key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) }, { key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) },
@@ -104,7 +106,7 @@ async function load() {
} }
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 } function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) } function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') } function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' && !(row as LocalLooseboardOrder).deliveryMode : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) } function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
/** 单据详情保留订单与客户范围,费用在原业务单中处理。 */ /** 单据详情保留订单与客户范围,费用在原业务单中处理。 */
function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) { function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
@@ -114,19 +116,20 @@ function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] { function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] {
return [ return [
{ key: 'detail', label: '详情', priority: 10 }, { key: 'detail', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) }, { key: 'review', label: '审核', visible: row.status === 'PENDING_ACCEPTANCE' && access.can(overseasBusinessPermissions.looseboardStatus), priority: 20 },
{ key: 'edit', label: '编辑', visible: row.status === 'PENDING_ACCEPTANCE' && !row.deliveryMode && access.can(overseasBusinessPermissions.looseboardEdit) },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement }, { key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
] ]
} }
function truckActions(row: LocalTransportOrder): RowActionItem[] { function truckActions(row: LocalTransportOrder): RowActionItem[] {
return [ return [
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 }, { key: 'detail', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement }, { key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
] ]
} }
function handleTruckAction(key: string, row: LocalTransportOrder | LocalLooseboardOrder) { function handleTruckAction(key: string, row: LocalTransportOrder | LocalLooseboardOrder) {
if (key === 'edit') openEdit(row) if (key === 'review' && isLooseboard.value) { reviewOrder.value=row as LocalLooseboardOrder; reviewOpen.value=true }
else if (key === 'edit') openEdit(row)
else if (key === 'arrangement') showArrangement(row) else if (key === 'arrangement') showArrangement(row)
else if (key === 'detail') openDetail(row) else if (key === 'detail') openDetail(row)
} }
@@ -136,6 +139,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
</script> </script>
<template> <template>
<LooseboardReviewDialog v-model="reviewOpen" :order="reviewOrder" @saved="load" />
<AdminPage class="admin-density admin-workbench"><ListPageLayout :aria-label="`${domainLabel}订单列表`"> <AdminPage class="admin-density admin-workbench"><ListPageLayout :aria-label="`${domainLabel}订单列表`">
@@ -68,6 +68,14 @@ describe('truck review and fulfillment', () => {
}) })
}) })
it('displays saved pallet counts instead of the unused package count', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ palletCount: 3, packageCount: 0 }))
const wrapper = mountDetail(); await flushPromises()
const facts = new Map(wrapper.findAll('.truck-detail-fact').map(fact => [fact.get('dt').text(), fact.get('dd').text()]))
expect(facts.get('托数')).toBe('3')
expect(facts.has('件数')).toBe(false)
})
it('groups the business number, single status and actions in the shared detail header', async () => { it('groups the business number, single status and actions in the shared detail header', async () => {
const wrapper = mountDetail(); await flushPromises() const wrapper = mountDetail(); await flushPromises()
const header = wrapper.find('.detail-page-layout__header') const header = wrapper.find('.detail-page-layout__header')
@@ -75,7 +83,7 @@ describe('truck review and fulfillment', () => {
expect(header.find('.status-badge').text()).toBe('待审核') expect(header.find('.status-badge').text()).toBe('待审核')
expect(wrapper.findAll('.status-badge')).toHaveLength(1) expect(wrapper.findAll('.status-badge')).toHaveLength(1)
expect(header.find('.truck-order-actions').text()).toContain('审核') expect(header.find('.truck-order-actions').text()).toContain('审核')
expect(header.find('.truck-order-actions').text()).toContain('取消订单') expect(header.find('.truck-order-actions').text()).not.toContain('取消订单')
await click(wrapper, '刷新订单') await click(wrapper, '刷新订单')
expect(getUsTruckOrder).toHaveBeenCalledTimes(2) expect(getUsTruckOrder).toHaveBeenCalledTimes(2)
await click(wrapper, '返回列表') await click(wrapper, '返回列表')
@@ -118,6 +126,9 @@ describe('truck review and fulfillment', () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车') const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
formModel(wrapper).remark = '核对完成'; await confirm(wrapper) formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
expect(wrapper.text()).toContain('订单版本冲突') expect(wrapper.text()).toContain('订单版本冲突')
expect(wrapper.text()).not.toContain('TRUCK-409')
expect(wrapper.text()).not.toContain('请求 ID')
expect(wrapper.text()).not.toContain('可修改后重新提交')
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true) expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2])) const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises() wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
@@ -152,11 +163,10 @@ describe('truck review and fulfillment', () => {
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘'); expect(wrapper.text()).not.toContain('超托审核') 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 () => { it('does not offer platform cancellation of a customer truck order', async () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '取消订单'); await confirm(wrapper) const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).not.toContain('取消订单')
expect(executeUsTruckCommand).not.toHaveBeenCalled() expect(executeUsTruckCommand).not.toHaveBeenCalled()
formModel(wrapper).remark = '客户取消需求'; vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'CANCELLED', version: 2 })); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'cancel', payload: expect.objectContaining({ status: 'CANCELLED', remark: '客户取消需求' }) }))
}) })
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => { it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
@@ -42,7 +42,7 @@ const fields = computed(() => detail.value ? [
['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone], ['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone],
['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress], ['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress],
['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone], ['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone],
['件数', detail.value.packageCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm], ['托数', detail.value.palletCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`], ['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)], ['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark], ['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
@@ -97,7 +97,7 @@ watch([orderNo, customerId], load, { immediate: true })
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /><p class="truck-detail-note">取货和送达照片请在排车任务中上传。</p></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="运输流程"> <section class="truck-detail-section truck-flow-section" aria-label="运输流程">
<div class="truck-section-heading"><h2>运输流程</h2><span class="truck-flow-caption">审核通过后由排车单推进取货、派送和送达</span></div> <div class="truck-section-heading"><h2>运输流程</h2></div>
<ol class="truck-flow"> <ol class="truck-flow">
<li v-for="(step, index) in flowSteps" :key="step" :class="{ active: index === flowIndex, done: index < flowIndex || detail.status === 'COMPLETED' }"> <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-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>
@@ -106,9 +106,9 @@ watch([orderNo, customerId], load, { immediate: true })
</ol> </ol>
</section> </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"><div class="truck-section-heading"><h2>订单资料</h2></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"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></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> <section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template> </template>
</DetailPageLayout></AdminPage> </DetailPageLayout></AdminPage>
</template> </template>
@@ -121,7 +121,7 @@ watch([orderNo, customerId], load, { immediate: true })
.truck-detail-section { margin-bottom: var(--admin-space-6); } .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 { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.truck-section-heading h2 { margin: 0; font-size: var(--admin-font-section); color: var(--cc-text); } .truck-section-heading h2 { margin: 0; font-size: var(--admin-font-section); color: var(--cc-text); }
.truck-flow-caption, .truck-detail-note { color: var(--cc-muted); font-size: var(--admin-font-caption); } .truck-detail-note { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.truck-detail-note { margin: 10px 0 0; } .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 { 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 { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; color: var(--cc-muted); text-align: center; }
@@ -4,12 +4,13 @@ import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = { export const procurementStatuses: Record<string, string> = {
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认', FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成', IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成',
REJECTED: '已拒绝', CANCELLED: '已取消',
} }
export const feePurposes: Record<string, string> = { export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费', SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款', DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
} }
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) } export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' { export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'COMPLETED') return 'success' if (status === 'COMPLETED') return 'success'
if ((status === 'REJECTED' || status === 'FUNDING_FAILED') || status === 'EXCEPTION_CLOSED') return 'danger' if ((status === 'REJECTED' || status === 'FUNDING_FAILED') || status === 'EXCEPTION_CLOSED') return 'danger'
@@ -9,6 +9,7 @@ it('distinguishes missing charges, genuine free rent and incomplete settlement',
expect(storageRentSummaryStatus({ ...lot, settledAmount: 0 })).toBe('免租 · 已结算') expect(storageRentSummaryStatus({ ...lot, settledAmount: 0 })).toBe('免租 · 已结算')
expect(storageRentSummaryStatus({ ...lot, chargeCount: 0 })).toBe('仓租记录待核对') expect(storageRentSummaryStatus({ ...lot, chargeCount: 0 })).toBe('仓租记录待核对')
expect(storageRentBillingShare(2, lot)).toBe('0.5 托') expect(storageRentBillingShare(2, lot)).toBe('0.5 托')
expect(storageRentBillingShare(1, lot, { billingQuantity: 1, billingUnit: 'PALLET' })).toBe('1 托')
}) })
describe('仓租原币汇总显示', () => { describe('仓租原币汇总显示', () => {
@@ -1,5 +1,5 @@
import { feeMoney } from './overseasFeePresentation' import { feeMoney } from './overseasFeePresentation'
import type { StorageRentLot } from './api/storageRent' import type { StorageRentLot, StorageRentCharge } from './api/storageRent'
export function storageRentSourceType(type?: string | null) { export function storageRentSourceType(type?: string | null) {
const labels: Record<string, string> = { LASTLEG_ORDER: '头程尾程订单', WAREHOUSE_TRANSFER: '仓储转运单', RETURN_ORDER: '退货单' } const labels: Record<string, string> = { LASTLEG_ORDER: '头程尾程订单', WAREHOUSE_TRANSFER: '仓储转运单', RETURN_ORDER: '退货单' }
@@ -24,7 +24,8 @@ export function storageRentSummaryStatus(lot: StorageRentLot) {
return Number(lot.settledAmount) === 0 ? '免租 · 已结算' : '已扣款' return Number(lot.settledAmount) === 0 ? '免租 · 已结算' : '已扣款'
} }
export function storageRentBillingShare(quantity: number, lot: StorageRentLot) { export function storageRentBillingShare(quantity: number, lot: StorageRentLot, charge?: Pick<StorageRentCharge, 'billingQuantity' | 'billingUnit'>) {
if (charge?.billingQuantity != null && charge.billingUnit) return `${charge.billingQuantity} ${charge.billingUnit === 'PALLET' ? '托' : charge.billingUnit}`
if (!(Number(lot.originalQuantity) > 0) || lot.billingQuantity == null) return '—' if (!(Number(lot.originalQuantity) > 0) || lot.billingQuantity == null) return '—'
const units = Number(quantity) * Number(lot.billingQuantity) / Number(lot.originalQuantity) const units = Number(quantity) * Number(lot.billingQuantity) / Number(lot.originalQuantity)
return `${Number(units.toFixed(6))} ${lot.billingUnit === 'PALLET' ? '托' : lot.billingUnit}` return `${Number(units.toFixed(6))} ${lot.billingUnit === 'PALLET' ? '托' : lot.billingUnit}`
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { createTransportDemandForm, transportDemandError, transportDemandRules, buildTransportDemand } from './transportDemandForm'
import { truckStatusLabels } from './truckWorkflow'
describe('truck order validation and state filters', () => {
it('requires both detailed addresses before submitting a truck order', () => {
const form = Object.assign(createTransportDemandForm(), {
vehicleType: 'PALLET_15' as const, origin: 'LA', destination: 'ONT',
originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2',
pickupAt: '2026-11-02T12:00:00', deliveryAt: '2026-11-03T12:00:00', estimatedPalletCount: 1, budgetAmount: 100,
})
expect(transportDemandRules('truck', form)).toMatchObject({ originAddress: [{ required: true }], destinationAddress: [{ required: true }] })
expect(transportDemandError('truck', form)).toBe('请输入取货地址')
form.originAddress = ' 取货街道1号 '
expect(transportDemandError('truck', form)).toBe('请输入送达地址')
form.destinationAddress = ' '
expect(() => buildTransportDemand('truck', form)).toThrow('请输入送达地址')
form.destinationAddress = ' 送达街道2号 '
expect(buildTransportDemand('truck', form)).toMatchObject({ originAddress: '取货街道1号', destinationAddress: '送达街道2号' })
})
it('assigns each filter a distinct Chinese state label', () => {
const labels = Object.values(truckStatusLabels)
expect(new Set(labels).size).toBe(labels.length)
expect(truckStatusLabels.PENDING_DISPATCH).toBe('待排车')
expect(truckStatusLabels.DISPATCHED).toBe('待取货')
expect(labels.every(label => /[\u4e00-\u9fff]/.test(label))).toBe(true)
})
})
@@ -2,6 +2,14 @@ import type { FormRules } from 'element-plus'
export type TransportDemandKind = 'truck' | 'looseboard' export type TransportDemandKind = 'truck' | 'looseboard'
export interface TransportDemandForm { export interface TransportDemandForm {
deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId: string
deliveryContactName: string
deliveryContactPhone: string
estimatedArrivalAt: string
trackingNo: string
destinationType: 'FBA' | 'PRIVATE'
destinationPostalCode: string
vehicleType: '' | 'PALLET_15' | 'PALLET_30' vehicleType: '' | 'PALLET_15' | 'PALLET_30'
origin: string origin: string
destination: string destination: string
@@ -25,6 +33,7 @@ export interface TransportDemandForm {
export function createTransportDemandForm(): TransportDemandForm { export function createTransportDemandForm(): TransportDemandForm {
return { return {
deliveryMode: 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: '', deliveryContactName: '', deliveryContactPhone: '', estimatedArrivalAt: '', trackingNo: '', destinationType: 'PRIVATE', destinationPostalCode: '',
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '', vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '', originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0, productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
@@ -32,11 +41,10 @@ export function createTransportDemandForm(): TransportDemandForm {
} }
} }
const requiredText = (kind: TransportDemandKind) => ({ const requiredText = (kind: TransportDemandKind, form: TransportDemandForm) => ({
origin: kind === 'truck' ? '取货地' : '起运地', ...(kind === 'truck' ? { originAddress: '取货地址', destinationAddress: '送达地址' } : {}),
...(kind === 'truck' || form.deliveryMode === 'WAREHOUSE_PICKUP' ? { origin: kind === 'truck' ? '取货地' : '起运地', originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人', originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话' } : {}),
destination: kind === 'truck' ? '送达地' : '目的地', destination: kind === 'truck' ? '送达地' : '目的地',
originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人',
originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话',
destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人', destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
destinationContactPhone: kind === 'truck' ? '送达联系电话' : '目的地联系电话', destinationContactPhone: kind === 'truck' ? '送达联系电话' : '目的地联系电话',
} as const) } as const)
@@ -47,10 +55,21 @@ export function localMinuteInput(value: unknown) {
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> { export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
const rules: FormRules<TransportDemandForm> = {} const rules: FormRules<TransportDemandForm> = {}
const labels = requiredText(kind) const labels = requiredText(kind, form)
for (const [field, label] of Object.entries(labels)) { for (const [field, label] of Object.entries(labels)) {
rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }] rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
} }
if (kind === 'looseboard') {
if (form.deliveryMode === 'WAREHOUSE_PICKUP') rules.originAddress = [{ required: true, whitespace: true, message: '请输入起运详细地址', trigger: 'blur' }]
rules.operatingWarehouseId = [{ required: true, message: '请选择收货仓库', trigger: 'change' }]
if (form.destinationType === 'PRIVATE') {
rules.destinationAddress = [{ required: true, whitespace: true, message: '请输入目的详细地址', trigger: 'blur' }]
rules.destinationPostalCode = [{ required: true, whitespace: true, message: '请输入目的地邮编', trigger: 'blur' }]
}
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE') {
for (const [field, label] of [['deliveryContactName', '到仓联系人'], ['deliveryContactPhone', '到仓联系电话'], ['estimatedArrivalAt', '预计到货时间']] as const) rules[field] = [{ required: true, message: '请填写' + label, trigger: 'change' }]
}
}
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }] if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) { for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
if (field === 'packageCount' && kind === 'truck') continue if (field === 'packageCount' && kind === 'truck') continue
@@ -66,8 +85,16 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
} }
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined { export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
if (kind === 'looseboard') {
if (!form.operatingWarehouseId) return '请选择收货仓库'
if (!['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP'].includes(form.deliveryMode)) return '请选择送货方式'
if (form.destinationType === 'PRIVATE' && (!form.destinationAddress.trim() || !form.destinationPostalCode.trim())) return '请填写目的详细地址和邮编'
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' && (!form.deliveryContactName.trim() || !form.deliveryContactPhone.trim() || !form.estimatedArrivalAt)) return '送货到仓须填写仓库联系人和预计到货时间'
if (form.deliveryMode === 'WAREHOUSE_PICKUP' && !form.originAddress.trim()) return '请输入起运详细地址'
if (form.packageCount < 1) return '散板收货件数须大于 0'
}
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型' if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
const labels = requiredText(kind) const labels = requiredText(kind, form)
for (const [field, label] of Object.entries(labels)) { for (const [field, label] of Object.entries(labels)) {
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}` if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
} }
@@ -102,15 +129,15 @@ function commonPayload(form: TransportDemandForm) {
type CommonPayload = ReturnType<typeof commonPayload> type CommonPayload = ReturnType<typeof commonPayload>
type TruckPayload = Omit<CommonPayload, 'packageCount'> & { palletCount: number; vehicleType: 'PALLET_15' | 'PALLET_30' } type TruckPayload = Omit<CommonPayload, 'packageCount'> & { palletCount: number; vehicleType: 'PALLET_15' | 'PALLET_30' }
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number } type LooseboardPayload = CommonPayload & { deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'; operatingWarehouseId: string; destinationType: 'FBA' | 'PRIVATE'; destinationPostalCode?: string; deliveryContactName?: string; deliveryContactPhone?: string; estimatedArrivalAt?: string; trackingNo?: string; productName: string | undefined; estimatedPalletCount: number }
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) { export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm): TruckPayload | LooseboardPayload {
const failure = transportDemandError(kind, form) const failure = transportDemandError(kind, form)
if (failure) throw new Error(failure) if (failure) throw new Error(failure)
const common = commonPayload(form) const common = commonPayload(form)
return kind === 'truck' return kind === 'truck'
? { ...(({ packageCount: _packages, ...rest }) => rest)(common), palletCount: form.estimatedPalletCount, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' } ? { ...(({ packageCount: _packages, ...rest }) => rest)(common), palletCount: form.estimatedPalletCount, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount } : { ...common, ...(form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? { origin: '', originAddress: undefined, originContactName: '', originContactPhone: '' } : {}), deliveryMode: form.deliveryMode, operatingWarehouseId: form.operatingWarehouseId, deliveryContactName: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactName.trim() : undefined, deliveryContactPhone: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactPhone.trim() : undefined, estimatedArrivalAt: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? localMinuteInput(form.estimatedArrivalAt) : undefined, trackingNo: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.trackingNo.trim() || undefined : undefined, destinationType: form.destinationType, destinationPostalCode: form.destinationType === 'PRIVATE' ? form.destinationPostalCode.trim() : undefined, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
} }
@@ -1,9 +1,9 @@
import type { LocalTransportDetail, LocalTransportStatus } from './types' import type { LocalTransportDetail, LocalTransportStatus } from './types'
export const truckStatusLabels: Record<LocalTransportStatus, string> = { export const truckStatusLabels: Record<LocalTransportStatus, string> = {
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', WAITING_INBOUND: '待取货', RECEIVING: '待取货', WAITING_CONSOLIDATION: '待取货', FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
PALLET_OVERAGE_REVIEW: '待取货', PARTIALLY_ALLOCATED: '待取货', ALLOCATED: '待取货', PALLET_OVERAGE_REVIEW: '超托待审核', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配',
PENDING_DISPATCH: '待取货', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消', PENDING_DISPATCH: '待排车', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
} }
export const truckCommandLabels = { export const truckCommandLabels = {
review: '审核并生成排车', arrange: '补建排车单', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘', review: '审核并生成排车', arrange: '补建排车单', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
@@ -23,8 +23,8 @@ export type TruckOperation =
/** 拼车来源只读;原单只保留审核入口,排车后的执行动作统一在排车任务完成。 */ /** 拼车来源只读;原单只保留审核入口,排车后的执行动作统一在排车任务完成。 */
export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] { export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] {
if (order.fleetArrangement) return [] if (order.fleetArrangement) return []
if (order.status === 'PENDING_REVIEW') return ['review', 'cancel'] if (order.status === 'PENDING_REVIEW') return ['review']
return order.status === 'PENDING_DISPATCH' ? ['arrange', 'cancel'] : [] return order.status === 'PENDING_DISPATCH' ? ['arrange'] : []
} }
export const truckCommandHints: Record<TruckCommand, string> = { export const truckCommandHints: Record<TruckCommand, string> = {
+21 -1
View File
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
} }
export type OverseasPurchaseStatus = 'FUNDING_PENDING' | 'FUNDING_FAILED' | 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED' export type OverseasPurchaseStatus = 'FUNDING_PENDING' | 'FUNDING_FAILED' | 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
export interface OverseasPurchaseQuery { export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | '' purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
@@ -72,6 +72,16 @@ export interface LocalLooseboardQuery {
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
} }
export interface LocalLooseboardOrder { export interface LocalLooseboardOrder {
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId?: string
destinationType?: 'FBA' | 'PRIVATE'
destinationPostalCode?: string
deliveryContactName?: string
deliveryContactPhone?: string
estimatedArrivalAt?: string
trackingNo?: string
transferNo?: string
fleetArrangement: import('./fleetArrangement').FleetArrangement | null fleetArrangement: import('./fleetArrangement').FleetArrangement | null
id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null
transportType: LocalTransportType | null; origin: string; destination: string; productName: string | null; estimatedPalletCount: number | null; packageCount: number transportType: LocalTransportType | null; origin: string; destination: string; productName: string | null; estimatedPalletCount: number | null; packageCount: number
@@ -84,6 +94,16 @@ export interface LocalLooseboardDetail extends LocalLooseboardOrder {
histories: OverseasBusinessHistory[] histories: OverseasBusinessHistory[]
} }
export interface CreateLocalLooseboardOrderRequest { export interface CreateLocalLooseboardOrderRequest {
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId?: string
destinationType?: 'FBA' | 'PRIVATE'
destinationPostalCode?: string
deliveryContactName?: string
deliveryContactPhone?: string
estimatedArrivalAt?: string
trackingNo?: string
transferNo?: string
productName?: string | null; estimatedPalletCount: number productName?: string | null; estimatedPalletCount: number
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
packageCount: number; totalWeightKg: number; totalVolumeCbm: number packageCount: number; totalWeightKg: number; totalVolumeCbm: number
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getDispatch, listLooseboardCargo, addLooseboardCargo, expandSelectionGroups, type Candidate, type DispatchDetail } from '@/modules/overseas-warehouse'
const open=defineModel<boolean>({default:false})
const props=defineProps<{dispatchNo:string}>()
const emit=defineEmits<{saved:[]}>()
const rows=ref<Candidate[]>([]), selected=ref<Candidate[]>([]), detail=ref<DispatchDetail|null>(null)
const page=ref(1), size=ref(20), total=ref(0), loading=ref(false), saving=ref(false), error=ref<ApiErrorDetails|null>(null)
const columns:DataTableColumn[]=[
{key:'selection',type:'selection',width:45}, {key:'orderNo',label:'订单号',minWidth:180},
{key:'customerName',label:'客户',minWidth:140}, {key:'inboundCode',label:'入仓号',minWidth:150},
{key:'sourceStage',label:'货物阶段',width:110,formatter:(row:Candidate)=>!row.selection.warehousePalletId ? '申报待到仓' : '已实收'}, {key:'palletNo',label:'计划 / 实物托盘',minWidth:150}, {key:'destination',label:'目的地',minWidth:180,formatter:(row:Candidate)=>row.destination.warehouseCode || row.destination.address || '—'},
{key:'availablePackages',label:'计划 / 可用箱数',width:100},
{key:'blockReasons',label:'资格',minWidth:200,formatter:(row:Candidate)=>row.blockReasons.length?row.blockReasons.join(';'):'可添加'},
]
let generation=0
async function load() {
const current=++generation;loading.value=true;error.value=null;selected.value=[];rows.value=[];detail.value=null
try {
const [dispatch,value]=await Promise.all([getDispatch(props.dispatchNo),listLooseboardCargo(props.dispatchNo,page.value,size.value)])
if(current!==generation)return
if(dispatch.dispatchNo!==props.dispatchNo)throw new Error('排车身份不一致,请重新打开')
detail.value=dispatch;rows.value=value.candidates;total.value=value.total
}catch(cause){if(current===generation)error.value=getApiErrorDetails(cause,'散板货物读取失败')}
finally{if(current===generation)loading.value=false}
}
async function submit(){
if(!detail.value || saving.value || !selected.value.length)return
saving.value=true;error.value=null
try{
if(selected.value.some(row=>row.blockReasons.length))throw new Error('所选货物存在阻断,请重新选择')
const cargo=expandSelectionGroups(selected.value)
// 完整组来源版本由后端复核;预约批次继续通过原排车预约工作台绑定。
await addLooseboardCargo(props.dispatchNo,{recordVersion:detail.value.recordVersion,idempotencyKey:crypto.randomUUID(),cargo,batchAllocations:[]})
ElMessage.success('散板货物已加入排车,请按当前货物重新审核');open.value=false;emit('saved')
}catch(cause){await load();error.value=getApiErrorDetails(cause,'添加货物失败')}
finally{saving.value=false}
}
watch(open,value=>{if(value){page.value=1;void load()}else generation++})
watch([page,size],()=>{if(open.value)void load()})
</script>
<template><AppDialog v-model="open" title="添加散板货物" width="1180px" class="admin-density-dialog admin-workbench" :confirm-loading="saving" :confirm-disabled="loading || !selected.length || !detail?.actions.ADD_CARGO?.allowed" lock-while-submitting @confirm="submit">
<LoadErrorState v-if="error" title="添加货物暂未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !detail.actions.ADD_CARGO?.allowed" :title="detail.actions.ADD_CARGO?.blockReason || '当前排车不可加货,请先退回审核或重新排车'" type="warning" :closable="false" />
<el-alert title="申报货物可先加入排车;实际到仓后核对货量,完成实收后才能出库拣货。上门取货的凭证在对应取货排车节点上传。" type="info" :closable="false" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="sourceKey" :page-sizes="[10,20,50]" empty-text="暂无可添加的已审核散板货物" @selection-change="selected=$event" />
</AppDialog></template>
@@ -2,6 +2,10 @@
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import LooseboardCargoDialog from '../components/LooseboardCargoDialog.vue'
const access=useAccessPreviewStore(), cargoOpen=ref(false), cargoDispatchNo=ref('')
function addCargo(row:DispatchCapacityDemand){cargoDispatchNo.value=row.dispatchNo;cargoOpen.value=true}
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -16,6 +20,7 @@ const filters = ref<QueryModel>({ origin: '', destination: '' }), applied = ref(
const fields: QueryField[] = [{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 190 }, { key: 'destination', label: '目的地', type: 'input', placeholder: '目的地名称', width: 190 }] const fields: QueryField[] = [{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 190 }, { key: 'destination', label: '目的地', type: 'input', placeholder: '目的地名称', width: 190 }]
const amount = (value: number | null | undefined) => value == null ? '—' : String(value) const amount = (value: number | null | undefined) => value == null ? '—' : String(value)
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{key:'actions',label:'操作',width:120,fixed:'left',slotName:'actions'},
{ key: 'dispatchNo', label: '排车单号', minWidth: 200 }, { key: 'dispatchNo', label: '排车单号', minWidth: 200 },
{ key: 'capacityReason', label: '余位状态', minWidth: 220, formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? '余位已确认' : row.capacityReason || '余位待确认', afterColumn: 'dispatchNo' }, { key: 'warehouseName', label: '起运仓', minWidth: 160 }, { key: 'capacityReason', label: '余位状态', minWidth: 220, formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? '余位已确认' : row.capacityReason || '余位待确认', afterColumn: 'dispatchNo' }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
{ key: 'route', label: '路线', minWidth: 240, formatter: (row: DispatchCapacityDemand) => row.route || '—' }, { key: 'route', label: '路线', minWidth: 240, formatter: (row: DispatchCapacityDemand) => row.route || '—' },
@@ -39,8 +44,10 @@ function search() { applied.value = { ...filters.value }; if (page.value === 1)
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }) watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script> </script>
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求"><div class="workbench-table is-page-surface"> <template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求"><div class="workbench-table is-page-surface">
<LooseboardCargoDialog v-model="cargoOpen" :dispatch-no="cargoDispatchNo" @saved="load" />
<DataTable appearance="integrated" preference-key="dispatch-capacity-demands" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="dispatchNo" empty-text="暂无已发布的散板需求"> <DataTable appearance="integrated" preference-key="dispatch-capacity-demands" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="dispatchNo" empty-text="暂无已发布的散板需求">
<template #toolbar-start><h1>散板需求</h1></template> <template #toolbar-start><h1>散板需求</h1></template>
<template #actions="{row}"><el-button v-if="(access.can('platform:fleet:capacity-offer:cargo-add') || access.can('platform:fleet:capacity-offer:manage')) && access.can('platform:overseas-warehouse:dispatch:edit') && access.can('platform:overseas-warehouse:dispatch:list')" link type="primary" @click="addCargo(row as DispatchCapacityDemand)">添加货物</el-button><span v-else>—</span></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="散板需求查询" :loading="loading" @search="search" /></template> <template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="散板需求查询" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="散板需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template> <template #notices><LoadErrorState v-if="error" title="散板需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #pagination-left>共 {{ total }} 条散板需求</template> <template #pagination-left>共 {{ total }} 条散板需求</template>
@@ -1,6 +1,7 @@
export const overseasMasterDataPermissions = { export const overseasMasterDataPermissions = {
capacityOfferList: 'platform:fleet:capacity-offer:list', capacityOfferList: 'platform:fleet:capacity-offer:list',
capacityOfferManage: 'platform:fleet:capacity-offer:manage', capacityOfferManage: 'platform:fleet:capacity-offer:manage',
capacityCargoAdd: 'platform:fleet:capacity-offer:cargo-add',
capacityInquiryCreate: 'platform:fleet:capacity-offer:inquiry-create', capacityInquiryCreate: 'platform:fleet:capacity-offer:inquiry-create',
capacityInquiryEdit: 'platform:fleet:capacity-offer:inquiry-edit', capacityInquiryEdit: 'platform:fleet:capacity-offer:inquiry-edit',
capacityInquiryCancel: 'platform:fleet:capacity-offer:inquiry-cancel', capacityInquiryCancel: 'platform:fleet:capacity-offer:inquiry-cancel',
@@ -62,4 +63,3 @@ export const overseasMasterDataPermissions = {
vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload', vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload',
vehicleRecordFinanceSubmit: 'platform:overseas-master-data:vehicle:record-finance-submit', vehicleRecordFinanceSubmit: 'platform:overseas-master-data:vehicle:record-finance-submit',
} as const } as const
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string } export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null } export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdByName?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { actualCartonMarks?: string[]; palletNo?: string | null; quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null } export interface CargoDiscardItem { actualCartonMarks?: string[]; palletNo?: string | null; quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null } export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
export async function getCargoDiscardStatistics(query: CargoDiscardQuery) { export async function getCargoDiscardStatistics(query: CargoDiscardQuery) {
@@ -0,0 +1,23 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type LooseboardPhotoPhase = 'PICKUP' | 'ARRIVAL'
export interface LooseboardPhoto { fileId: string; fileName: string; contentType: string; sizeBytes: string | number }
export interface LooseboardExecutionItem { itemId: string; customerId: string; orderNo: string; destination: string | null; packages: number; pickupPhotos: LooseboardPhoto[]; arrivalPhotos: LooseboardPhoto[]; canPickup: boolean; canArrive: boolean }
export interface LooseboardExecution { dispatchNo: string; recordVersion: string | number; stage: string; direct: boolean; items: LooseboardExecutionItem[] }
const path = (number: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}/looseboard-execution`
const itemPath = (number: string, item: string, phase: LooseboardPhotoPhase) => `${path(number)}/${encodeURIComponent(item)}/${phase}`
export async function getLooseboardExecution(number: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(path(number)))
}
export async function uploadLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, file: File) {
const body = new FormData(); body.append('file', file)
return unwrapApiResponse(await http.post<ApiEnvelope<LooseboardPhoto>, ApiEnvelope<LooseboardPhoto>>(`${itemPath(number, item, phase)}/uploads`, body))
}
export async function saveLooseboardPhotos(number: string, item: string, phase: LooseboardPhotoPhase, body: { recordVersion: string | number; idempotencyKey: string; fileIds: string[] }) {
return unwrapApiResponse(await http.put<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(itemPath(number, item, phase), body))
}
export async function readLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, photo: LooseboardPhoto) {
const blob = await http.get<Blob, Blob>(`${itemPath(number, item, phase)}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || blob.type !== photo.contentType || !blob.size) throw new Error('散板照片读取失败')
return blob
}
@@ -17,6 +17,11 @@ export async function listDispatches(query: DispatchQuery) {
return normalizeDispatchPage(unwrapApiResponse(await http.get<ApiEnvelope<DispatchPageWire>, ApiEnvelope<DispatchPageWire>>(base, { params: query }))) return normalizeDispatchPage(unwrapApiResponse(await http.get<ApiEnvelope<DispatchPageWire>, ApiEnvelope<DispatchPageWire>>(base, { params: query })))
} }
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail) export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
/** 散板余位页只选择已实收打托且与本车同仓的散板来源。 */
export async function listLooseboardCargo(no: string, page = 1, size = 20) {
return normalizeCandidateList(unwrapApiResponse(await http.get<ApiEnvelope<CandidateListWire>, ApiEnvelope<CandidateListWire>>(`${base}/capacity-demands/${encodeURIComponent(no)}/cargo`, { params: { page, size } })))
}
export const addLooseboardCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${base}/capacity-demands/${encodeURIComponent(no)}/cargo`, body) }
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems) export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
export async function getDispatchAppointmentAllocations(no: string) { export async function getDispatchAppointmentAllocations(no: string) {
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`) const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
@@ -1,10 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders' import { generateExceptionReturn, createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } })) vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
describe('exception order API', () => { describe('exception order API', () => {
it('generates the return order through the dedicated endpoint without evidence or receipt data', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { orderNo: 'WC-RETURN-1' } })
expect(await generateExceptionReturn('100', 'EX/1', 7)).toEqual({ orderNo: 'WC-RETURN-1' })
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/exception-orders/EX%2F1/generate-return-inbound',
{ expectedVersion: 7, idempotencyKey: expect.any(String) }, { params: { customerId: '100' } })
})
it('normalizes native carton sources and rejects missing, ambiguous or unsafe source identities', () => { it('normalizes native carton sources and rejects missing, ambiguous or unsafe source identities', () => {
const raw = { order: { version: '1' }, returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: '1' }], returnReceipts: [] } as unknown as ExceptionDetail const raw = { order: { version: '1' }, returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: '1' }], returnReceipts: [] } as unknown as ExceptionDetail
expect(normalizeExceptionDetail(raw).returnSources[0]).toMatchObject({ inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: 1 }) expect(normalizeExceptionDetail(raw).returnSources[0]).toMatchObject({ inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: 1 })
@@ -33,7 +39,7 @@ describe('exception order API', () => {
await getExceptionDetail('100', 'EX/1') await getExceptionDetail('100', 'EX/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/EX%2F1', { params: { customerId: '100' } }) expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/EX%2F1', { params: { customerId: '100' } })
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX/1', version: '8', restoredPackages: '0' } }) vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX/1', version: '8', restoredPackages: '0' } })
for (const action of ['start-processing', 'cancel', 'complete'] as const) { for (const action of ['start-processing', 'cancel', 'complete', 'force-close'] as const) {
const body = { expectedVersion: 7, idempotencyKey: `original-${action}` } const body = { expectedVersion: 7, idempotencyKey: `original-${action}` }
await commandException('100', 'EX/1', action, body) await commandException('100', 'EX/1', action, body)
expect(http.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/exception-orders/EX%2F1/${action}`, body, { params: { customerId: '100' } }) expect(http.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/exception-orders/EX%2F1/${action}`, body, { params: { customerId: '100' } })
@@ -21,7 +21,7 @@ export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: n
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[]; returnOrderNo?: string | null } export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[]; returnOrderNo?: string | null }
export interface ExceptionReceipt extends ReturnSourceIdentity { outboundPalletItemId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string } export interface ExceptionReceipt extends ReturnSourceIdentity { outboundPalletItemId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] } export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] }
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel' export type ExceptionAction = 'start-processing' | 'complete' | 'cancel' | 'force-close'
export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean } export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean }
const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}` const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}`
function safeInteger(value: unknown, label: string) { function safeInteger(value: unknown, label: string) {
@@ -472,6 +472,15 @@ export async function createManualInboundPlan(payload: ManualInboundPlanPayload,
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
/** 异常退回以本单预计到仓时间创建计划,后端核对原异常及真实客户归属。 */
export async function createExceptionReturnInboundPlan(plan: ManualInboundPlanPayload, expectedArrivalAt: string, idempotencyKey: string) {
const response = await http.post<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(
'/platform/v1/last-leg/exception-orders/return-inbound-plans', { plan, expectedArrivalAt },
{ headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) { export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) {
const response = await http.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>( const response = await http.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload, `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload,
@@ -13,6 +13,7 @@ import { getDispatch } from '../api/dispatches'
import type { DispatchDetail } from '../dispatchContracts' import type { DispatchDetail } from '../dispatchContracts'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue' import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue' import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>() const props = defineProps<{ dispatchNo: string }>()
@@ -22,10 +23,14 @@ const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = r
const tab = ref('RECEIPT'), selected = ref('') const tab = ref('RECEIPT'), selected = ref('')
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>() const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>() const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
const looseboardEditor = ref<InstanceType<typeof DispatchLooseboardExecution>>()
const directLooseboard = ref(false)
const hasLooseboard = computed(() => detail.value?.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD') === true)
const tabs = computed(() => [...(hasLooseboard.value ? [{ key: 'LOOSEBOARD', label: '散板取货与送达' }] : []), ...(!directLooseboard.value ? [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }] : [])])
const deliveries = computed(() => detail.value?.documents.deliveries || []) const deliveries = computed(() => detail.value?.documents.deliveries || [])
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || '')) const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value)) const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy)) const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy || looseboardEditor.value?.busy))
let sequence = 0 let sequence = 0
onBeforeUnmount(() => { sequence++ }) onBeforeUnmount(() => { sequence++ })
async function load() { async function load() {
@@ -36,6 +41,7 @@ async function load() {
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致') if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
if (value.status !== 'ACTIVE' || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('进入装车中后可管理资料') if (value.status !== 'ACTIVE' || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('进入装车中后可管理资料')
if (!detail.value && ['LOADING', 'AFTER_LOADING'].includes(value.stage)) tab.value = 'LOADING' if (!detail.value && ['LOADING', 'AFTER_LOADING'].includes(value.stage)) tab.value = 'LOADING'
if (!detail.value && value.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD')) tab.value = 'LOOSEBOARD'
detail.value = value detail.value = value
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || '' if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') } } catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
@@ -43,7 +49,7 @@ async function load() {
} }
async function canLeave() { async function canLeave() {
if (busy.value) return false if (busy.value) return false
if (!editor.value?.dirty) return true if (!editor.value?.dirty && !looseboardEditor.value?.dirty) return true
try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false } try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false }
} }
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value } async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
@@ -52,7 +58,7 @@ function selectTab(value: string) { if (!busy.value) { tab.value = value } }
function saved() { emit('saved') } function saved() { emit('saved') }
async function loadingSaved() { emit('saved'); await load() } async function loadingSaved() { emit('saved'); await load() }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => { watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null; directLooseboard.value = false
if (visible && props.dispatchNo) void load() if (visible && props.dispatchNo) void load()
}, { immediate: true }) }, { immediate: true })
</script> </script>
@@ -60,14 +66,15 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()"> <AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail"> <template v-if="detail">
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div> <div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in tabs" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<DispatchLooseboardExecution v-if="hasLooseboard" v-show="tab === 'LOOSEBOARD'" ref="looseboardEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @mode="directLooseboard = $event" @saved="loadingSaved" />
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" /> <DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture"> <div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
<el-form v-if="deliveries.length > 1" class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form> <el-form v-if="deliveries.length > 1" class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" /> <DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p> <p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
</div> </div>
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p> <p v-if="beforeDeparture && tab === 'RECEIPT'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
</template> </template>
</AppDialog> </AppDialog>
</template> </template>
@@ -0,0 +1,92 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos } from '../api/dispatchLooseboardExecution'
import type { LooseboardExecution } from '../api/dispatchLooseboardExecution'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/dispatchLooseboardExecution', () => ({ getLooseboardExecution: vi.fn(), uploadLooseboardPhoto: vi.fn(), saveLooseboardPhotos: vi.fn(), readLooseboardPhoto: vi.fn() }))
const value = (direct = true): LooseboardExecution => ({ dispatchNo: 'PC1', recordVersion: '3', stage: direct ? 'LOADING' : 'DELIVERING', direct, items: [{ itemId: '101', customerId: '1', orderNo: 'LB1', destination: 'TEST', packages: 2, pickupPhotos: [], arrivalPhotos: [], canPickup: true, canArrive: false }] })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getLooseboardExecution).mockResolvedValue(value()) })
it('shows pure looseboard photo flow and uploads to its authoritative item and node', async () => {
vi.mocked(uploadLooseboardPhoto).mockResolvedValue({ fileId: '77', fileName: 'pickup.png', contentType: 'image/png', sizeBytes: 10 })
vi.mocked(saveLooseboardPhotos).mockResolvedValue({ ...value(), stage: 'DELIVERING' })
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('全车为散板')
expect(wrapper.findAll('input[type=file]')).toHaveLength(2)
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
const input = wrapper.get('input[type=file]')
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadLooseboardPhoto).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.any(File))
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', { recordVersion: '3', idempotencyKey: expect.any(String), fileIds: ['77'] })
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('keeps pickup upload visible but disabled until mixed warehouse cargo departs', async () => {
const snapshot = value(false)
snapshot.stage = 'LOADING'
snapshot.items[0]!.canPickup = false
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('混排货物按原流程')
expect(wrapper.text()).toContain('上传取货照片')
expect(wrapper.text()).toContain('完成仓内拣货并确认装车后可上传')
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeDefined()
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
input.element.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
expect(uploadLooseboardPhoto).not.toHaveBeenCalled()
wrapper.unmount()
})
it('enables pickup upload for mixed cargo in delivery', async () => {
vi.mocked(getLooseboardExecution).mockResolvedValue(value(false))
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('enables mixed pickup from after loading using server eligibility', async () => {
const snapshot = value(false)
snapshot.stage = 'AFTER_LOADING'
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('keeps upload available after saving and appends multiple files to the current list', async () => {
const snapshot = value(false)
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
snapshot.items[0]!.pickupPhotos = [old]
snapshot.items[0]!.canArrive = true
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
vi.mocked(uploadLooseboardPhoto)
.mockResolvedValueOnce({ ...old, fileId: '78', fileName: 'new1.png' })
.mockResolvedValueOnce({ ...old, fileId: '79', fileName: 'new2.png' })
vi.mocked(saveLooseboardPhotos).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeUndefined()
Object.defineProperty(input.element, 'files', { value: ['new1.png', 'new2.png'].map(name => new File(['test'], name, { type: 'image/png' })), configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadLooseboardPhoto).toHaveBeenCalledTimes(2)
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['77', '78', '79'] }))
wrapper.unmount()
})
it('deletes one photo while preserving the others in the versioned save', async () => {
const snapshot = value(false)
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
snapshot.items[0]!.pickupPhotos = [old, { ...old, fileId: '78', fileName: 'keep.png' }]
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const updated = value(false)
updated.items[0]!.pickupPhotos = [snapshot.items[0]!.pickupPhotos[1]!]
vi.mocked(saveLooseboardPhotos).mockResolvedValue(updated)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('button[aria-label="删除取货照片old.png"]').trigger('click'); await flushPromises()
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['78'] }))
expect(wrapper.text()).not.toContain('old.png')
expect(wrapper.text()).not.toContain('keep.png')
expect(wrapper.find('button[aria-label="删除取货照片keep.png"]').exists()).toBe(true)
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos, readLooseboardPhoto, type LooseboardExecution, type LooseboardExecutionItem, type LooseboardPhoto, type LooseboardPhotoPhase } from '../api/dispatchLooseboardExecution'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: []; mode: [direct: boolean] }>()
const access = useAccessPreviewStore()
const snapshot = ref<LooseboardExecution | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const previews = ref<Record<string, string>>({})
const pending = ref<{ itemId: string; phase: LooseboardPhotoPhase; photos: LooseboardPhoto[] } | null>(null)
const dirty = computed(() => Boolean(pending.value?.photos.length))
const phases: { key: LooseboardPhotoPhase; label: string }[] = [{ key: 'PICKUP', label: '取货照片' }, { key: 'ARRIVAL', label: '送达照片' }]
function uploadReason(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase): string {
if (busy.value) return '正在处理,请稍候'
if (pending.value) return '请先保存已上传照片'
if ((phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length >= 20) return '每个节点最多20张照片'
if (phase === 'PICKUP' ? item.canPickup : item.canArrive) return ''
if (phase === 'ARRIVAL' && !item.pickupPhotos.length) return '请先上传取货照片'
if (phase === 'PICKUP' && !snapshot.value?.direct && snapshot.value?.stage === 'LOADING') return '完成仓内拣货并确认装车后可上传'
return '当前状态不可上传'
}
const number = props.dispatchNo
let active = true
function accept(value: LooseboardExecution) {
if (!active || number !== props.dispatchNo || value.dispatchNo !== number) throw new Error('排车身份已变化,请重新打开')
snapshot.value = value; emit('mode', value.direct)
}
async function images() {
for (const item of snapshot.value?.items || []) for (const phase of phases) {
const photos = phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
await Promise.allSettled(photos.map(async photo => {
if (previews.value[photo.fileId]) return
const blob = await readLooseboardPhoto(number, item.itemId, phase.key, photo)
if (active) previews.value[photo.fileId] = URL.createObjectURL(blob)
}))
}
}
async function load() {
if (busy.value) return
busy.value = true; error.value = null
try { accept(await getLooseboardExecution(number)); await images() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '散板履约资料读取失败') }
finally { busy.value = false }
}
async function save() {
if (!pending.value?.photos.length || !snapshot.value || busy.value) return
busy.value = true; error.value = null
const value = pending.value
try {
const item = snapshot.value.items.find(row => row.itemId === value.itemId)
if (!item) throw new Error('散板明细已变化,请重新打开')
const existing = value.phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, value.itemId, value.phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: [...existing, ...value.photos].map(photo => photo.fileId) }))
pending.value = null; await images(); ElMessage.success('散板照片已保存'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '保存失败,已上传照片保留,请核对当前状态后重试')
try {
accept(await getLooseboardExecution(number)); await images()
const item = snapshot.value?.items.find(row => row.itemId === value.itemId)
const bound = (value.phase === 'PICKUP' ? item?.pickupPhotos : item?.arrivalPhotos) || []
if (value.photos.every(photo => bound.some(file => file.fileId === photo.fileId))) { pending.value = null; emit('saved') }
} catch { /* 保留照片和当前错误,用户可重新读取。 */ }
}
} finally { busy.value = false }
}
async function upload(itemId: string, phase: LooseboardPhotoPhase, event: Event) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
const item = snapshot.value?.items.find(row => row.itemId === itemId)
if (!files.length || !item || uploadReason(item, phase) || !access.can(permission.dispatchEdit)) return
const existing = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
if (files.length + existing.length > 20 || files.some(file => !['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('每个节点最多20张JPG或PNG,每张不超过10MiB'); return }
busy.value = true; error.value = null; pending.value = { itemId, phase, photos: [] }; let failed = false
try { for (const file of files) { const photo = await uploadLooseboardPhoto(number, itemId, phase, file); if (!active) return; pending.value.photos.push(photo) } }
catch (cause) { failed = true; error.value = getApiErrorDetails(cause, '照片上传失败,已成功上传的照片可继续保存'); if (!pending.value?.photos.length) pending.value = null }
finally { busy.value = false }
if (!failed && pending.value?.photos.length) await save()
}
async function remove(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase, fileId: string) {
if (!snapshot.value || busy.value || pending.value || !access.can(permission.dispatchEdit) || !(phase === 'PICKUP' ? item.canPickup : item.canArrive)) return
busy.value = true; error.value = null
try {
const photos = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, item.itemId, phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: photos.filter(photo => photo.fileId !== fileId).map(photo => photo.fileId) }))
await images(); ElMessage.success('照片已删除'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '照片删除失败,请刷新后重试')
try { accept(await getLooseboardExecution(number)); await images() } catch { /* 保留原错误。 */ }
}
} finally { busy.value = false }
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(previews.value).forEach(URL.revokeObjectURL) })
defineExpose({ busy, dirty })
</script>
<template>
<div v-loading="busy" class="looseboard-execution">
<el-button :disabled="busy" @click="load">刷新资料</el-button>
<LoadErrorState v-if="error" compact title="散板履约资料操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending"><span>已上传 {{ pending.photos.length }} 张,尚未保存。</span><el-button :disabled="busy" @click="save">保存已上传照片</el-button></div>
<section v-for="item in snapshot?.items || []" :key="item.itemId" class="looseboard-cargo">
<h3>{{ item.orderNo }} · {{ item.destination || '—' }} · {{ item.packages }} 箱</h3>
<div v-for="phase in phases" :key="phase.key" class="looseboard-node">
<h4>{{ phase.label }}</h4>
<label v-if="access.can(permission.dispatchEdit)" class="node-upload" :class="{ 'is-disabled': Boolean(uploadReason(item, phase.key)) }" :title="uploadReason(item, phase.key)">
上传{{ phase.label }}
<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`${item.orderNo}上传${phase.label}`" :disabled="Boolean(uploadReason(item, phase.key))" @change="upload(item.itemId, phase.key, $event)" />
</label>
<span v-if="access.can(permission.dispatchEdit) && uploadReason(item, phase.key)" class="upload-reason">{{ uploadReason(item, phase.key) }}</span>
<p v-if="!(phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length">尚未上传{{ phase.label }}</p>
<div v-else class="node-photos">
<div v-for="photo in phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos" :key="photo.fileId" class="node-photo">
<el-image v-if="previews[photo.fileId]" :src="previews[photo.fileId]" :preview-src-list="[previews[photo.fileId]!]" preview-teleported fit="contain" class="node-image" :alt="phase.label" />
<button v-if="access.can(permission.dispatchEdit) && (phase.key === 'PICKUP' ? item.canPickup : item.canArrive)" type="button" class="node-photo-remove" :aria-label="`删除${phase.label}${photo.fileName}`" title="删除照片" :disabled="busy || Boolean(pending)" @click.stop="remove(item, phase.key, photo.fileId)"><span aria-hidden="true">×</span></button>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.looseboard-execution { display: flex; flex-direction: column; gap: 12px; }
.looseboard-cargo { border: 1px solid var(--cc-border, var(--el-border-color)); }
.looseboard-cargo > h3 { margin: 0; padding: 8px 12px; font-size: 14px; background: var(--el-fill-color-light); }
.looseboard-node { padding: 8px 12px; border-top: 1px solid var(--el-border-color-lighter); }
.looseboard-node h4 { display: inline-block; margin: 0 16px 8px 0; font-size: 14px; }
.looseboard-node p { color: var(--el-text-color-secondary); }
.node-photos { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 4px; }
.node-photo { position: relative; flex: 0 0 160px; width: 160px; height: 100px; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); background: var(--el-fill-color-light); }
.node-image { display: block; width: 100%; height: 100%; border-radius: inherit; }
.node-photo-remove { position: absolute; top: 4px; right: 4px; z-index: 1; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: var(--el-color-danger); color: var(--el-color-white); font-size: 20px; line-height: 1; cursor: pointer; }
.node-photo-remove:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.node-photo-remove:disabled { opacity: .5; cursor: not-allowed; }
.node-upload { position: relative; display: inline-flex; align-items: center; padding: 6px 12px; border: 1px solid var(--el-color-primary); border-radius: var(--el-border-radius-base); color: var(--el-color-primary); cursor: pointer; }
.node-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.node-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.upload-reason { margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
.node-upload.is-disabled input { cursor: not-allowed; }
.node-upload.is-disabled { border-color: var(--el-border-color); color: var(--el-text-color-disabled); cursor: not-allowed; }
</style>
@@ -310,6 +310,16 @@ describe('dispatch workflow native layout', () => {
}) })
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => { it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
const { dialog } = await render(action) const { dialog } = await render(action)
if (action === 'CANCEL') {
expect(dialog.props('confirmText')).toBe('确认取消排车')
expect(dialog.props('confirmType')).toBe('danger')
expect(dialog.props('cancelText')).toBe('暂不取消')
expect(dialog.props('width')).toBe('560px')
expect(field(dialog, '取消原因').props()).toMatchObject({ labelPosition: 'top', required: true })
expect(dialog.get('textarea').attributes('placeholder')).toBe('请填写取消原因')
expect(dialog.find('[aria-label="操作原因与意见"]').exists()).toBe(false)
expect(dialog.text()).toContain('取消本次排车,不会自动回库。')
}
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW' const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() } if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
@@ -318,6 +328,20 @@ describe('dispatch workflow native layout', () => {
dialog.vm.$emit('confirm'); await flushPromises() dialog.vm.$emit('confirm'); await flushPromises()
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) }) expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) })
}) })
it('preserves the cancellation reason after failure and refreshing current facts', async () => {
const { dialog } = await render('CANCEL')
await dialog.get('textarea').setValue(' 客户调整送货安排 ')
vi.mocked(api.cancelDispatch).mockRejectedValueOnce(new ApiResponseError('排车资料已更新,请核对后重试', 'A0409', 409))
dialog.vm.$emit('confirm'); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户调整送货安排 ')
expect(dialog.get('textarea').attributes('disabled')).toBeUndefined()
expect(dialog.props('confirmLoading')).toBe(false)
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户调整送货安排 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.cancelDispatch).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.cancelDispatch).mock.calls[1]![1]).toMatchObject({ recordVersion: 1, reason: '客户调整送货安排' })
})
it('reorders only real unload stops and preserves the external stop and zero route version', async () => { it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
const { dialog } = await render('EDIT', 'ROUTE') const { dialog } = await render('EDIT', 'ROUTE')
const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length) const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length)
@@ -134,7 +134,8 @@ it('retains edited values and the original validation response when the action i
expect(state.form.remark).toBe('用户未保存的本轮备注') expect(state.form.remark).toBe('用户未保存的本轮备注')
expect(state.detail.recordVersion).toBe(0) expect(state.detail.recordVersion).toBe(0)
expect(wrapper.text()).toContain('预计到达时间须在发车之后') expect(wrapper.text()).toContain('预计到达时间须在发车之后')
expect(wrapper.text()).toContain('A0400') expect(wrapper.text()).not.toContain('A0400')
expect(wrapper.text()).not.toContain('validation-request')
expect(wrapper.text()).not.toContain('原操作结果尚待核对') expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await state.retryError() await state.retryError()
@@ -150,7 +151,7 @@ it('allows another click after rejection even when a later read would fail', asy
await state.submit(); await flushPromises() await state.submit(); await flushPromises()
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]]) expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]])
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull() expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).not.toContain('A0409')
expect(wrapper.text()).not.toContain('原操作结果尚待核对') expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(state.form.remark).toBe('保留本次备注') expect(state.form.remark).toBe('保留本次备注')
await state.submit() await state.submit()
@@ -10,7 +10,6 @@ import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches' import * as api from '../api/dispatches'
import { getTransport } from '../api/transportTasks' import { getTransport } from '../api/transportTasks'
@@ -150,7 +149,7 @@ function payload(): object {
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因') if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
case 'CANCEL': case 'RETURN_TO_REVIEW': case 'CANCEL': case 'RETURN_TO_REVIEW':
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因') if (!form.reason.trim()) throw new DispatchInputError(currentAction.value === 'CANCEL' ? '请填写取消原因' : '请填写操作原因')
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
case 'LOADING_COMPLETE': { case 'LOADING_COMPLETE': {
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平') if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
@@ -250,11 +249,11 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true }) watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
</script> </script>
<template> <template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()"> <AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'CANCEL' ? '确认取消排车' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" :confirm-type="currentAction === 'CANCEL' ? 'danger' : 'primary'" :cancel-text="currentAction === 'CANCEL' ? '暂不取消' : '取消'" @confirm="submit()">
<div class="density-dialog-sections"> <div class="density-dialog-sections">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon /> <el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon /> <el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon /> <el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed"> <el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3> <section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
@@ -313,14 +312,17 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<section class="density-dialog-section" data-tone="work" aria-label="确认装车完成"><h3 class="density-dialog-section__title">确认装车完成</h3><p>已完成 {{ preview.pallets.length }} 托拣货,共 {{ preview.pallets.reduce((sum, row) => sum + Number(row.pickedPackages), 0) }} 箱。请确认本车货物已全部装车。</p></section> <section class="density-dialog-section" data-tone="work" aria-label="确认装车完成"><h3 class="density-dialog-section__title">确认装车完成</h3><p>已完成 {{ preview.pallets.length }} 托拣货,共 {{ preview.pallets.reduce((sum, row) => sum + Number(row.pickedPackages), 0) }} 箱。请确认本车货物已全部装车。</p></section>
</template> </template>
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon /> <el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon /> <el-alert v-else-if="currentAction === 'CANCEL'" title="取消本次排车,不会自动回库。" type="warning" :closable="false" show-icon />
<section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片"> <section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片">
<h3 class="density-dialog-section__title">车辆司机照片</h3> <h3 class="density-dialog-section__title">车辆司机照片</h3>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片"> <div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" /> <DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
</div> </div>
</section> </section>
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section> <el-form-item v-if="currentAction === 'CANCEL' && currentOperation !== 'ROUTE'" label="取消原因" label-position="top" required>
<el-input v-model="form.reason" aria-label="取消原因" type="textarea" :rows="4" placeholder="请填写取消原因" />
</el-form-item>
<section v-else-if="currentOperation === 'ROUTE' || ['RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="isReview ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || currentAction === 'RETURN_TO_REVIEW' ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section> <section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
</el-form> </el-form>
</div> </div>
@@ -4,13 +4,13 @@ import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus'
import { beforeEach, expect, it, vi } from 'vitest' import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http' import { ApiResponseError } from '@/shared/api/http'
import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue' import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue'
import { getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders' import { generateExceptionReturn, getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders'
import { getPallet } from '../api/pallets' import { getPallet } from '../api/pallets'
import { getOverseasLocation } from '../api/locations' import { getOverseasLocation } from '../api/locations'
const can = vi.hoisted(() => vi.fn(() => true)) const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() })) vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), generateExceptionReturn: vi.fn(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() })) vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() }))
vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() })) vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() }))
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
@@ -77,6 +77,53 @@ beforeEach(() => {
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited<ReturnType<typeof getPallet>>) vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited<ReturnType<typeof getPallet>>)
vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited<ReturnType<typeof getOverseasLocation>>) vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited<ReturnType<typeof getOverseasLocation>>)
}) })
it.each(['PENDING', 'PROCESSING'])('generates a return order without evidence or receipt inputs in %s', async status => {
const state = snapshot('EX-1', 4)
state.order.status = status; state.order.evidenceFileIds = []
state.order.allowedActions = ['GENERATE_RETURN_INBOUND', 'COMPLETE_EXCEPTION', 'FORCE_CLOSE']
const generated = { ...state, returnOrderNo: 'WC-RETURN-1', order: { ...state.order, status: 'PROCESSING', allowedActions: ['COMPLETE_EXCEPTION'] } }
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state).mockResolvedValue(generated)
vi.mocked(generateExceptionReturn).mockResolvedValue({ orderNo: 'WC-RETURN-1' })
const wrapper = open()
try {
await flushPromises()
expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
expect(wrapper.find('[data-test="quantity-0"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
await wrapper.get('[data-test="generate-return"]').trigger('click'); await flushPromises()
expect(generateExceptionReturn).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 4)
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('WC-RETURN-1')
expect(wrapper.text()).toContain('请在 PDA 退货入库中验收')
expect(wrapper.find('[data-test="generate-return"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
expect(wrapper.emitted('changed')).toHaveLength(1)
} finally { wrapper.unmount() }
})
it.each(['RETURN', 'DAMAGED', 'REJECT', 'OTHER'])('offers other handling for %s and closes with a required reason only', async exceptionType => {
const state = snapshot('EX-1', 4)
state.order.exceptionType = exceptionType; state.order.allowedActions = ['FORCE_CLOSE', 'COMPLETE_EXCEPTION']; state.order.evidenceFileIds = []
state.returnOrderNo = exceptionType === 'RETURN' ? 'WC-RETURN-1' : null
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state).mockResolvedValue(snapshot('EX-1', 5, 0))
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', status: 'COMPLETED', version: 5, restoredPackages: 0, replayed: false })
const wrapper = open()
try {
await flushPromises(); await wrapper.get('[data-test="processing-method"]').setValue('other'); await flushPromises()
expect(wrapper.get('[data-test="notes"]').attributes('placeholder')).toBe('请填写其他处理方式及完结原因')
expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
await wrapper.get('[data-test="notes"]').setValue(' ')
await wrapper.get('[data-test="force-close"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('请填写其他处理方式的完结原因'); expect(commandException).not.toHaveBeenCalled()
await wrapper.get('[data-test="notes"]').setValue(' 客户沟通后结束处理 ')
await wrapper.get('[data-test="force-close"]').trigger('click'); await flushPromises()
expect(commandException).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 'force-close', {
expectedVersion: 4, idempotencyKey: expect.any(String), reason: '客户沟通后结束处理',
})
expect(uploadExceptionEvidence).not.toHaveBeenCalled(); expect(generateExceptionReturn).not.toHaveBeenCalled()
expect(wrapper.find('[data-test="force-close"]').exists()).toBe(false)
} finally { wrapper.unmount() }
})
it('real receipt quantity control becomes accessible after starting processing', async () => { it('real receipt quantity control becomes accessible after starting processing', async () => {
const pending = snapshot('EX-1', 0, 2) const pending = snapshot('EX-1', 0, 2)
pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING']; pending.returnSources = [] pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING']; pending.returnSources = []
@@ -166,7 +213,10 @@ it('keeps a conflict visible and reloads when the operator reopens the detail',
wrapper.unmount() wrapper.unmount()
}) })
it('has no processing or upload controls without independent permission', async () => { it('has no processing or upload controls without independent permission', async () => {
const state = snapshot(); state.order.allowedActions = ['COMPLETE_EXCEPTION', 'FORCE_CLOSE']
vi.mocked(getExceptionDetail).mockResolvedValue(state)
can.mockReturnValue(false); const wrapper = open(); await flushPromises() can.mockReturnValue(false); const wrapper = open(); await flushPromises()
expect(wrapper.find('[data-test="processing-method"]').exists()).toBe(false); expect(wrapper.find('[data-test="force-close"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false) expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount() expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount()
}) })
@@ -30,12 +30,17 @@ const pending = computed(() => pendingByScope.get(identity.value))
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value) const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value)
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE') const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action) const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
const processingMethod = ref('standard')
const otherMethod = computed(() => processingMethod.value === 'other' && allowed('FORCE_CLOSE'))
// 已生成的退回单由 PDA 验收;生成入口与后台完成入口不能同时开放。
const canComplete = computed(() => allowed('COMPLETE_EXCEPTION') && !detail.value?.returnOrderNo && !allowed('GENERATE_RETURN_INBOUND'))
let sequence = 0 let sequence = 0
const targetSequences = new Map<number, number>() const targetSequences = new Map<number, number>()
const current = (ticket: number) => props.active && ticket === sequence const current = (ticket: number) => props.active && ticket === sequence
function definite(cause: unknown) { return cause instanceof ApiResponseError && /^A0[34]\d{2}$/.test(cause.code) && (!cause.status || cause.status < 500) } function definite(cause: unknown) { return cause instanceof ApiResponseError && /^A0[34]\d{2}$/.test(cause.code) && (!cause.status || cause.status < 500) }
async function load() { async function load() {
const ticket = ++sequence; loading.value = true; error.value = ''; detail.value = null; receipts.value = []; targets.value = []; evidence.value = []; selectedHistory.value = []; notes.value = ''; targetSequences.clear() const ticket = ++sequence; loading.value = true; error.value = ''; detail.value = null; receipts.value = []; targets.value = []; evidence.value = []; selectedHistory.value = []; notes.value = ''; targetSequences.clear()
processingMethod.value = 'standard'
try { try {
const result = await getExceptionDetail(props.customerId, props.exceptionNo) const result = await getExceptionDetail(props.customerId, props.exceptionNo)
if (!current(ticket)) return if (!current(ticket)) return
@@ -94,10 +99,12 @@ async function upload(event: Event) {
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' } } finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
} }
async function execute(action: ExceptionAction) { async function execute(action: ExceptionAction) {
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action] if (action === 'complete' && !canComplete.value) return
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION', 'force-close': 'FORCE_CLOSE' }[action]
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return
if (pending.value && pending.value.action !== action) return if (pending.value && pending.value.action !== action) return
if (!pending.value) { if (!pending.value) {
if (action === 'force-close' && !notes.value.trim()) { error.value = '请填写其他处理方式的完结原因'; return }
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])] const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return } if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return }
const selected = receipts.value.filter(row => Number(row.quantity) > 0) const selected = receipts.value.filter(row => Number(row.quantity) > 0)
@@ -110,6 +117,7 @@ async function execute(action: ExceptionAction) {
const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() } const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() }
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理' if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
if (action === 'force-close') payload.reason = notes.value.trim()
if (action === 'complete') { if (action === 'complete') {
payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, ...returnSourceReference(row), receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : [] payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, ...returnSourceReference(row), receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
@@ -124,7 +132,7 @@ async function execute(action: ExceptionAction) {
busy.value = true; error.value = ''; message.value = '' busy.value = true; error.value = ''; message.value = ''
try { try {
const result = await commandException(customer, number, attempt.action, attempt.payload) const result = await commandException(customer, number, attempt.action, attempt.payload)
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING'] const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'force-close' ? 'COMPLETED' : attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status)) if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
throw new Error('处理结果无法核对,请刷新当前记录') throw new Error('处理结果无法核对,请刷新当前记录')
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) } if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
@@ -149,9 +157,16 @@ function selectHistory(fileId: string, event: Event) {
/** 生成动作只建单,实物入账由PDA完成。 */ /** 生成动作只建单,实物入账由PDA完成。 */
async function generateReturn() { async function generateReturn() {
if (locked.value || !allowed('GENERATE_RETURN_INBOUND') || !detail.value) return if (locked.value || !allowed('GENERATE_RETURN_INBOUND') || !detail.value) return
const ticket = sequence const ticket = sequence, scope = identity.value
busy.value = true; error.value = '' busy.value = true; error.value = ''
try { await generateExceptionReturn(props.customerId, props.exceptionNo, detail.value.order.version!); if (current(ticket)) { busy.value = false; emit('changed'); await load() } } try {
const result = await generateExceptionReturn(props.customerId, props.exceptionNo, detail.value.order.version!)
if (!result?.orderNo) throw new Error('生成结果无法核对,请重新打开详情')
if (current(ticket)) {
busy.value = false; emit('changed'); await load()
if (props.active && identity.value === scope) message.value = `已生成退回尾程单 ${result.orderNo},请在 PDA 退货入库中验收`
}
}
catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '生成退回入库单失败').message } catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '生成退回入库单失败').message }
finally { if (current(ticket)) busy.value = false } finally { if (current(ticket)) busy.value = false }
} }
@@ -210,8 +225,14 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p> <p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
<template v-if="canProcess"> <template v-if="canProcess">
<el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked"> <el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="请输入处理说明或取消原因(选填)" /></el-form-item> <el-form-item v-if="allowed('FORCE_CLOSE')" label="处理方式">
<template v-if="allowed('COMPLETE_EXCEPTION') && !detail.returnOrderNo"> <el-select v-model="processingMethod" data-test="processing-method" :disabled="locked">
<el-option value="standard" :label="allowed('GENERATE_RETURN_INBOUND') ? '退回海外仓' : '按流程处理'" />
<el-option value="other" label="其他" />
</el-select>
</el-form-item>
<el-form-item :label="otherMethod ? '完结原因(必填)' : '处理说明 / 取消原因(选填)'" :required="!!otherMethod" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" :placeholder="otherMethod ? '请填写其他处理方式及完结原因' : '请输入处理说明或取消原因(选填)'" /></el-form-item>
<template v-if="canComplete && !otherMethod">
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label> <label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div> <div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
<ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul> <ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul>
@@ -227,13 +248,17 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
</template> </template>
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p> <p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
</template> </template>
<p v-if="otherMethod">填写原因后直接完结此异常单,无需上传资料。关联退回单及货物实收仍按实际业务处理。</p>
</el-form> </el-form>
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer> <Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
<div class="density-actions density-actions--footer"> <div class="density-actions density-actions--footer">
<el-button v-if="allowed('GENERATE_RETURN_INBOUND')" type="primary" :disabled="locked" @click="generateReturn">生成退回入库单</el-button> <el-button v-if="otherMethod" data-test="force-close" type="primary" :disabled="locked" @click="execute('force-close')">完结异常处理</el-button>
<template v-else>
<el-button v-if="allowed('GENERATE_RETURN_INBOUND') && !detail.returnOrderNo" data-test="generate-return" type="primary" :disabled="locked" @click="generateReturn">生成退回入库单</el-button>
<el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button> <el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button> <el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button> <el-button v-if="canComplete" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
</template>
</div> </div>
</Teleport> </Teleport>
</template> </template>
@@ -5,6 +5,7 @@ import TransportExecutionPanel from './TransportExecutionPanel.vue'
import { assignTransport, commandTransport, downloadTransportEvidence, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks' import { assignTransport, commandTransport, downloadTransportEvidence, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
import { getDriver } from '@/modules/overseas-master-data' import { getDriver } from '@/modules/overseas-master-data'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue' import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import { ApiResponseError } from '@/shared/api/http'
const can = vi.hoisted(() => vi.fn((_code: string) => true)) const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() })) vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
@@ -150,7 +151,7 @@ it('requires three categories and sends photos with both current versions for re
it('restores saved photos and retains uploads after a failed save', async () => { it('restores saved photos and retains uploads after a failed save', async () => {
const value = facts(); value.dispatchRecordVersion = 9 const value = facts(); value.dispatchRecordVersion = 9
value.resources = { vehicle: { vehicleId: '1', evidenceFileIds: ['21'] }, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['23'] } } } as TransportDetail['resources'] value.resources = { vehicle: { vehicleId: '1', evidenceFileIds: ['21'] }, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['23'] } } } as TransportDetail['resources']
vi.mocked(getTransport).mockResolvedValue(value); vi.mocked(assignTransport).mockRejectedValue(new Error('保存失败')) vi.mocked(getTransport).mockResolvedValue(value); vi.mocked(assignTransport).mockRejectedValue(new ApiResponseError('司机预计任务结束时间须晚于当前时间', 'A0400', 400, undefined, 'save-request', 'save-trace'))
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises() const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
const cards = wrapper.findAllComponents(DispatchEvidenceFiles) const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
expect(cards.map(card => card.props('modelValue'))).toEqual([['21'], ['22'], ['23']]) expect(cards.map(card => card.props('modelValue'))).toEqual([['21'], ['22'], ['23']])
@@ -158,6 +159,8 @@ it('restores saved photos and retains uploads after a failed save', async () =>
cards[0]!.vm.$emit('update:modelValue', ['24']); await flushPromises() cards[0]!.vm.$emit('update:modelValue', ['24']); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!.trigger('click'); await flushPromises() await wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!.trigger('click'); await flushPromises()
expect(cards[0]!.props('modelValue')).toEqual(['24']) expect(cards[0]!.props('modelValue')).toEqual(['24'])
expect(wrapper.text()).toContain('司机预计任务结束时间须晚于当前时间')
for (const diagnostic of ['A0400', 'save-request', 'save-trace']) expect(wrapper.text()).not.toContain(diagnostic)
expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount() expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
}) })
it('clears old photos when selecting another vehicle or driver and ignores late license responses', async () => { it('clears old photos when selecting another vehicle or driver and ignores late license responses', async () => {
@@ -7,8 +7,6 @@ import { onBeforeRouteLeave } from 'vue-router'
import { Truck, Package, RefreshCw, Trash2 } from '@lucide/vue' import { Truck, Package, RefreshCw, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import { listVehicles, listDrivers, getDriver, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data' import { listVehicles, listDrivers, getDriver, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue' import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
@@ -195,7 +193,7 @@ onBeforeUnmount(clearTruckPreviewUrls)
</script> </script>
<template> <template>
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行"> <section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <el-alert v-if="error" :title="error.message" type="error" :closable="false" show-icon />
<template v-if="detail && trip"> <template v-if="detail && trip">
<header class="transport-toolbar"> <header class="transport-toolbar">
+1
View File
@@ -12,6 +12,7 @@ export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, D
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts' export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery' export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue' export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
export { listLooseboardCargo, addLooseboardCargo } from './api/dispatches'
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs' export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
export { useDispatchCandidates } from './useDispatchCandidates' export { useDispatchCandidates } from './useDispatchCandidates'
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders' export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation' import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => { describe('last-mile order presentation adapter', () => {
it('translates raw return order type, source and pending inbound status', () => {
const row = toLastMileOrderListItem({ id: '1', customerId: '2', orderNo: 'WC1', orderType: 'DELIVERY_RETURN', orderTypeName: 'DELIVERY_RETURN', currentStatus: 'PENDING_INBOUND', businessSource: { label: 'DELIVERY_RETURN' } } as Parameters<typeof toLastMileOrderListItem>[0])
expect(row.orderType).toBe('派送异常退回')
expect(row.status).toBe('待入库')
expect(row.businessSource).toBe('派送异常退回')
})
it('keeps completed and processing relabel applications visible together', () => { it('keeps completed and processing relabel applications visible together', () => {
const row = toLastMileOrderListItem({ const row = toLastMileOrderListItem({
id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED', id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED',
@@ -15,6 +15,7 @@ const orderTypeLabels: Record<string, string> = {
} }
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
PENDING_INBOUND: '待入库',
PENDING_REVIEW: '待审核', PENDING_REVIEW: '待审核',
PENDING_RECEIPT: '待签收入库', PENDING_RECEIPT: '待签收入库',
PARTIALLY_RECEIVED: '部分入库', PARTIALLY_RECEIVED: '部分入库',
@@ -156,13 +157,13 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
orderNo: lastMileOrderReference(dto.orderNo), orderNo: lastMileOrderReference(dto.orderNo),
sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''), sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''),
businessType: dto.businessSource?.businessType || '', businessType: dto.businessSource?.businessType || '',
businessSource: text(dto.businessSource?.label), businessSource: lastMileOrderTypeLabel(dto.businessSource?.label),
businessOrderNo: text(dto.businessSource?.businessOrderNo), businessOrderNo: text(dto.businessSource?.businessOrderNo),
externalBookingNo: text(dto.businessSource?.externalBookingNo), externalBookingNo: text(dto.businessSource?.externalBookingNo),
orderTypeCode: String(dto.orderType ?? '').trim(), orderTypeCode: String(dto.orderType ?? '').trim(),
currentStatusCode: String(dto.currentStatus ?? '').trim(), currentStatusCode: String(dto.currentStatus ?? '').trim(),
warning: dto.abnormal ? '异常' : '—', warning: dto.abnormal ? '异常' : '—',
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)), orderType: lastMileOrderTypeLabel(dto.orderTypeName || dto.orderType),
rejectedAt: '—', rejectedAt: '—',
inboundCode: text(dto.inboundCode), inboundCode: text(dto.inboundCode),
lxCode: text(dto.lxCode), lxCode: text(dto.lxCode),
@@ -43,6 +43,20 @@ function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: strin
async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() } async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() }
describe('native cargo discard detail and approval', () => { describe('native cargo discard detail and approval', () => {
it('rejects a pending application with a required trimmed reason and customer scope', async () => {
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValueOnce({ value: ' 箱唛不符 ', action: 'confirm' })
const wrapper = await page()
await row(wrapper).findAll('button').find(item => item.text() === '驳回')!.trigger('click'); await flushPromises()
expect(prompt).toHaveBeenCalled()
expect(api.review).toHaveBeenCalledWith('CD-001', 'REJECT', '箱唛不符', '9007199254740994')
prompt.mockRestore()
})
it('formats applicant names and seconds and uses the same reviewed status as the client', async () => {
api.list.mockResolvedValueOnce({ records: [summary({ createdBy: '123456', createdByName: '客户姓名', createdAt: '2026-11-02T18:42:27.511', status: 'APPROVED' })], total: 1 })
const wrapper = await page()
expect(row(wrapper).text()).toContain('客户姓名'); expect(row(wrapper).text()).toContain('2026-11-02 18:42:27')
expect(row(wrapper).text()).not.toContain('.511'); expect(row(wrapper).text()).not.toContain('待 PDA')
})
it('binds fees to the discard application instead of its inventory or outbound order, and unmounts them on close', async () => { it('binds fees to the discard application instead of its inventory or outbound order, and unmounts them on close', async () => {
const { dialog } = await open() const { dialog } = await open()
expect(dialog.getComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({ expect(dialog.getComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
@@ -19,11 +19,13 @@ import { BusinessDocumentFees } from '@/modules/overseas-business'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' }) const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
/** 时间统一显示到秒。 */
function dateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
/** 数量事实缺失时明确提示,不用零代替未记录。 */ /** 数量事实缺失时明确提示,不用零代替未记录。 */
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` } function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }] const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'status', label: '状态', width: 150, slotName: 'status', afterColumn: 'discardNo' }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }] const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'status', label: '状态', width: 150, slotName: 'status', afterColumn: 'discardNo' }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdByName', label: '申请人', width: 110, formatter: (row: CargoDiscardSummary) => row.createdByName || '—' }, { key: 'createdAt', label: '创建时间', minWidth: 170, formatter: (row: CargoDiscardSummary) => dateTime(row.createdAt) }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核', PDA_COMPLETED: '待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null) const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null)
const detailError = ref<ApiErrorDetails | null>(null); const reviewError = ref<ApiErrorDetails | null>(null) const detailError = ref<ApiErrorDetails | null>(null); const reviewError = ref<ApiErrorDetails | null>(null)
const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref<CargoDiscardSummary | null>(null) const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref<CargoDiscardSummary | null>(null)
@@ -78,10 +80,23 @@ async function audit(row: CargoDiscardSummary) {
catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') } catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') }
finally { reviewing.value = '' } finally { reviewing.value = '' }
} }
/** 驳回须填写原因,成功后刷新当前列表和详情。 */
async function reject(row: CargoDiscardSummary) {
if (reviewing.value || row.status !== 'PENDING_REVIEW' || !access.can(permission.discardApprove)) return
reviewing.value = row.discardNo; reviewError.value = null
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回废弃单', { inputType: 'textarea', inputValidator: (value: string) => !!value?.trim() || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消', customClass: 'admin-density-confirm admin-workbench is-dialog' })
if (!access.can(permission.discardApprove)) return
await reviewCargoDiscard(row.discardNo, 'REJECT', value.trim(), row.customerId || undefined)
ElMessage.success('已驳回'); await load()
if (detailOpen.value && detailTarget.value?.discardNo === row.discardNo) await readDetail()
} catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '驳回失败') }
finally { reviewing.value = '' }
}
onMounted(() => { void loadCustomers(); void load() }) onMounted(() => { void loadCustomers(); void load() })
</script> </script>
<template> <template>
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template></DataTable></div></section></ListPageLayout></AdminPage> <AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" class="density-action" size="small" type="danger" :disabled="!!reviewing" @click="reject(row as CargoDiscardSummary)">驳回</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template></DataTable></div></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" class="admin-density-dialog admin-workbench is-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable> <AppDialog v-model="detailOpen" class="admin-density-dialog admin-workbench is-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
<p v-if="detailLoading" role="status">正在读取废弃单…</p> <p v-if="detailLoading" role="status">正在读取废弃单…</p>
<LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
@@ -111,6 +126,7 @@ onMounted(() => { void loadCustomers(); void load() })
<LoadErrorState v-else-if="photoErrors[photo.fileId]" title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState> <LoadErrorState v-else-if="photoErrors[photo.fileId]" title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small> <el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small>
</div></div><p v-else>暂无已提交的废弃现场照片</p> </div></div><p v-else>暂无已提交的废弃现场照片</p>
<div v-if="detail.summary.status === 'PENDING_REVIEW' && access.can(permission.discardApprove)"><el-button type="danger" :disabled="!!reviewing" @click="reject(detail.summary)">驳回</el-button></div>
<h3>状态流转</h3><table v-if="detail.histories.length" class="density-table" aria-label="废弃状态流转"><tbody><tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '未记录' }}</td><td>{{ item.remark || '—' }}</td></tr></tbody></table><p v-else>暂无状态流转记录</p> <h3>状态流转</h3><table v-if="detail.histories.length" class="density-table" aria-label="废弃状态流转"><tbody><tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '未记录' }}</td><td>{{ item.remark || '—' }}</td></tr></tbody></table><p v-else>暂无状态流转记录</p>
</template> </template>
</AppDialog> </AppDialog>
@@ -45,6 +45,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || [])
const orderColumns: DataTableColumn[] = [ const orderColumns: DataTableColumn[] = [
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 }, { key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'sourceBusinessType', label: '业务类型', width: 120, formatter: (row: { sourceBusinessType?: string | null }) => row.sourceBusinessType === 'LOCAL_LOOSEBOARD' ? '本土散板' : row.sourceBusinessType === 'WAREHOUSE_TRANSFER' ? '仓储转运' : '—' },
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 }, { key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' }, { key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
@@ -202,29 +202,28 @@ it('shows five working phases and uses server stage counts instead of current pa
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button') const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true) expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1']) expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' })) expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ALL' }))
await tabs[3]!.trigger('click'); await flushPromises() await tabs[3]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount() expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
}) })
it.each([0, 1, 2])('preserves section %s and its query after clearing the active dispatch filter', async index => { it.each([0, 1, 2])('queries all documents after clearing scope from section %s', async index => {
const wrapper = mount(DispatchesView, options); await flushPromises() const wrapper = mount(DispatchesView, options); await flushPromises()
const sectionValues = ['VEHICLE_INFO', 'DISPATCH_REVIEW', 'LOADING_REVIEW'] const sectionValues = ['VEHICLE_INFO', 'DISPATCH_REVIEW', 'LOADING_REVIEW']
const sectionSelector = '[aria-label="装车前审核分区"]' const sectionSelector = '[aria-label="装车前审核分区"]'
await wrapper.get(sectionSelector).findAll('button')[index]!.trigger('click'); await flushPromises() await wrapper.get(sectionSelector).findAll('button')[index]!.trigger('click'); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar) const toolbar = wrapper.getComponent(QueryToolbar)
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
toolbar.findAllComponents(ElSelect).find(select => select.props('placeholder') === '单据范围')!.vm.$emit('update:modelValue', '') toolbar.findAllComponents(ElSelect).find(select => select.props('placeholder') === '单据范围')!.vm.$emit('update:modelValue', '')
await flushPromises() await flushPromises()
expect(wrapper.get(sectionSelector).findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核']) expect(wrapper.get(sectionSelector).findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
expect(wrapper.get(sectionSelector).findAll('button')[index]!.attributes('aria-selected')).toBe('true') expect(wrapper.get(sectionSelector).findAll('button')[index]!.attributes('aria-selected')).toBe('true')
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises() await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[index] })) expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
const nextIndex = (index + 1) % 3 const nextIndex = (index + 1) % 3
await wrapper.get(sectionSelector).findAll('button')[nextIndex]!.trigger('click'); await flushPromises() await wrapper.get(sectionSelector).findAll('button')[nextIndex]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] })) expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises() await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
expect(wrapper.get(sectionSelector).findAll('button')).toHaveLength(3) expect(wrapper.get(sectionSelector).findAll('button')).toHaveLength(3)
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] })) expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises() await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
expect(wrapper.find(sectionSelector).exists()).toBe(false) expect(wrapper.find(sectionSelector).exists()).toBe(false)
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection') expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
@@ -279,7 +278,7 @@ it('shows trip progress independently of master vehicle status and leaves cancel
vi.mocked(listDispatches).mockResolvedValueOnce(page(records)) vi.mocked(listDispatches).mockResolvedValueOnce(page(records))
const wrapper = mount(DispatchesView, options); await flushPromises() const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar) const toolbar = wrapper.getComponent(QueryToolbar)
expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus']) expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus', 'status'])
expect(toolbar.props('fields').find(field => field.key === 'vehicleStatus')?.options?.map(option => option.label)).toEqual(['待发车', '配送中', '已送达']) expect(toolbar.props('fields').find(field => field.key === 'vehicleStatus')?.options?.map(option => option.label)).toEqual(['待发车', '配送中', '已送达'])
expect(wrapper.getComponent(DataTable).props('columns').some(column => column.key === 'vehicleStatus' && column.label === '车辆状态')).toBe(true) expect(wrapper.getComponent(DataTable).props('columns').some(column => column.key === 'vehicleStatus' && column.label === '车辆状态')).toBe(true)
expect(wrapper.findAllComponents(ElTag).map(tag => tag.text())).toEqual(['待发车', '待发车', '配送中', '已送达']) expect(wrapper.findAllComponents(ElTag).map(tag => tag.text())).toEqual(['待发车', '待发车', '配送中', '已送达'])
@@ -287,23 +286,21 @@ it('shows trip progress independently of master vehicle status and leaves cancel
expect(wrapper.text()).not.toContain('维修中') expect(wrapper.text()).not.toContain('维修中')
wrapper.unmount() wrapper.unmount()
}) })
it.each(['CANCELLED', 'ALL'])('retains advanced %s lookup and resets its hidden value', async status => { it.each(['CANCELLED', 'ALL'])('keeps %s lookup visible and resets to all documents', async status => {
const wrapper = mount(DispatchesView, options); await flushPromises() const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar) const toolbar = wrapper.getComponent(QueryToolbar)
toolbar.vm.$emit('search', { status: 'ACTIVE', vehicleStatus: 'WAIT_DEPARTURE' }); await flushPromises() toolbar.vm.$emit('search', { status: 'ACTIVE', vehicleStatus: 'WAIT_DEPARTURE' }); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单据范围')! const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单据范围')!
select.vm.$emit('update:modelValue', status); select.vm.$emit('change', status); await flushPromises() select.vm.$emit('update:modelValue', status); select.vm.$emit('change', status); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises() await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue').vehicleStatus).toBe('') expect(toolbar.props('modelValue').vehicleStatus).toBe('')
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status })) expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection') expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
await toolbar.findAll('button').find(button => button.text().startsWith('收起筛选'))!.trigger('click') expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(true)
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false) expect(toolbar.text()).not.toContain('高级筛选')
expect(toolbar.text()).toContain(status === 'CANCELLED' ? '高级筛选 · 已取消' : '高级筛选 · 全部单据')
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises() await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: '' }) expect(toolbar.props('modelValue')).toMatchObject({ status: '', vehicleStatus: '' })
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' })) expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ALL' }))
wrapper.unmount() wrapper.unmount()
}) })
it('returns to active dispatches when selecting vehicle progress from cancelled lookup', async () => { it('returns to active dispatches when selecting vehicle progress from cancelled lookup', async () => {
@@ -330,7 +327,7 @@ it.each(['dispatchReview', 'loadingReview'] as const)('shows %s rejection in the
row.reviews[reviewKey] = { ...row.reviews[reviewKey], status: 'REJECTED', reason: '车辆证照需补齐' } row.reviews[reviewKey] = { ...row.reviews[reviewKey], status: 'REJECTED', reason: '车辆证照需补齐' }
vi.mocked(listDispatches).mockResolvedValue(page([row])) vi.mocked(listDispatches).mockResolvedValue(page([row]))
const wrapper = mount(DispatchesView, options); await flushPromises() const wrapper = mount(DispatchesView, options); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'VEHICLE_INFO' })) expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
expect(wrapper.text()).toContain(`${reviewKey === 'dispatchReview' ? '调度审核' : '理货员审核'}退回:车辆证照需补齐`) expect(wrapper.text()).toContain(`${reviewKey === 'dispatchReview' ? '调度审核' : '理货员审核'}退回:车辆证照需补齐`)
wrapper.unmount() wrapper.unmount()
}) })
@@ -424,8 +421,8 @@ it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'EDIT' })) expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'EDIT' }))
if (event === 'rejected') { if (event === 'rejected') {
expect(wrapper.text()).toContain('本轮审核已经提交') expect(wrapper.text()).toContain('本轮审核已经提交')
expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).not.toContain('A0409')
expect(wrapper.text()).toContain('list-reject-request') expect(wrapper.text()).not.toContain('list-reject-request')
} }
expect(wrapper.text()).not.toContain('结果尚待核对') expect(wrapper.text()).not.toContain('结果尚待核对')
wrapper.unmount() wrapper.unmount()
@@ -481,14 +478,14 @@ it('opens the exception form from the dispatching row and hides it without permi
denied.unmount() denied.unmount()
}) })
it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_REJECTED']] as const)('returns a rejected %s to vehicle information',async(action,index,reviewState)=>{ it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED','DELIVERY'],['LOADING_REVIEW',2,'LOADING_REJECTED','DELIVERY'],['DISPATCH_REVIEW',1,'DISPATCH_REJECTED','LOCAL_TRUCK'],['LOADING_REVIEW',2,'LOADING_REJECTED','LOCAL_TRUCK']] as const)('returns a rejected %s to vehicle information (%s)',async(action,index,reviewState,taskType)=>{
const allowed={allowed:true,blockCode:null,blockReason:null} const allowed={allowed:true,blockCode:null,blockReason:null}
vi.mocked(listDispatches).mockResolvedValue(page([listRow({actions:{[action]:allowed}})])) vi.mocked(listDispatches).mockResolvedValue(page([listRow({taskType,actions:{[action]:allowed}})]))
const wrapper=mount(DispatchesView,options);await flushPromises() const wrapper=mount(DispatchesView,options);await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click');await flushPromises() await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click');await flushPromises()
const row=wrapper.findAllComponents(RowActions).find(entry=>entry.props('moreLabel')==='更多操作 · PC/1')! const row=wrapper.findAllComponents(RowActions).find(entry=>entry.props('moreLabel')==='更多操作 · PC/1')!
row.vm.$emit('action',action);await flushPromises() row.vm.$emit('action',action);await flushPromises()
vi.mocked(getDispatch).mockResolvedValueOnce(detail({reviewState,stage:'BEFORE_LOADING'})) vi.mocked(getDispatch).mockResolvedValueOnce(detail({taskType,reviewState,stage:'BEFORE_LOADING'}))
wrapper.getComponent({name:'DispatchWorkflowDialog'}).vm.$emit('saved');await flushPromises() wrapper.getComponent({name:'DispatchWorkflowDialog'}).vm.$emit('saved');await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({page:1,stage:'BEFORE_LOADING',beforeLoadingSection:'VEHICLE_INFO'})) expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({page:1,stage:'BEFORE_LOADING',beforeLoadingSection:'VEHICLE_INFO'}))
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true') expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
@@ -48,8 +48,7 @@ const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const total = ref(0) const total = ref(0)
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO') const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' }) const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', vehicleStatus: '', status: '' })
const showAdvancedFilters = ref(false)
const vehicleStatuses = [ const vehicleStatuses = [
{ value: 'WAIT_DEPARTURE', label: '待发车', type: 'warning' }, { value: 'WAIT_DEPARTURE', label: '待发车', type: 'warning' },
{ value: 'DELIVERING', label: '配送中', type: 'primary' }, { value: 'DELIVERING', label: '配送中', type: 'primary' },
@@ -63,14 +62,14 @@ const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('') const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([]) const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }] const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && (model.value.status || 'ACTIVE') === 'ACTIVE') const showSections = computed(() => stage.value === 'BEFORE_LOADING' && (!model.value.status || model.value.status === 'ACTIVE'))
const availableFields = computed<QueryField[]>(() => [ const availableFields = computed<QueryField[]>(() => [
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' }, { key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' }, { key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
{ key: 'vehicleStatus', label: '车辆状态', placeholder: '车辆状态', type: 'select', width: 140, options: [...vehicleStatuses], props: { 'aria-label': '车辆状态', onChange: (value: unknown) => { if (value) model.value.status = 'ACTIVE' } } }, { key: 'vehicleStatus', label: '车辆状态', placeholder: '车辆状态', type: 'select', width: 140, options: [...vehicleStatuses], props: { 'aria-label': '车辆状态', onChange: (value: unknown) => { if (value) model.value.status = 'ACTIVE' } } },
{ key: 'status', label: '单据范围', placeholder: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }], props: { 'aria-label': '单据范围', onChange: (value: unknown) => { if (value && value !== 'ACTIVE') model.value.vehicleStatus = '' } } }, { key: 'status', label: '单据范围', placeholder: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }], props: { 'aria-label': '单据范围', onChange: (value: unknown) => { if (value && value !== 'ACTIVE') model.value.vehicleStatus = '' } } },
]) ])
const fields = computed(() => availableFields.value.filter(field => showAdvancedFilters.value || field.key !== 'status')) const fields = availableFields
const columns = computed<DataTableColumn[]>(() => [ const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : rows.value.some(canDownloadBol) ? 290 : 220, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : rows.value.some(canDownloadBol) ? 290 : 220, fixed: 'left', slotName: 'actions' },
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' }, { key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
@@ -95,17 +94,17 @@ async function load() {
const current = ++sequence const current = ++sequence
loading.value = true; loadError.value = null loading.value = true; loadError.value = null
try { try {
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined, const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ALL') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
...(showSections.value ? { beforeLoadingSection: section.value } : {}) }) ...(showSections.value && model.value.status === 'ACTIVE' ? { beforeLoadingSection: section.value } : {}) })
if (current !== sequence) return if (current !== sequence) return
rows.value = result.records; total.value = result.total; statistics.value = result.statistics rows.value = result.records; total.value = result.total; statistics.value = result.statistics
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } } } catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
finally { if (current === sequence) loading.value = false } finally { if (current === sequence) loading.value = false }
} }
function search(value: QueryModel) { function search(value: QueryModel) {
model.value = { ...value, status: value.status || 'ACTIVE' } model.value = { ...value, status: value.status || '' }
const selected = vehicleStatuses.find(item => item.value === model.value.vehicleStatus) const selected = vehicleStatuses.find(item => item.value === model.value.vehicleStatus)
if (model.value.status !== 'ACTIVE') model.value.vehicleStatus = '' if (model.value.status !== 'ACTIVE') { model.value.vehicleStatus = ''; section.value = 'VEHICLE_INFO' }
else if (selected && vehicleStatusForStage(stage.value)?.value !== selected.value) { else if (selected && vehicleStatusForStage(stage.value)?.value !== selected.value) {
// 车辆运输进度与阶段联动,查询仍由服务端按阶段分页,避免交叉条件恒为空。 // 车辆运输进度与阶段联动,查询仍由服务端按阶段分页,避免交叉条件恒为空。
stage.value = selected.value === 'WAIT_DEPARTURE' ? 'BEFORE_LOADING' : selected.value stage.value = selected.value === 'WAIT_DEPARTURE' ? 'BEFORE_LOADING' : selected.value
@@ -113,13 +112,13 @@ function search(value: QueryModel) {
} }
page.value = 1; void load() page.value = 1; void load()
} }
function resetSearch() { search({ dispatchNo: '', vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' }) } function resetSearch() { search({ dispatchNo: '', vehiclePlate: '', vehicleStatus: '', status: '' }) }
function switchStage(value: DispatchStage) { function switchStage(value: DispatchStage) {
stage.value = value stage.value = value
if (model.value.vehicleStatus) model.value.vehicleStatus = vehicleStatusForStage(value)?.value || '' if (model.value.vehicleStatus) model.value.vehicleStatus = vehicleStatusForStage(value)?.value || ''
section.value = 'VEHICLE_INFO'; page.value = 1; void load() section.value = 'VEHICLE_INFO'; page.value = 1; void load()
} }
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() } function switchSection(value: BeforeLoadingSection) { model.value.status = 'ACTIVE'; section.value = value; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) } function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL'] const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(row.stage) } function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(row.stage) }
@@ -229,11 +228,11 @@ onBeforeUnmount(() => { sequence++ })
<template> <template>
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓排车列表"> <AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" /> <OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState> <el-alert v-if="rejectionError" :title="rejectionError.message" type="error" :closable="false" show-icon />
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()"> <div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template> <template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template> <template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="dispatch-query-filters" @click="showAdvancedFilters = !showAdvancedFilters">{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}{{ model.status === 'CANCELLED' ? ' · 已取消' : model.status === 'ALL' ? ' · 全部单据' : '' }}</el-button></template></QueryToolbar></div></template> <template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"></QueryToolbar></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template> <template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' || canDownloadBol(row as DispatchListRow) ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template> <template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' || canDownloadBol(row as DispatchListRow) ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template> <template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
@@ -145,6 +145,33 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
}) })
}) })
it('allows selecting and submitting manual storage rent', async () => {
const { dialog } = await openFeeEditor()
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.findAllComponents({ name: 'ElOption' }).some(option => option.props('value') === 'FEE_TYPE_077')).toBe(true)
select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
dialog.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 80)
await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeItemCode: 'FEE_TYPE_077', amount: 80 }))
})
it('creates a return order fee without charging quantity or unit inputs', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'RT-1', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
const { dialog } = await openFeeEditor()
expect(dialog.text()).not.toContain('计费数量')
expect(dialog.text()).not.toContain('计费单位')
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'FEE_TYPE_071')
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 80)
await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledOnce()
const payload = vi.mocked(createLastMileOrderFee).mock.calls[0]![1]
expect(payload.amount).toBe(80)
expect(payload).not.toHaveProperty('chargeableQuantity')
expect(payload).not.toHaveProperty('quantityUnit')
expect(payload).not.toHaveProperty('unitPrice')
})
it('shows business references without changing the transfer workflow or requiring business-list permission', async () => { it('shows business references without changing the transfer workflow or requiring business-list permission', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'A', orderNo: 'WT-1', sourceOrderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }, cargo: [], fees: [], statusHistory: [], operationHistory: [] }) vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'A', orderNo: 'WT-1', sourceOrderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises() const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
@@ -284,13 +311,13 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises() await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled() expect(createLastMileOrderFee).not.toHaveBeenCalled()
}) })
it('uses the live catalog, excludes automatic rent and only loads suppliers for payables', async () => { it('uses the live catalog including rent and only loads suppliers for payables', async () => {
const { dialog } = await openFeeEditor() const { dialog } = await openFeeEditor()
expect(listOverseasFeeItems).toHaveBeenCalledWith('RECEIVABLE') expect(listOverseasFeeItems).toHaveBeenCalledWith('RECEIVABLE')
expect(listSuppliers).not.toHaveBeenCalled() expect(listSuppliers).not.toHaveBeenCalled()
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]! const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.props('allowCreate')).toBe(false) expect(select.props('allowCreate')).toBe(false)
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071']) expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071', 'FEE_TYPE_077'])
await fillFee(dialog, 'PAYABLE') await fillFee(dialog, 'PAYABLE')
expect(listOverseasFeeItems).toHaveBeenLastCalledWith('PAYABLE') expect(listOverseasFeeItems).toHaveBeenLastCalledWith('PAYABLE')
expect(listSuppliers).toHaveBeenCalledOnce() expect(listSuppliers).toHaveBeenCalledOnce()
@@ -124,7 +124,7 @@ const showReturnForecast = computed(() => isReturnOrder.value && (detail.value?.
const orderFields = computed<DetailField[]>(() => { const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order const order = detail.value?.order
const fields: DetailField[] = [ const fields: DetailField[] = [
{ label: '履约类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) }, { label: '履约类型', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
{ label: '入仓码', value: text(order?.inboundCode) }, { label: '入仓码', value: text(order?.inboundCode) },
{ label: '货主', value: text(order?.customerName) }, { label: '货主', value: text(order?.customerName) },
{ label: '海外仓', value: warehouseDisplayName.value }, { label: '海外仓', value: warehouseDisplayName.value },
@@ -149,7 +149,7 @@ function isNonzeroDifference(field: DetailField) { return field.label === '箱
const containerFields = computed<DetailField[]>(() => { const containerFields = computed<DetailField[]>(() => {
const order = detail.value?.order const order = detail.value?.order
return [ return [
{ label: '运输渠道', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) }, { label: '运输渠道', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
{ label: '集装箱号', value: text(order?.containerNo) }, { label: '集装箱号', value: text(order?.containerNo) },
{ label: '托数', value: Number(order?.palletCount || 0) }, { label: '托数', value: Number(order?.palletCount || 0) },
{ label: '箱数', value: Number(order?.packageCount || 0) }, { label: '箱数', value: Number(order?.packageCount || 0) },
@@ -272,7 +272,7 @@ async function loadFeeItems() {
feeForm.itemCode = ''; feeForm.itemName = '' feeForm.itemCode = ''; feeForm.itemName = ''
try { try {
const items = await listOverseasFeeItems(direction) const items = await listOverseasFeeItems(direction)
if (request === feeItemsRequest) feeItemOptions.value = items.filter(item => item.feeCode !== 'FEE_TYPE_077') if (request === feeItemsRequest) feeItemOptions.value = items
} catch (error) { } catch (error) {
if (request === feeItemsRequest) feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败').message if (request === feeItemsRequest) feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败').message
} finally { if (request === feeItemsRequest) feeItemsLoading.value = false } } finally { if (request === feeItemsRequest) feeItemsLoading.value = false }
@@ -324,7 +324,7 @@ async function saveFee() {
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return } if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
saving.value = true; mutationError.value = null saving.value = true; mutationError.value = null
try { try {
await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined }) await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), ...(!isReturnOrder.value ? { chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined } : {}) })
feeDialogOpen.value = false; ElMessage.success('费用已提交审核'); await load() feeDialogOpen.value = false; ElMessage.success('费用已提交审核'); await load()
await Promise.all([feePanel.value?.refresh(), payableFeePanel.value?.refresh()]); activeTab.value = 'fees' await Promise.all([feePanel.value?.refresh(), payableFeePanel.value?.refresh()]); activeTab.value = 'fees'
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用新增失败,请重试') } } catch (error) { mutationError.value = getApiErrorDetails(error, '费用新增失败,请重试') }
@@ -351,7 +351,7 @@ onMounted(load)
<section aria-label="商业来源与关联单据"> <section aria-label="商业来源与关联单据">
<h2 class="source-heading">关联单据</h2> <h2 class="source-heading">关联单据</h2>
<dl class="detail-fields density-fields"> <dl class="detail-fields density-fields">
<div class="wide"><dt>业务来源</dt><dd>{{ text(detail?.order.businessSource?.label) }}</dd></div> <div class="wide"><dt>业务来源</dt><dd>{{ lastMileOrderTypeLabel(detail?.order.businessSource?.label) }}</dd></div>
<div class="wide"><dt>关联商业单号</dt><dd><el-button v-if="commercialLink" link type="primary" @click="router.push(commercialLink)">{{ detail?.order.businessSource?.businessOrderNo }}</el-button><span v-else>{{ text(detail?.order.businessSource?.businessOrderNo) }}</span></dd></div> <div class="wide"><dt>关联商业单号</dt><dd><el-button v-if="commercialLink" link type="primary" @click="router.push(commercialLink)">{{ detail?.order.businessSource?.businessOrderNo }}</el-button><span v-else>{{ text(detail?.order.businessSource?.businessOrderNo) }}</span></dd></div>
<div class="wide"><dt>外接预订号</dt><dd>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div> <div class="wide"><dt>外接预订号</dt><dd>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div>
<div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div> <div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div>
@@ -443,7 +443,7 @@ onMounted(load)
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr> <tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr> <tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr> <tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr> <tr v-if="!isReturnOrder"><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr>
<tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr> <tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr>
<tr v-if="existingFeesError"><td colspan="2">{{ existingFeesError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></td></tr> <tr v-if="existingFeesError"><td colspan="2">{{ existingFeesError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></td></tr>
@@ -17,6 +17,7 @@ import { listContainerArrivalTimes } from '@/modules/container-loading'
import { import {
changeContainerReceivingWarehouse, changeContainerReceivingWarehouse,
createManualInboundPlan, createManualInboundPlan,
createExceptionReturnInboundPlan,
convertLastMileContainerToInboundPlans, convertLastMileContainerToInboundPlans,
getContainerInboundPlanPreview, getContainerInboundPlanPreview,
getContainerPlanProgress, getContainerPlanProgress,
@@ -46,7 +47,7 @@ vi.mock('../api/lastMileOrders', () => ({
getReturnOrderStatistics: vi.fn(), getReturnOrderStatistics: vi.fn(),
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(), listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
changeContainerReceivingWarehouse: vi.fn(), changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(), createExceptionReturnInboundPlan: vi.fn(), createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(), getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(), reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(),
})) }))
@@ -454,6 +455,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
summary, summary,
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }], orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
}) })
vi.mocked(createExceptionReturnInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never) vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime]) vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime])
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never) vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
@@ -666,6 +668,30 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).not.toContain('尾程订单加载失败') expect(wrapper.text()).not.toContain('尾程订单加载失败')
}) })
it('requires an explicit return ETA and plans the current tail order without a source container', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow({ orderNo: 'WC1', sourceOrderNo: 'ZY1', orderTypeCode: 'DELIVERY_RETURN', currentStatusCode: 'PENDING_INBOUND', sourceContainerBookingId: null, inboundCode: null, estimatedWarehouseAt: '', warehouseEta: '—' })])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('WC1')
expect(dialog.text()).toContain('ZY1')
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createExceptionReturnInboundPlan).not.toHaveBeenCalled()
dialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-05T14:30:00')
await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createExceptionReturnInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ customerId, orderId: '2092873463568596994', expectedOrderVersion: 3 }), '2026-11-05T14:30:00', expect.any(String))
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(listContainerArrivalTimes).not.toHaveBeenCalled()
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
})
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => { it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
const wrapper = await openAfterDeparture() const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()]) await selectRows(wrapper, [selectedRow()])
@@ -1410,6 +1436,24 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises() await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-')) expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-'))
}) })
it.each(['RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'LOCAL_LOOSEBOARD', 'RETURN_RELABEL', 'CARGO_DISCARD'])('does not offer manual transport nodes for %s', async orderType => {
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'business-tail', customerId, orderNo: 'BUSINESS-1', orderType, currentStatus: 'PENDING_ASSIGNMENT' }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
expect(wrapper.get('.el-table__body-wrapper .row-actions').text()).not.toContain('修改节点')
await (wrapper.vm as unknown as { openManualNodes(row: object): Promise<void> }).openManualNodes(selectedRow({ orderTypeCode: orderType }))
expect(getLastMileOrderManualNodes).not.toHaveBeenCalled()
})
it('provides the same return receipt statuses as the list labels and submits their exact codes', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const status = (toolbar.props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>).find(field => field.key === 'status')!
for (const [value, label] of [['PENDING_REVIEW', '待审核'], ['PENDING_RECEIPT', '待签收入库'], ['PARTIALLY_RECEIVED', '部分入库'], ['INBOUND_COMPLETED', '全部入库']]) {
expect(status.options).toContainEqual({ value, label })
toolbar.vm.$emit('search', { ...toolbar.props('modelValue'), status: value })
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: value }))
}
})
it('edits only customs nodes while preserving hidden node facts and zero version', async () => { it('edits only customs nodes while preserving hidden node facts and zero version', async () => {
vi.mocked(getLastMileOrderManualNodes).mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [{ nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }], version: 0 }) vi.mocked(getLastMileOrderManualNodes).mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [{ nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }], version: 0 })
const wrapper = await mountView() const wrapper = await mountView()
@@ -21,6 +21,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders' import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { businessOrderLink } from '../businessOrderLink' import { businessOrderLink } from '../businessOrderLink'
import { createExceptionReturnInboundPlan } from '../api/lastMileOrders'
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/lastMileOrders' import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue' import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders' import { reviewCargoDiscard } from '../api/lastMileOrders'
@@ -111,6 +112,8 @@ const inboundPlanSourcePalletCount = ref<number | null>(null)
const inboundPlanPreviewLoading = ref(false) const inboundPlanPreviewLoading = ref(false)
const inboundPlanPreviewError = ref('') const inboundPlanPreviewError = ref('')
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP') const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
const inboundPlanIsExceptionReturn = computed(() => actionTarget.value?.orderTypeCode === 'DELIVERY_RETURN')
const exceptionReturnArrival = ref('')
const inboundPlanArrival = ref<ContainerArrivalTime | null>(null) const inboundPlanArrival = ref<ContainerArrivalTime | null>(null)
const inboundPlanArrivalLoading = ref(false) const inboundPlanArrivalLoading = ref(false)
const inboundPlanArrivalError = ref('') const inboundPlanArrivalError = ref('')
@@ -119,7 +122,7 @@ const inboundPlanArrivalProjection = computed(() => {
const arrival = inboundPlanArrival.value const arrival = inboundPlanArrival.value
return projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined) return projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
}) })
const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || (inboundPlanIsExceptionReturn.value ? !exceptionReturnArrival.value : !inboundPlanArrivalProjection.value.date)
|| (inboundPlanIsDropship.value || (inboundPlanIsDropship.value
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null && (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|| inboundPlanSourcePalletCount.value <= 0))) || inboundPlanSourcePalletCount.value <= 0)))
@@ -217,12 +220,16 @@ const allFields: QueryField[] = [
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' }, { key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' }, { key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [ { key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '待签收入库', value: 'PENDING_RECEIPT' },
{ label: '部分入库', value: 'PARTIALLY_RECEIVED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '待换标', value: 'WAIT_RELABEL' }, { label: '待换标', value: 'WAIT_RELABEL' },
{ label: '待排车', value: 'READY_FOR_DISPATCH' }, { label: '待排车', value: 'READY_FOR_DISPATCH' },
{ label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '待出库', value: 'WAIT_OUTBOUND' },
{ label: '待入库', value: 'PENDING_INBOUND' }, { label: '待入库', value: 'PENDING_INBOUND' },
{ label: '入库完成', value: 'INBOUND_COMPLETED' }, { label: '全部入库', value: 'INBOUND_COMPLETED' },
{ label: '待派送', value: 'PENDING_DELIVERY' }, { label: '待派送', value: 'PENDING_DELIVERY' },
{ label: '派送中', value: 'DELIVERING' }, { label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' }, { label: '派送完成', value: 'DELIVERED' },
@@ -646,6 +653,7 @@ function openInboundPlan(source?: object) {
actionError.value = null actionError.value = null
inboundPlanArrival.value = null inboundPlanArrival.value = null
inboundPlanArrivalError.value = '' inboundPlanArrivalError.value = ''
exceptionReturnArrival.value = target.orderTypeCode === 'DELIVERY_RETURN' ? target.estimatedWarehouseAt || '' : ''
inboundPlanOpen.value = true inboundPlanOpen.value = true
void refreshInboundPlanArrival(target) void refreshInboundPlanArrival(target)
void refreshManualPalletMinimum(target) void refreshManualPalletMinimum(target)
@@ -653,6 +661,7 @@ function openInboundPlan(source?: object) {
/** 每次打开重新读取柜级权威日期,避免使用列表旧值或在页面推算默认日期。 */ /** 每次打开重新读取柜级权威日期,避免使用列表旧值或在页面推算默认日期。 */
async function refreshInboundPlanArrival(target: LastMileOrderListItem) { async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
const request = ++inboundPlanArrivalRequest const request = ++inboundPlanArrivalRequest
if (target.orderTypeCode === 'DELIVERY_RETURN') { inboundPlanArrivalLoading.value = false; return }
inboundPlanArrivalLoading.value = true inboundPlanArrivalLoading.value = true
try { try {
if (!target.sourceContainerBookingId) { if (!target.sourceContainerBookingId) {
@@ -676,6 +685,7 @@ async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
} }
} }
async function refreshManualPalletMinimum(target: LastMileOrderListItem) { async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
if (target.orderTypeCode === 'DELIVERY_RETURN') return
if (!target.sourceContainerBookingId) return if (!target.sourceContainerBookingId) return
inboundPlanPreviewLoading.value = true inboundPlanPreviewLoading.value = true
inboundPlanPreviewError.value = '' inboundPlanPreviewError.value = ''
@@ -710,23 +720,26 @@ async function submitInboundPlan() {
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划') ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
return return
} }
if (!row || !row.overseasWarehouseId || inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) { if (!row || !row.overseasWarehouseId || inboundPlanSubmitDisabled.value || (!inboundPlanIsExceptionReturn.value && (!frozenInboundCode || frozenInboundCode === '—')) || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数') ElMessage.warning(inboundPlanIsExceptionReturn.value ? '请选择预计到仓时间,并确认退回仓和计划托数' : '当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
return return
} }
actionLoading.value = true; actionError.value = null actionLoading.value = true; actionError.value = null
try { try {
const result = await createManualInboundPlan({ const payload = {
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version, customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
palletCount: inboundPlanForm.palletCount, palletCount: inboundPlanForm.palletCount,
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned, palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent, mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
remark: inboundPlanForm.remark.trim() || undefined, remark: inboundPlanForm.remark.trim() || undefined,
}, idempotencyKey('manual-inbound-plan')) }
const result = inboundPlanIsExceptionReturn.value
? await createExceptionReturnInboundPlan(payload, exceptionReturnArrival.value, idempotencyKey('exception-return-plan'))
: await createManualInboundPlan(payload, idempotencyKey('manual-inbound-plan'))
const palletCodesReady = result.pallets?.length === result.palletCount const palletCodesReady = result.pallets?.length === result.palletCount
&& result.pallets.every((pallet) => Boolean(pallet.warehousePalletId && pallet.palletNo)) && result.pallets.every((pallet) => Boolean(pallet.warehousePalletId && pallet.palletNo))
inboundPlanOpen.value = false; selectedRows.value = [] inboundPlanOpen.value = false; selectedRows.value = []
ElMessage.success(palletCodesReady ElMessage.success(inboundPlanIsExceptionReturn.value ? '入库计划已生成,请在异常退回验收中完成收货' : palletCodesReady
? '入库计划和托盘码已自动生成' ? '入库计划和托盘码已自动生成'
: '入库计划已保存,整柜计划齐全后将自动生成托盘码') : '入库计划已保存,整柜计划齐全后将自动生成托盘码')
await load() await load()
@@ -754,8 +767,14 @@ async function submitHold() {
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') } } catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
finally { actionLoading.value = false } finally { actionLoading.value = false }
} }
const manualNodeExcludedOrderTypes = new Set(['RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'LOCAL_LOOSEBOARD', 'RETURN_RELABEL', 'CARGO_DISCARD'])
/** 独立仓内业务不提供头程运输节点修改入口。 */
function canEditManualNodes(row: LastMileOrderListItem) {
return !manualNodeExcludedOrderTypes.has(row.orderTypeCode)
}
async function openManualNodes(source: object) { async function openManualNodes(source: object) {
const row = source as LastMileOrderListItem const row = source as LastMileOrderListItem
if (!canEditManualNodes(row)) return
actionTarget.value = row actionTarget.value = row
manualNodesOpen.value = true manualNodesOpen.value = true
manualNodesLoading.value = true manualNodesLoading.value = true
@@ -1069,7 +1088,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '入库', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated }, { key: 'inbound-plan', label: '入库', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') }, { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
{ key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) }, { key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) && canEditManualNodes(row) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) }, { key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '换标退回重做', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && canReworkRelabel(row) }, { key: 'rework-relabel', label: '换标退回重做', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && canReworkRelabel(row) },
] ]
@@ -1332,15 +1351,17 @@ onMounted(() => { void load() })
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon /> <el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon /> <el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading"> <el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3> <section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">{{ inboundPlanIsExceptionReturn ? '异常退回按本次退回安排填写预计到仓时间,计划关联当前尾程订单,收货时从原出库来源恢复库存。' : '优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。' }}</InlineHelp></h3>
<table class="density-table last-mile-dialog-facts inbound-plan-source"><colgroup><col class="inbound-plan-source__label" /><col /><col class="inbound-plan-source__label" /><col /></colgroup><tbody> <table class="density-table last-mile-dialog-facts inbound-plan-source"><colgroup><col class="inbound-plan-source__label" /><col /><col class="inbound-plan-source__label" /><col /></colgroup><tbody>
<tr><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr> <tr v-if="inboundPlanIsExceptionReturn"><th scope="row">尾程单号</th><td>{{ actionTarget?.orderNo }}</td><th scope="row">来源尾程单号</th><td>{{ actionTarget?.sourceOrderNo || '—' }}</td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr> <tr v-if="inboundPlanIsExceptionReturn"><th scope="row">预计到仓时间 <span class="el-form-item__required">*</span></th><td colspan="3"><el-date-picker v-model="exceptionReturnArrival" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择本次退回预计到仓时间" clearable /></td></tr>
<tr v-else><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanIsExceptionReturn ? actionTarget?.orderNo : inboundPlanForm.inboundCode || '—' }}</td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3> <section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
<table class="density-table density-table--editable last-mile-command-table"><tbody> <table class="density-table density-table--editable last-mile-command-table"><tbody>
<tr><td><el-form-item label="托数" required><div class="inbound-plan-pallet-field"><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></div></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr> <tr><td><el-form-item label="托数" required><div class="inbound-plan-pallet-field"><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>{{ inboundPlanIsExceptionReturn ? '按本次退回货物安排计划托数。' : `按柜级预览至少 ${inboundPlanMinimumPalletCount} 托,可增加,不能减少。` }}</span></div></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="是否外配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr> <tr><td><el-form-item label="是否外配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
@@ -81,6 +81,23 @@ describe('OutboundDetailView', () => {
emptyWrapper.unmount() emptyWrapper.unmount()
}) })
it.each([
['WAIT_PICK', '待拣货'], ['WAIT_OUTBOUND', '待出库'], ['PICKING', '拣货中'],
['PARTIAL_PICKED', '部分已拣货'], ['PICKED', '已拣货'], ['OUTBOUNDED', '已出库'], ['CANCELLED', '已取消'],
[null, '—'], ['FUTURE_STATUS', '未知状态'],
])('renders outbound pallet status %s in Chinese', async (status, label) => {
vi.mocked(getOutbound).mockResolvedValueOnce({
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
pallets: [{ palletNo: 'DP-1', status }], deliveries: [],
})
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const table = wrapper.get('.outbound-pallet-table')
expect(table.text()).toContain(label)
if (status) expect(table.text()).not.toContain(status)
wrapper.unmount()
})
it('shows cancellation only before picking with the independent permission', async () => { it('shows cancellation only before picking with the independent permission', async () => {
canPermission.mockImplementation((code: string) => code === 'platform:overseas-warehouse:outbound:cancel') canPermission.mockImplementation((code: string) => code === 'platform:overseas-warehouse:outbound:cancel')
vi.mocked(getOutbound).mockResolvedValueOnce({ outbound: { id: '1', outboundNo: 'OUT/1', status: 'WAIT_PICK' }, pallets: [], deliveries: [] }) vi.mocked(getOutbound).mockResolvedValueOnce({ outbound: { id: '1', outboundNo: 'OUT/1', status: 'WAIT_PICK' }, pallets: [], deliveries: [] })
@@ -24,10 +24,10 @@ const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false) const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const warehouseName = ref('') const warehouseName = ref('')
const statusLabels: Record<string, string> = { WAIT_PICK: '待拣货', WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' } const statusLabels: Record<string, string> = { WAIT_PICK: '待拣货', WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PARTIAL_PICKED: '部分已拣货', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
const palletColumns: DataTableColumn[] = [ const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', label: '托盘码', width: 220 }, { key: 'palletNo', label: '托盘码', width: 220 },
{ key: 'status', label: '状态', width: 110, afterColumn: 'palletNo' }, { key: 'status', label: '状态', width: 110, afterColumn: 'palletNo', formatter: (_row: unknown, _column: unknown, value: unknown) => palletStatusLabel(value) },
{ key: 'inboundCode', label: '入仓码', width: 190 }, { key: 'inboundCode', label: '入仓码', width: 190 },
{ key: 'destinationWarehouseCode', label: '目的仓编码', width: 150 }, { key: 'destinationWarehouseCode', label: '目的仓编码', width: 150 },
{ key: 'destinationWarehouseName', label: '目的仓名称', minWidth: 180 }, { key: 'destinationWarehouseName', label: '目的仓名称', minWidth: 180 },
@@ -62,6 +62,7 @@ watch(() => detail.value, async (value, _previous, onCleanup) => {
} }
}) })
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) } function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
function palletStatusLabel(value: unknown) { const code = String(value ?? '').trim(); return code ? statusLabels[code] || '未知状态' : '—' }
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) } function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(detail.value?.outbound.status || ''))) const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(detail.value?.outbound.status || '')))
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } } async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }