Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8408b8076 | ||
|
|
7704532e8f | ||
|
|
19a618c9d2 | ||
|
|
51d0877a45 |
@@ -60,7 +60,7 @@ function setFee(overrides: Partial<FinanceFeeSourceDetail> = {}) {
|
||||
vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 })
|
||||
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: [
|
||||
{ path: '/finance/customer-arap', name: 'finance-customer-arap', 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.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()
|
||||
return { wrapper, router }
|
||||
}
|
||||
@@ -99,17 +102,6 @@ async function fillCreation(dialog: VueWrapper, selected = false) {
|
||||
await flushPromises()
|
||||
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)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
@@ -233,6 +225,30 @@ describe('fee source and statement native dialogs', () => {
|
||||
await click(dialog, '确定')
|
||||
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 () => {
|
||||
const { dialog } = await openCreate()
|
||||
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.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('keeps all adjustment rows in the main dialog flow with plain statement facts', async () => {
|
||||
vi.mocked(api.getReconciliationStatement).mockResolvedValue({ ...detail, lines: Array.from({ length: 13 }, (_, index) => ({ ...detail.lines[0]!, statementLineId: 'line-' + index, feeItemName: '费用' + (index + 1) })) })
|
||||
const { dialog } = await openAdjustment()
|
||||
expect(dialog.props('width')).toContain('900px')
|
||||
expect(dialog.props('title')).toContain(statement.billNo)
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
expect(dialog.getComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
|
||||
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, '确定')
|
||||
it.each(['APPROVED', 'ISSUED', 'PAID'])('does not offer adjustment or void after statement approval: %s', async status => {
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }])
|
||||
const { wrapper } = await mountPage('statements')
|
||||
const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
|
||||
expect(labels).toContain('详情')
|
||||
expect(labels).not.toContain('调整')
|
||||
expect(labels).not.toContain('作废')
|
||||
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([
|
||||
['驳回', '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>
|
||||
</tbody></table></section>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineComponent, h, KeepAlive, type VNode } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
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 { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -53,7 +54,7 @@ const eligibleFeeSource: FinanceFeeSourceSummary = {
|
||||
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 auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
@@ -63,7 +64,10 @@ async function mountPage(permissions = ['platform:finance:customer-statement:lis
|
||||
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
|
||||
] })
|
||||
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()
|
||||
return { wrapper, router }
|
||||
}
|
||||
@@ -121,6 +125,25 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
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 () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
|
||||
@@ -160,31 +183,33 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
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([
|
||||
'platform:finance:customer-statement:list',
|
||||
'platform:finance:reconciliation:adjust',
|
||||
'platform:finance:reconciliation:void',
|
||||
'platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void',
|
||||
])
|
||||
const actionButtons = [...document.querySelectorAll('button')].filter((button) => ['详情', '调整', '作废'].includes(button.textContent?.trim() || ''))
|
||||
const detailButton = actionButtons.find((button) => button.textContent?.trim() === '详情')
|
||||
const adjustButton = actionButtons.find((button) => button.textContent?.trim() === '调整')
|
||||
const voidButton = actionButtons.find((button) => button.textContent?.trim() === '作废')
|
||||
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)
|
||||
const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
|
||||
expect(labels).toContain('详情')
|
||||
expect(labels).not.toContain('调整')
|
||||
expect(labels).not.toContain('作废')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
|
||||
@@ -224,10 +249,6 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(text).toContain('2026-08-14 03:27')
|
||||
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 () => {
|
||||
@@ -373,6 +394,67 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
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 () => {
|
||||
const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
|
||||
const { wrapper } = await mountPage(permissions)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
|
||||
@@ -29,15 +29,6 @@ import { actionReconciliationStatement, createReconciliationStatement, getReconc
|
||||
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||
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'> & {
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
@@ -85,12 +76,9 @@ const createForm = ref({
|
||||
remark: '',
|
||||
})
|
||||
let createSourceLoadSequence = 0
|
||||
const adjustOpen = ref(false)
|
||||
const adjustBusy = ref(false)
|
||||
const adjustError = ref<ApiErrorDetails | null>(null)
|
||||
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
||||
const adjustReason = ref('')
|
||||
const adjustLines = ref<AdjustmentRow[]>([])
|
||||
let selectedCreateGeneration = 0
|
||||
let pageActive = true
|
||||
let initialLoadCompleted = false
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
|
||||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||||
@@ -100,7 +88,6 @@ const canCreate = computed(() => access.can('platform:finance:reconciliation:cre
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
|
||||
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 canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
|
||||
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
|
||||
@@ -407,6 +394,7 @@ function defaultCreateForm() {
|
||||
|
||||
function openCreate() {
|
||||
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
|
||||
invalidateCreateLoads()
|
||||
createForm.value = defaultCreateForm()
|
||||
createSourceOptions.value = []
|
||||
createSourcesError.value = null
|
||||
@@ -440,6 +428,7 @@ async function loadEligibleCreateSources(preferredIds: string[] = createForm.val
|
||||
businessDateFrom: createForm.value.periodStart,
|
||||
businessDateTo: createForm.value.periodEnd,
|
||||
})
|
||||
if (sequence !== createSourceLoadSequence) return
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
@@ -467,7 +456,9 @@ function queryText(value: unknown) {
|
||||
}
|
||||
|
||||
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) {
|
||||
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
|
||||
return
|
||||
@@ -484,8 +475,12 @@ async function openSelectedCreateFromRoute() {
|
||||
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
|
||||
return
|
||||
}
|
||||
createError.value = null
|
||||
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
|
||||
await loadEligibleCreateSources(feeSourceIds)
|
||||
// 带入候选只属于当前激活页面及本次路由,旧响应不能重新打开弹框或覆盖用户导航。
|
||||
if (!pageActive || generation !== selectedCreateGeneration || route.fullPath !== sourcePath
|
||||
|| !canGenerate.value || !canSelectSources.value) return
|
||||
createOpen.value = true
|
||||
await router.replace({ name: 'finance-customer-statements' })
|
||||
}
|
||||
@@ -535,18 +530,6 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
|
||||
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[] {
|
||||
return [
|
||||
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value },
|
||||
@@ -554,14 +537,13 @@ function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
|
||||
{ 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: '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: '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 },
|
||||
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
|
||||
]
|
||||
}
|
||||
function handleStatementAction(command: string, row: FinanceStatementReadModel) {
|
||||
if (!statementActions(row).some(item => item.key === command && item.visible !== false)) return
|
||||
if (command === 'detail') void openDetail(row)
|
||||
else if (command === 'adjust') void openAdjustment(row)
|
||||
else {
|
||||
const actions: Record<string, ReconciliationActionPayload['action']> = {
|
||||
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
|
||||
@@ -571,35 +553,39 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAdjustment() {
|
||||
if (adjustBusy.value) return
|
||||
const statement = adjustStatement.value
|
||||
const selected = adjustLines.value.filter((line) => line.selected)
|
||||
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
|
||||
adjustError.value = null
|
||||
try {
|
||||
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 }
|
||||
function invalidateCreateLoads() {
|
||||
selectedCreateGeneration += 1
|
||||
createSourceLoadSequence += 1
|
||||
createSourcesLoading.value = false
|
||||
}
|
||||
|
||||
function deactivateCreateLoads() {
|
||||
pageActive = false
|
||||
invalidateCreateLoads()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (canListCustomers.value) await loadCustomers()
|
||||
await (canListCustomers.value ? loadCustomers() : undefined)
|
||||
initialLoadCompleted = true
|
||||
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(
|
||||
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
|
||||
([open, mode]) => {
|
||||
if (!open || mode !== 'SELECTED') return
|
||||
([open, mode], [wasOpen]) => {
|
||||
if (!open) {
|
||||
if (wasOpen === true) invalidateCreateLoads()
|
||||
return
|
||||
}
|
||||
if (mode !== 'SELECTED') return
|
||||
void loadEligibleCreateSources()
|
||||
},
|
||||
)
|
||||
@@ -744,16 +730,7 @@ watch(detailRouteId, (statementId) => {
|
||||
</section>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -761,10 +738,8 @@ watch(detailRouteId, (statementId) => {
|
||||
.finance-statements-page { min-width: 0; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.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-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-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); }
|
||||
|
||||
@@ -90,6 +90,7 @@ const receiptValidationError = ref<string | 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 actionBusy = ref(false)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const exportSubmitting = ref(false)
|
||||
|
||||
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
|
||||
@@ -459,12 +460,19 @@ async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
|
||||
}).then((result) => result.value.trim()).catch(() => null)
|
||||
if (reason === null) return
|
||||
actionBusy.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version })
|
||||
ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开')
|
||||
if (detailMode.value) await loadDetail(row.id)
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -524,6 +532,7 @@ onMounted(() => {
|
||||
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
|
||||
<OperationErrorToast :message="receiptValidationError" />
|
||||
<OperationErrorToast :details="receiptError" />
|
||||
<OperationErrorToast :details="actionError" />
|
||||
<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}详情`">
|
||||
<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 +683,7 @@ onMounted(() => {
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
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 router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ 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] } })
|
||||
await flushPromises()
|
||||
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 }>
|
||||
openDetail(row: FinanceStatementReadModel): Promise<void>
|
||||
handleStatementAction(command: string, row: FinanceStatementReadModel): void
|
||||
}
|
||||
|
||||
await vm.openAdjustment(statement)
|
||||
vm.adjustReason = '账期复核调整'
|
||||
vm.adjustLines[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正'
|
||||
vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正'
|
||||
await vm.submitAdjustment()
|
||||
|
||||
vm.handleStatementAction('adjust', statement)
|
||||
vm.handleStatementAction('void', statement)
|
||||
await flushPromises()
|
||||
expect(actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
await vm.openDetail(statement)
|
||||
await flushPromises()
|
||||
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
|
||||
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', {
|
||||
action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3,
|
||||
adjustmentLines: [
|
||||
{ statementLineId: '601', adjustedAmount: 90, reason: '运费更正' },
|
||||
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
|
||||
],
|
||||
})
|
||||
expect(wrapper.text()).toContain(statement.billNo)
|
||||
}, 15_000)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('submits every selected adjustment line with the detail version', async () => {
|
||||
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
|
||||
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 }> }
|
||||
await vm.openAdjustment(statement)
|
||||
vm.adjustReason = '月末核对更正'
|
||||
vm.adjustLines.forEach((line, index) => { line.selected = true; line.adjustedAmount = index === 0 ? 90 : 180; line.reason = `明细${index + 1}更正` })
|
||||
await vm.submitAdjustment()
|
||||
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { action: 'ADJUST', reason: '月末核对更正', expectedVersion: 3, adjustmentLines: [{ statementLineId: '601', adjustedAmount: 90, reason: '明细1更正' }, { statementLineId: '602', adjustedAmount: 180, reason: '明细2更正' }] })
|
||||
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', 'platform:finance:reconciliation:void'])
|
||||
const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
|
||||
expect(labels).toContain('详情')
|
||||
expect(labels).not.toContain('调整')
|
||||
expect(labels).not.toContain('作废')
|
||||
expect(actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 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 { 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 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', () => {
|
||||
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(':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 { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -21,6 +21,7 @@ import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../typ
|
||||
const now = new Date()
|
||||
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const month = ref(defaultMonth)
|
||||
const customerId = ref('')
|
||||
@@ -33,6 +34,7 @@ const error = ref<ApiErrorDetails | null>(null)
|
||||
const result = ref<ReceivableStatisticsResult | null>(null)
|
||||
|
||||
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 rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? [])
|
||||
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 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 displayCustomerName(value: unknown) {
|
||||
const row = value as ReceivableStatisticsRow
|
||||
@@ -116,12 +118,30 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
</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 #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 #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
|
||||
</DataTable></div>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -133,10 +153,21 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
.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; }
|
||||
.statistics-table { overflow: hidden; }
|
||||
.receivable-statistics-print { display: none; }
|
||||
@media print {
|
||||
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
|
||||
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
|
||||
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
|
||||
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
|
||||
:global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }
|
||||
:global(body:has(> .receivable-statistics-print)) { min-width: 0; height: auto; margin: 0; overflow: visible; background: #fff; }
|
||||
.receivable-statistics-print { display: block; width: 100%; color: #000; font: 10pt/1.5 Arial, 'Microsoft YaHei', sans-serif; }
|
||||
.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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRequire } from 'node:module'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
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 OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
||||
@@ -295,4 +295,46 @@ describe('formal settlement native dialogs', () => {
|
||||
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 })
|
||||
})
|
||||
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('加载失败')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,21 @@ describe('finance status presentation', () => {
|
||||
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', () => {
|
||||
expect(financeArapStatusOptions).toEqual([
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
|
||||
@@ -60,6 +60,11 @@ export function financeStatusTone(status: unknown): FinanceStatusTone {
|
||||
|
||||
const financeSourceTypeLabels: Record<string, string> = {
|
||||
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> = {
|
||||
RECONCILIATION_CREATED: '生成对账单',
|
||||
|
||||
@@ -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 } })
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -270,6 +270,20 @@ describe('单据费用操作', () => {
|
||||
expect(dialog.text()).toContain('审核状态')
|
||||
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 () => {
|
||||
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'] })
|
||||
|
||||
@@ -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>
|
||||
@@ -24,7 +24,7 @@ function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-c
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) })
|
||||
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 rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [
|
||||
{ id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' },
|
||||
@@ -39,8 +39,32 @@ describe('simplified procurement operations', () => {
|
||||
expect(uploadProcurementFile).not.toHaveBeenCalled()
|
||||
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) })
|
||||
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('请核对')
|
||||
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 () => {
|
||||
const wrapper = render()
|
||||
|
||||
@@ -101,14 +101,14 @@ const fileName = (id: string) => props.detail.files.find(file => file.fileId ===
|
||||
</script>
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.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 FormSection from '@/shared/components/FormSection.vue'
|
||||
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
|
||||
@@ -16,7 +17,20 @@ const props = withDefaults(defineProps<{
|
||||
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
|
||||
const emit = defineEmits<{ submit: []; cancel: [] }>()
|
||||
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 isTruck = computed(() => props.kind === 'truck')
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate() ?? Promise.resolve(false),
|
||||
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
|
||||
@@ -26,26 +40,40 @@ defineExpose({
|
||||
<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">
|
||||
<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="运输信息">
|
||||
<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-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 :label="kind === 'truck' ? '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 :label="kind === 'truck' ? '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 :label="kind === 'truck' ? '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 :label="kind === 'truck' ? 'C点送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></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="isTruck ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></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="isTruck ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" 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="isTruck ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></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="isTruck ? 'C点送达地址' : '目的详细地址'" prop="destinationAddress"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="货物信息">
|
||||
<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="预计托数" 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="总体积 (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>
|
||||
@@ -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>
|
||||
</div></el-form-item>
|
||||
<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="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 ? '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 ? '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>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
@@ -105,12 +105,12 @@ async function submit() {
|
||||
</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">
|
||||
<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" />
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<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="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'">
|
||||
<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>
|
||||
|
||||
@@ -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')
|
||||
expect(purchaseEdit).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(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'")
|
||||
expect(transportForm).toContain("form.status === 'PENDING_REVIEW'")
|
||||
|
||||
@@ -13,6 +13,17 @@ const fee = (overrides: Partial<OverseasFee> = {}): OverseasFee => ({
|
||||
|
||||
})
|
||||
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('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => {
|
||||
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')
|
||||
|
||||
@@ -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 canReview = can(feePermissions.review)
|
||||
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 => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit))
|
||||
.map(action => ({ key: action, label: actionLabels[action]!, priority: 20,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
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 BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
|
||||
@@ -36,6 +36,22 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.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 { ArrowLeft } from '@lucide/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 BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import TransferPickupProgressPanel from '@/modules/overseas-warehouse/components/TransferPickupProgressPanel.vue'
|
||||
import { poolOrderStatusLabels } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
|
||||
|
||||
@@ -58,6 +59,12 @@ async function load() {
|
||||
finally { if (sequence === requestSequence) loading.value = false }
|
||||
}
|
||||
watch([orderNo, customerId], load, { immediate: true })
|
||||
// 首次由路由加载;缓存标签重新打开时读取PDA和排车已推进的最新状态。
|
||||
let activatedOnce = false
|
||||
onActivated(() => {
|
||||
if (activatedOnce) void load()
|
||||
activatedOnce = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -73,6 +80,7 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
<el-skeleton v-else-if="loading" :rows="8" animated />
|
||||
<template v-else-if="detail">
|
||||
<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"><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>
|
||||
|
||||
@@ -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.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 () => {
|
||||
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status: 'PENDING_REVIEW' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage(PurchaseOrdersView); await flushPromises()
|
||||
@@ -402,12 +423,15 @@ 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('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 auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status')
|
||||
await wrapper.vm.$nextTick()
|
||||
const review = wrapper.findAll('button').find(button => button.text() === '审核')
|
||||
expect(review).toBeDefined(); await review!.trigger('click'); await flushPromises()
|
||||
const actions = wrapper.findAll('.el-table__body-wrapper button')
|
||||
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' } })
|
||||
})
|
||||
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') })
|
||||
|
||||
@@ -60,7 +60,7 @@ async function load() {
|
||||
if (isLooseboard.value) {
|
||||
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: 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 {
|
||||
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
|
||||
@@ -5,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import type { DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions as permissions } from '../permissions'
|
||||
import type { LocalLooseboardOrder } from '../types'
|
||||
import type { LocalLooseboardOrder, LocalTransportOrder } from '../types'
|
||||
import TransportOrdersView from './TransportOrdersView.vue'
|
||||
|
||||
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] : [])]
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
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,
|
||||
} } })
|
||||
}
|
||||
@@ -52,7 +53,7 @@ describe('transport document entry', () => {
|
||||
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 actions = wrapper.findComponent(RowActions)
|
||||
expect(actions.text()).toContain('编辑')
|
||||
@@ -65,4 +66,34 @@ describe('transport document entry', () => {
|
||||
expect(listUsTruckOrders).toHaveBeenCalledOnce()
|
||||
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 { truckStatusLabels } from '../truckWorkflow'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import LooseboardReviewDialog from '../components/LooseboardReviewDialog.vue'
|
||||
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
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'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const reviewOpen = ref(false), reviewOrder = ref<LocalLooseboardOrder | null>(null)
|
||||
const routeOpen = ref(false)
|
||||
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
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 queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
|
||||
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 page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
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: '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: '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: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) },
|
||||
{ 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 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 openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
|
||||
@@ -114,19 +116,20 @@ function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
|
||||
function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] {
|
||||
return [
|
||||
{ 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 },
|
||||
]
|
||||
}
|
||||
function truckActions(row: LocalTransportOrder): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 },
|
||||
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
|
||||
{ key: 'detail', label: '详情', priority: 10 },
|
||||
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
|
||||
]
|
||||
}
|
||||
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 === 'detail') openDetail(row)
|
||||
}
|
||||
@@ -136,6 +139,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LooseboardReviewDialog v-model="reviewOpen" :order="reviewOrder" @saved="load" />
|
||||
<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 () => {
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
const header = wrapper.find('.detail-page-layout__header')
|
||||
|
||||
@@ -42,7 +42,7 @@ const fields = computed(() => detail.value ? [
|
||||
['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone],
|
||||
['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress],
|
||||
['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone],
|
||||
['件数', detail.value.packageCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
|
||||
['托数', detail.value.palletCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
|
||||
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
|
||||
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
|
||||
['创建时间', 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 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">
|
||||
<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>
|
||||
@@ -106,9 +106,9 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2><span class="truck-flow-caption">基础信息与货量</span></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section>
|
||||
<section class="truck-detail-section"><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"><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>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
@@ -121,7 +121,7 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
.truck-detail-section { margin-bottom: var(--admin-space-6); }
|
||||
.truck-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.truck-section-heading h2 { margin: 0; font-size: 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-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; }
|
||||
|
||||
@@ -4,12 +4,13 @@ import type { Amount, ProcurementFee } from './procurementTypes'
|
||||
export const procurementStatuses: Record<string, string> = {
|
||||
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
|
||||
IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成',
|
||||
REJECTED: '已拒绝', CANCELLED: '已取消',
|
||||
}
|
||||
export const feePurposes: Record<string, string> = {
|
||||
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
|
||||
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' {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if ((status === 'REJECTED' || status === 'FUNDING_FAILED') || status === 'EXCEPTION_CLOSED') return 'danger'
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { FormRules } from 'element-plus'
|
||||
|
||||
export type TransportDemandKind = 'truck' | 'looseboard'
|
||||
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'
|
||||
origin: string
|
||||
destination: string
|
||||
@@ -25,6 +33,7 @@ export interface TransportDemandForm {
|
||||
|
||||
export function createTransportDemandForm(): TransportDemandForm {
|
||||
return {
|
||||
deliveryMode: 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: '', deliveryContactName: '', deliveryContactPhone: '', estimatedArrivalAt: '', trackingNo: '', destinationType: 'PRIVATE', destinationPostalCode: '',
|
||||
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
|
||||
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
|
||||
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
|
||||
@@ -32,11 +41,9 @@ export function createTransportDemandForm(): TransportDemandForm {
|
||||
}
|
||||
}
|
||||
|
||||
const requiredText = (kind: TransportDemandKind) => ({
|
||||
origin: kind === 'truck' ? '取货地' : '起运地',
|
||||
const requiredText = (kind: TransportDemandKind, form: TransportDemandForm) => ({
|
||||
...(kind === 'truck' || form.deliveryMode === 'WAREHOUSE_PICKUP' ? { origin: kind === 'truck' ? '取货地' : '起运地', originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人', originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话' } : {}),
|
||||
destination: kind === 'truck' ? '送达地' : '目的地',
|
||||
originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人',
|
||||
originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话',
|
||||
destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
|
||||
destinationContactPhone: kind === 'truck' ? '送达联系电话' : '目的地联系电话',
|
||||
} as const)
|
||||
@@ -47,10 +54,21 @@ export function localMinuteInput(value: unknown) {
|
||||
|
||||
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
|
||||
const rules: FormRules<TransportDemandForm> = {}
|
||||
const labels = requiredText(kind)
|
||||
const labels = requiredText(kind, form)
|
||||
for (const [field, label] of Object.entries(labels)) {
|
||||
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' }]
|
||||
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
|
||||
if (field === 'packageCount' && kind === 'truck') continue
|
||||
@@ -66,8 +84,16 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
|
||||
}
|
||||
|
||||
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 '请选择车辆类型'
|
||||
const labels = requiredText(kind)
|
||||
const labels = requiredText(kind, form)
|
||||
for (const [field, label] of Object.entries(labels)) {
|
||||
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
|
||||
}
|
||||
@@ -102,15 +128,15 @@ function commonPayload(form: TransportDemandForm) {
|
||||
|
||||
type CommonPayload = ReturnType<typeof commonPayload>
|
||||
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: '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)
|
||||
if (failure) throw new Error(failure)
|
||||
const common = commonPayload(form)
|
||||
return kind === 'truck'
|
||||
? { ...(({ 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 }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
|
||||
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 {
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
|
||||
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
|
||||
@@ -72,6 +72,16 @@ export interface LocalLooseboardQuery {
|
||||
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
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
|
||||
id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null
|
||||
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[]
|
||||
}
|
||||
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
|
||||
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
|
||||
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/adminDensity.css'
|
||||
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 ListPageLayout from '@/shared/components/ListPageLayout.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 amount = (value: number | null | undefined) => value == null ? '—' : String(value)
|
||||
const columns: DataTableColumn[] = [
|
||||
{key:'actions',label:'操作',width:120,fixed:'left',slotName:'actions'},
|
||||
{ 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: '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++ })
|
||||
</script>
|
||||
<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="暂无已发布的散板需求">
|
||||
<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 #notices><LoadErrorState v-if="error" title="散板需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #pagination-left>共 {{ total }} 条散板需求</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const overseasMasterDataPermissions = {
|
||||
capacityOfferList: 'platform:fleet:capacity-offer:list',
|
||||
capacityOfferManage: 'platform:fleet:capacity-offer:manage',
|
||||
capacityCargoAdd: 'platform:fleet:capacity-offer:cargo-add',
|
||||
capacityInquiryCreate: 'platform:fleet:capacity-offer:inquiry-create',
|
||||
capacityInquiryEdit: 'platform:fleet:capacity-offer:inquiry-edit',
|
||||
capacityInquiryCancel: 'platform:fleet:capacity-offer:inquiry-cancel',
|
||||
@@ -62,4 +63,3 @@ export const overseasMasterDataPermissions = {
|
||||
vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload',
|
||||
vehicleRecordFinanceSubmit: 'platform:overseas-master-data:vehicle:record-finance-submit',
|
||||
} as const
|
||||
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
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 async function getDispatchAppointmentAllocations(no: string) {
|
||||
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
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() } }))
|
||||
|
||||
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', () => {
|
||||
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 })
|
||||
@@ -33,7 +39,7 @@ describe('exception order API', () => {
|
||||
await getExceptionDetail('100', 'EX/1')
|
||||
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' } })
|
||||
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}` }
|
||||
await commandException('100', 'EX/1', action, body)
|
||||
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 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 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 }
|
||||
const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}`
|
||||
function safeInteger(value: unknown, label: string) {
|
||||
|
||||
@@ -472,6 +472,15 @@ export async function createManualInboundPlan(payload: ManualInboundPlanPayload,
|
||||
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) {
|
||||
const response = await http.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchDetail } from '../dispatchContracts'
|
||||
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
||||
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
|
||||
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
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 editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
|
||||
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 beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
|
||||
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
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
async function load() {
|
||||
@@ -36,6 +41,7 @@ async function load() {
|
||||
if (value.dispatchNo !== props.dispatchNo) 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 && value.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD')) tab.value = 'LOOSEBOARD'
|
||||
detail.value = value
|
||||
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, '排车资料读取失败') }
|
||||
@@ -43,7 +49,7 @@ async function load() {
|
||||
}
|
||||
async function canLeave() {
|
||||
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 }
|
||||
}
|
||||
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') }
|
||||
async function loadingSaved() { emit('saved'); await load() }
|
||||
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()
|
||||
}, { immediate: true })
|
||||
</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()">
|
||||
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
|
||||
<p v-if="beforeDeparture && tab === 'RECEIPT'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</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 => {
|
||||
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 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() }
|
||||
@@ -318,6 +328,20 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
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 () => {
|
||||
const { dialog } = await render('EDIT', 'ROUTE')
|
||||
const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length)
|
||||
|
||||
@@ -150,7 +150,7 @@ function payload(): object {
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
|
||||
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
|
||||
case 'LOADING_COMPLETE': {
|
||||
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
|
||||
@@ -250,7 +250,7 @@ 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 })
|
||||
</script>
|
||||
<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">
|
||||
<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 />
|
||||
@@ -313,14 +313,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>
|
||||
</template>
|
||||
<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="审核照片">
|
||||
<h3 class="density-dialog-section__title">车辆司机照片</h3>
|
||||
<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" />
|
||||
</div>
|
||||
</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="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' || 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>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,13 @@ import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
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 { getOverseasLocation } from '../api/locations'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
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/locations', () => ({ getOverseasLocation: vi.fn() }))
|
||||
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(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 () => {
|
||||
const pending = snapshot('EX-1', 0, 2)
|
||||
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()
|
||||
})
|
||||
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()
|
||||
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(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 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 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
|
||||
const targetSequences = new Map<number, number>()
|
||||
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) }
|
||||
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()
|
||||
processingMethod.value = 'standard'
|
||||
try {
|
||||
const result = await getExceptionDetail(props.customerId, props.exceptionNo)
|
||||
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 = '' }
|
||||
}
|
||||
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 (pending.value && pending.value.action !== action) return
|
||||
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])]
|
||||
if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return }
|
||||
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() }
|
||||
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
|
||||
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
|
||||
if (action === 'force-close') payload.reason = notes.value.trim()
|
||||
if (action === 'complete') {
|
||||
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 })) : []
|
||||
@@ -124,7 +132,7 @@ async function execute(action: ExceptionAction) {
|
||||
busy.value = true; error.value = ''; message.value = ''
|
||||
try {
|
||||
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))
|
||||
throw new Error('处理结果无法核对,请刷新当前记录')
|
||||
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
|
||||
@@ -149,9 +157,16 @@ function selectHistory(fileId: string, event: Event) {
|
||||
/** 生成动作只建单,实物入账由PDA完成。 */
|
||||
async function generateReturn() {
|
||||
if (locked.value || !allowed('GENERATE_RETURN_INBOUND') || !detail.value) return
|
||||
const ticket = sequence
|
||||
const ticket = sequence, scope = identity.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 }
|
||||
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>
|
||||
<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-item label="处理说明 / 取消原因(选填)" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="请输入处理说明或取消原因(选填)" /></el-form-item>
|
||||
<template v-if="allowed('COMPLETE_EXCEPTION') && !detail.returnOrderNo">
|
||||
<el-form-item v-if="allowed('FORCE_CLOSE')" label="处理方式">
|
||||
<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>
|
||||
<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>
|
||||
@@ -227,13 +248,17 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
</template>
|
||||
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
|
||||
</template>
|
||||
<p v-if="otherMethod">填写原因后直接完结此异常单,无需上传资料。关联退回单及货物实收仍按实际业务处理。</p>
|
||||
</el-form>
|
||||
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
|
||||
<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('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>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, D
|
||||
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
|
||||
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
|
||||
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
|
||||
export { listLooseboardCargo, addLooseboardCargo } from './api/dispatches'
|
||||
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
|
||||
export { useDispatchCandidates } from './useDispatchCandidates'
|
||||
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'
|
||||
|
||||
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', () => {
|
||||
const row = toLastMileOrderListItem({
|
||||
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> = {
|
||||
PENDING_INBOUND: '待入库',
|
||||
PENDING_REVIEW: '待审核',
|
||||
PENDING_RECEIPT: '待签收入库',
|
||||
PARTIALLY_RECEIVED: '部分入库',
|
||||
@@ -156,13 +157,13 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
orderNo: lastMileOrderReference(dto.orderNo),
|
||||
sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''),
|
||||
businessType: dto.businessSource?.businessType || '',
|
||||
businessSource: text(dto.businessSource?.label),
|
||||
businessSource: lastMileOrderTypeLabel(dto.businessSource?.label),
|
||||
businessOrderNo: text(dto.businessSource?.businessOrderNo),
|
||||
externalBookingNo: text(dto.businessSource?.externalBookingNo),
|
||||
orderTypeCode: String(dto.orderType ?? '').trim(),
|
||||
currentStatusCode: String(dto.currentStatus ?? '').trim(),
|
||||
warning: dto.abnormal ? '异常' : '—',
|
||||
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
|
||||
orderType: lastMileOrderTypeLabel(dto.orderTypeName || dto.orderType),
|
||||
rejectedAt: '—',
|
||||
inboundCode: text(dto.inboundCode),
|
||||
lxCode: text(dto.lxCode),
|
||||
|
||||
@@ -45,6 +45,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || [])
|
||||
const orderColumns: DataTableColumn[] = [
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||
{ 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: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
|
||||
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
|
||||
|
||||
@@ -124,7 +124,7 @@ const showReturnForecast = computed(() => isReturnOrder.value && (detail.value?.
|
||||
const orderFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
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?.customerName) },
|
||||
{ label: '海外仓', value: warehouseDisplayName.value },
|
||||
@@ -149,7 +149,7 @@ function isNonzeroDifference(field: DetailField) { return field.label === '箱
|
||||
const containerFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
return [
|
||||
{ label: '运输渠道', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
|
||||
{ label: '运输渠道', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
|
||||
{ label: '集装箱号', value: text(order?.containerNo) },
|
||||
{ label: '托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: '箱数', value: Number(order?.packageCount || 0) },
|
||||
@@ -351,7 +351,7 @@ onMounted(load)
|
||||
<section aria-label="商业来源与关联单据">
|
||||
<h2 class="source-heading">关联单据</h2>
|
||||
<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>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div>
|
||||
<div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { listContainerArrivalTimes } from '@/modules/container-loading'
|
||||
import {
|
||||
changeContainerReceivingWarehouse,
|
||||
createManualInboundPlan,
|
||||
createExceptionReturnInboundPlan,
|
||||
convertLastMileContainerToInboundPlans,
|
||||
getContainerInboundPlanPreview,
|
||||
getContainerPlanProgress,
|
||||
@@ -46,7 +47,7 @@ vi.mock('../api/lastMileOrders', () => ({
|
||||
getReturnOrderStatistics: vi.fn(),
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: 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(),
|
||||
reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(),
|
||||
}))
|
||||
@@ -454,6 +455,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
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' }],
|
||||
})
|
||||
vi.mocked(createExceptionReturnInboundPlan).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime])
|
||||
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
|
||||
@@ -666,6 +668,30 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
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 () => {
|
||||
const wrapper = await openAfterDeparture()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
|
||||
@@ -21,6 +21,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
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 { businessOrderLink } from '../businessOrderLink'
|
||||
import { createExceptionReturnInboundPlan } from '../api/lastMileOrders'
|
||||
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { reviewCargoDiscard } from '../api/lastMileOrders'
|
||||
@@ -111,6 +112,8 @@ const inboundPlanSourcePalletCount = ref<number | null>(null)
|
||||
const inboundPlanPreviewLoading = ref(false)
|
||||
const inboundPlanPreviewError = ref('')
|
||||
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 inboundPlanArrivalLoading = ref(false)
|
||||
const inboundPlanArrivalError = ref('')
|
||||
@@ -119,7 +122,7 @@ const inboundPlanArrivalProjection = computed(() => {
|
||||
const arrival = inboundPlanArrival.value
|
||||
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
|
||||
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|
||||
|| inboundPlanSourcePalletCount.value <= 0)))
|
||||
@@ -646,6 +649,7 @@ function openInboundPlan(source?: object) {
|
||||
actionError.value = null
|
||||
inboundPlanArrival.value = null
|
||||
inboundPlanArrivalError.value = ''
|
||||
exceptionReturnArrival.value = target.orderTypeCode === 'DELIVERY_RETURN' ? target.estimatedWarehouseAt || '' : ''
|
||||
inboundPlanOpen.value = true
|
||||
void refreshInboundPlanArrival(target)
|
||||
void refreshManualPalletMinimum(target)
|
||||
@@ -653,6 +657,7 @@ function openInboundPlan(source?: object) {
|
||||
/** 每次打开重新读取柜级权威日期,避免使用列表旧值或在页面推算默认日期。 */
|
||||
async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
|
||||
const request = ++inboundPlanArrivalRequest
|
||||
if (target.orderTypeCode === 'DELIVERY_RETURN') { inboundPlanArrivalLoading.value = false; return }
|
||||
inboundPlanArrivalLoading.value = true
|
||||
try {
|
||||
if (!target.sourceContainerBookingId) {
|
||||
@@ -676,6 +681,7 @@ async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
|
||||
}
|
||||
}
|
||||
async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
|
||||
if (target.orderTypeCode === 'DELIVERY_RETURN') return
|
||||
if (!target.sourceContainerBookingId) return
|
||||
inboundPlanPreviewLoading.value = true
|
||||
inboundPlanPreviewError.value = ''
|
||||
@@ -710,23 +716,26 @@ async function submitInboundPlan() {
|
||||
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
|
||||
return
|
||||
}
|
||||
if (!row || !row.overseasWarehouseId || inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
|
||||
if (!row || !row.overseasWarehouseId || inboundPlanSubmitDisabled.value || (!inboundPlanIsExceptionReturn.value && (!frozenInboundCode || frozenInboundCode === '—')) || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
ElMessage.warning(inboundPlanIsExceptionReturn.value ? '请选择预计到仓时间,并确认退回仓和计划托数' : '当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
const result = await createManualInboundPlan({
|
||||
const payload = {
|
||||
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
|
||||
palletCount: inboundPlanForm.palletCount,
|
||||
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
|
||||
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
|
||||
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
|
||||
&& result.pallets.every((pallet) => Boolean(pallet.warehousePalletId && pallet.palletNo))
|
||||
inboundPlanOpen.value = false; selectedRows.value = []
|
||||
ElMessage.success(palletCodesReady
|
||||
ElMessage.success(inboundPlanIsExceptionReturn.value ? '入库计划已生成,请在异常退回验收中完成收货' : palletCodesReady
|
||||
? '入库计划和托盘码已自动生成'
|
||||
: '入库计划已保存,整柜计划齐全后将自动生成托盘码')
|
||||
await load()
|
||||
@@ -1332,15 +1341,17 @@ onMounted(() => { void load() })
|
||||
<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-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>
|
||||
<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><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
<tr v-if="inboundPlanIsExceptionReturn"><th scope="row">尾程单号</th><td>{{ actionTarget?.orderNo }}</td><th scope="row">来源尾程单号</th><td>{{ actionTarget?.sourceOrderNo || '—' }}</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>
|
||||
</section>
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
@@ -81,6 +81,23 @@ describe('OutboundDetailView', () => {
|
||||
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 () => {
|
||||
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: [] })
|
||||
|
||||
@@ -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 loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
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[] = [
|
||||
{ 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: 'destinationWarehouseCode', label: '目的仓编码', width: 150 },
|
||||
{ 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 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) }
|
||||
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 } }
|
||||
|
||||
Reference in New Issue
Block a user