优化
This commit is contained in:
@@ -16,7 +16,12 @@ vi.mock('@/shared/api/http', () => ({
|
||||
}))
|
||||
|
||||
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
|
||||
const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'EDITING', items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1' }] }
|
||||
const plan = {
|
||||
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
|
||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
|
||||
pdaTaskId: 9007199254740996, pdaTaskStatus: 'LOADING', pdaTaskVersion: '4',
|
||||
items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1', pickedPackageCount: '2', minPackageCount: '2', canAdjust: true, canRemove: false }],
|
||||
}
|
||||
|
||||
describe('platform container loading API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
@@ -71,12 +76,13 @@ describe('platform container loading API', () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
|
||||
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
|
||||
await createSmartLoadingPlan('7', null)
|
||||
const normalized = await createSmartLoadingPlan('7', null)
|
||||
await assignSmartLoadingItem('10', { inboundId: '2', inboundDetailId: '3', packageCount: 4, expectedVersion: 5 })
|
||||
await adjustSmartLoadingItem('10', '11', 6, 7, 8)
|
||||
await removeSmartLoadingItem('10', '11', 9, 10)
|
||||
await pushSmartLoadingPreload('10', 11)
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null })
|
||||
expect(normalized).toMatchObject({ id: String(plan.id), version: 3, canAssign: true, canEdit: true, pdaTaskId: String(plan.pdaTaskId), pdaTaskVersion: 4, items: [{ version: 1, pickedPackageCount: 2, minPackageCount: 2, canAdjust: true, canRemove: false }] })
|
||||
expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-') })
|
||||
expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') })
|
||||
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
|
||||
|
||||
@@ -89,6 +89,9 @@ function item(raw: SmartLoadingItem): SmartLoadingItem {
|
||||
inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId),
|
||||
destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version),
|
||||
packageCount: numberValue(raw.packageCount),
|
||||
pickedPackageCount: numberValue(raw.pickedPackageCount),
|
||||
minPackageCount: numberValue(raw.minPackageCount, 1),
|
||||
canAdjust: Boolean(raw.canAdjust), canRemove: Boolean(raw.canRemove),
|
||||
productQuantity: raw.productQuantity == null ? null : numberValue(raw.productQuantity),
|
||||
grossWeightKg: raw.grossWeightKg == null ? null : numberValue(raw.grossWeightKg),
|
||||
volumeCbm: raw.volumeCbm == null ? null : numberValue(raw.volumeCbm),
|
||||
@@ -96,7 +99,15 @@ function item(raw: SmartLoadingItem): SmartLoadingItem {
|
||||
}
|
||||
|
||||
function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
|
||||
return { ...raw, id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version), items: (raw.items || []).map(item) }
|
||||
return {
|
||||
...raw,
|
||||
id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version),
|
||||
canAssign: Boolean(raw.canAssign), canEdit: Boolean(raw.canEdit),
|
||||
blockedReason: raw.blockedReason || null,
|
||||
pdaTaskId: optionalId(raw.pdaTaskId),
|
||||
pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion),
|
||||
items: (raw.items || []).map(item),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listContainers(query: ContainerQuery): Promise<ContainerPage> {
|
||||
|
||||
@@ -50,7 +50,9 @@ const container = {
|
||||
}
|
||||
const plan = {
|
||||
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
|
||||
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1 }],
|
||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
|
||||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
|
||||
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
|
||||
}
|
||||
|
||||
describe('GlobalSmartLoadingView', () => {
|
||||
@@ -191,7 +193,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).not.toContain('是否退税')
|
||||
})
|
||||
|
||||
it('prefers an editable container on first load so pushed plans do not show a false editing error', async () => {
|
||||
it('prefers an editing container on first load while keeping loading containers available', async () => {
|
||||
const pushed = { ...container, id: '9007199254740995', planStatus: 'LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [pushed, container], page: 1, size: 100, total: 2, pages: 1 })
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
@@ -224,16 +226,19 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(waitingOption.text()).not.toContain('拣货中')
|
||||
})
|
||||
|
||||
it('excludes picking containers from the empty-container workbench', async () => {
|
||||
it('keeps picking containers available when the plan capability allows increments', async () => {
|
||||
const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, bookingId: pushed.id, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 })
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
|
||||
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.container-option')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('CB-2')
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(pushed.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.id, pushed.warehouseId)
|
||||
expect(wrapper.findAll('.container-option')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('CB-2')
|
||||
expect(wrapper.text()).toContain('已下发 PDA,仍可维护未完成的装柜明细')
|
||||
expect(wrapper.text()).toContain('刷新 PDA 明细与版本')
|
||||
})
|
||||
|
||||
it('hides completed containers from the smart loading workbench', async () => {
|
||||
@@ -267,7 +272,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||
expect(warningMessage).toHaveBeenCalledWith('方案已被其他人修改,已刷新最新版本,请重新确认后提交')
|
||||
expect(warningMessage).toHaveBeenCalledWith('计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作')
|
||||
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||||
expect(wrapper.text()).not.toContain('request-1')
|
||||
})
|
||||
@@ -307,7 +312,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const removeAllButton = wrapper.findAll('button').find((button) => button.text().includes('全部移除'))
|
||||
const removeAllButton = wrapper.findAll('button').find((button) => button.text().includes('移除未拣'))
|
||||
expect(removeAllButton).toBeDefined()
|
||||
await removeAllButton!.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -317,6 +322,43 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).toContain('当前柜次暂无已装货物')
|
||||
})
|
||||
|
||||
it('uses item capabilities to protect picked quantities and removal', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
status: 'LOADING',
|
||||
pdaTaskId: '700',
|
||||
pdaTaskStatus: 'LOADING',
|
||||
pdaTaskVersion: 4,
|
||||
items: [{ ...plan.items[0], pickedPackageCount: 3, minPackageCount: 3, canAdjust: true, canRemove: false }],
|
||||
})
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('已拣 3 箱')
|
||||
expect(wrapper.findAll('.row-actions .el-button--danger')).toHaveLength(0)
|
||||
const quantityInput = wrapper.findAllComponents(ElInputNumber).find((input) => input.props('modelValue') === 6)
|
||||
expect(quantityInput?.props('min')).toBe(3)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('移除未拣'))).toBe(false)
|
||||
})
|
||||
|
||||
it('renders final completion capability as read-only with the server reason', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
canAssign: false,
|
||||
canEdit: false,
|
||||
blockedReason: '正在校验装柜证据',
|
||||
finalCompletionStatus: 'IN_PROGRESS',
|
||||
})
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('正在最终确认,装柜方案暂时只读')
|
||||
expect(wrapper.text()).toContain('正在校验装柜证据')
|
||||
expect(wrapper.text()).not.toContain('请先完成拖车')
|
||||
expect(wrapper.findAll('.row-actions button')).toHaveLength(0)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('加入'))).toBe(false)
|
||||
})
|
||||
|
||||
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 4, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
@@ -360,7 +402,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(source).not.toContain('channel.containerCount')
|
||||
expect(source).toContain('class="compact-summary" aria-label="当前渠道装柜统计"')
|
||||
expect(source).toContain('<dt>候选货物</dt>')
|
||||
expect(source).toContain('<dt>空柜</dt>')
|
||||
expect(source).toContain('<dt>柜次</dt>')
|
||||
expect(source).toContain('<dt>已装</dt>')
|
||||
expect(source).toContain('<dt>总重量</dt>')
|
||||
expect(source).toContain('<dt>总体积</dt>')
|
||||
|
||||
@@ -63,7 +63,7 @@ const selectedContainer = computed(() => containers.value.find((item) => item.id
|
||||
const channelTabs = computed(() => {
|
||||
const channels = new Map<string, { code: string; name: string }>()
|
||||
for (const item of containers.value) {
|
||||
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus) || !item.channelCode) continue
|
||||
if (!['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus) || !item.channelCode) continue
|
||||
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
|
||||
code: item.channelCode,
|
||||
name: item.channelName || item.channelCode,
|
||||
@@ -72,7 +72,8 @@ const channelTabs = computed(() => {
|
||||
return [...channels.values()]
|
||||
})
|
||||
const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null)
|
||||
const editable = computed(() => plan.value?.status === 'EDITING')
|
||||
const editable = computed(() => plan.value?.canEdit === true)
|
||||
const assignable = computed(() => plan.value?.canAssign === true)
|
||||
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
|
||||
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
|
||||
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||||
@@ -90,7 +91,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
|
||||
const filteredContainers = computed(() => containers.value.filter((item) => (
|
||||
['WAIT_LOADING', 'EDITING'].includes(item.planStatus)
|
||||
['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)
|
||||
&& Boolean(activeChannelCode.value)
|
||||
&& item.channelCode === activeChannelCode.value
|
||||
)))
|
||||
@@ -106,6 +107,7 @@ const filteredCandidates = computed(() => {
|
||||
function preferredContainerId() {
|
||||
const visible = filteredContainers.value
|
||||
return visible.find((item) => item.planStatus === 'EDITING')?.id
|
||||
|| visible.find((item) => item.planStatus === 'LOADING')?.id
|
||||
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ function itemRow(source: object) { return source as SmartLoadingItem }
|
||||
function adjustmentMax(source: object) {
|
||||
const item = itemRow(source)
|
||||
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
|
||||
return Math.max(1, item.packageCount + remaining)
|
||||
return Math.max(item.minPackageCount, item.packageCount + remaining)
|
||||
}
|
||||
function displayCabinetNo(container: ContainerBookingResource | null | undefined) {
|
||||
if (!container) return '待生成'
|
||||
@@ -300,7 +302,10 @@ async function adjust(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value) return
|
||||
const count = Number(quantityDrafts[item.id])
|
||||
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
|
||||
if (!Number.isInteger(count) || count < item.minPackageCount) {
|
||||
ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`)
|
||||
return
|
||||
}
|
||||
const max = adjustmentMax(item)
|
||||
if (count > max) { ElMessage.warning(`该货物最多可分配 ${max} 箱`); return }
|
||||
await mutate(async () => {
|
||||
@@ -311,7 +316,7 @@ async function adjust(source: object) {
|
||||
}
|
||||
async function remove(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value) return
|
||||
if (!plan.value || !item.canRemove) return
|
||||
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', {
|
||||
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
@@ -323,22 +328,23 @@ async function remove(source: object) {
|
||||
}, '移除装柜明细失败', { pickingConflict: true })
|
||||
}
|
||||
async function removeAll() {
|
||||
if (!plan.value?.items.length) return
|
||||
const itemCount = plan.value.items.length
|
||||
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的全部 ${itemCount} 条装柜明细吗?`, '全部移除', {
|
||||
type: 'warning', confirmButtonText: '全部移除', cancelButtonText: '取消',
|
||||
const removableItems = plan.value?.items.filter((item) => item.canRemove) || []
|
||||
if (!removableItems.length) return
|
||||
const itemCount = removableItems.length
|
||||
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 条未拣明细吗?已拣明细会保留。`, '移除未拣明细', {
|
||||
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
let current = plan.value!
|
||||
for (const original of [...current.items]) {
|
||||
for (const original of removableItems) {
|
||||
const item = current.items.find((value) => value.id === original.id)
|
||||
if (!item) continue
|
||||
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
|
||||
acceptPlan(current)
|
||||
}
|
||||
await loadSelectedData()
|
||||
ElMessage.success(`已移除全部 ${itemCount} 条装柜明细`)
|
||||
ElMessage.success(`已移除 ${itemCount} 条未拣明细`)
|
||||
}, '全部移除失败', { pickingConflict: true })
|
||||
}
|
||||
async function pushPreload() {
|
||||
@@ -368,7 +374,7 @@ async function mutate(
|
||||
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
|
||||
? '该订单在拣货中不许删除'
|
||||
: null
|
||||
const message = pickingMessage || '方案已被其他人修改,已刷新最新版本,请重新确认后提交'
|
||||
const message = pickingMessage || '计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作'
|
||||
if (recovery.preserveAssign) assignOpen.value = true
|
||||
if (pickingMessage) ElMessage.error(pickingMessage)
|
||||
else ElMessage.warning(message)
|
||||
@@ -392,7 +398,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</el-tabs>
|
||||
<dl class="compact-summary" aria-label="当前渠道装柜统计">
|
||||
<div><dt>候选货物</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
|
||||
<div><dt>空柜</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
|
||||
<div><dt>柜次</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
|
||||
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
|
||||
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
|
||||
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
|
||||
@@ -426,7 +432,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<el-button
|
||||
v-if="canAssign && editable"
|
||||
v-if="canAssign && assignable"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
@@ -441,7 +447,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</section>
|
||||
|
||||
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
|
||||
<header class="pane-header"><div><strong>当前渠道空柜</strong><small>仅显示待装柜和预装编辑中的柜次</small></div></header>
|
||||
<header class="pane-header"><div><strong>当前渠道柜次</strong><small>显示待装柜、预装编辑和尚未最终完成的 PDA 柜次</small></div></header>
|
||||
<div v-loading="loading" class="container-list">
|
||||
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
|
||||
<button
|
||||
@@ -472,8 +478,8 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong><small>{{ selectedContainer ? `柜次号:${displayCabinetNo(selectedContainer)} · 集装箱号:${displayActualContainerNo(selectedContainer)}` : '尚未选择柜次' }}</small></div>
|
||||
<div class="plan-actions">
|
||||
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
<el-button v-if="canDelete && editable && plan?.items.length" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />全部移除</el-button>
|
||||
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
<el-button v-if="canDelete && editable && plan?.items.some((item) => item.canRemove)" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />移除未拣</el-button>
|
||||
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -492,6 +498,24 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
|
||||
</el-empty>
|
||||
<template v-else-if="plan">
|
||||
<el-alert
|
||||
v-if="plan.status === 'LOADING' && plan.canEdit"
|
||||
class="preload-readiness-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="已下发 PDA,仍可维护未完成的装柜明细"
|
||||
description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="!plan.canEdit"
|
||||
class="preload-readiness-alert"
|
||||
:type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'"
|
||||
:description="plan.blockedReason || '请刷新计划获取最新作业状态。'"
|
||||
/>
|
||||
<el-alert
|
||||
class="preload-readiness-alert"
|
||||
type="info"
|
||||
@@ -505,10 +529,10 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
||||
</div>
|
||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无已装货物">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||
</DataTable>
|
||||
|
||||
@@ -25,13 +25,18 @@ const container = {
|
||||
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
||||
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||||
}
|
||||
const planResponse = {
|
||||
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
|
||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
|
||||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, items: [],
|
||||
}
|
||||
|
||||
describe('SmartLoadingWorkbenchView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getContainer).mockResolvedValue(container)
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(planResponse)
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
|
||||
})
|
||||
|
||||
@@ -50,26 +55,26 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).toContain('110% 容量上限')
|
||||
})
|
||||
|
||||
it('keeps picking containers out of intelligent loading and links to the preload list', async () => {
|
||||
it('uses loading plan capabilities to keep PDA increments editable', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'LOADING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...planResponse, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
|
||||
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('该柜次已进入拣货流程')
|
||||
expect(wrapper.text()).toContain('查看预装清单')
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
|
||||
expect(wrapper.text()).toContain('已下发 PDA,仍可维护未完成的装柜明细')
|
||||
expect(wrapper.text()).toContain('刷新 PDA 明细与版本')
|
||||
expect(wrapper.text()).not.toContain('智能推荐')
|
||||
expect(wrapper.text()).not.toContain('ORD-1')
|
||||
expect(wrapper.text()).toContain('IN-1')
|
||||
})
|
||||
|
||||
it('refreshes the latest plan after A0409', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking)
|
||||
.mockResolvedValueOnce({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
||||
.mockResolvedValueOnce({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 3, items: [] })
|
||||
.mockResolvedValueOnce(planResponse)
|
||||
.mockResolvedValueOnce({ ...planResponse, version: 3 })
|
||||
vi.mocked(recommendSmartLoading).mockRejectedValue(new AxiosError(
|
||||
'conflict', 'ERR_BAD_REQUEST', undefined, undefined,
|
||||
{ data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
|
||||
|
||||
@@ -39,7 +39,8 @@ const canAssign = computed(() => access.can('platform:firstleg:smart-loading:ass
|
||||
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
|
||||
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editable = computed(() => plan.value?.status === 'EDITING')
|
||||
const editable = computed(() => plan.value?.canEdit === true)
|
||||
const assignable = computed(() => plan.value?.canAssign === true)
|
||||
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
|
||||
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
||||
@@ -91,14 +92,17 @@ function displayContainerNo(value: ContainerBookingResource | null | undefined)
|
||||
const containerNo = String(value?.containerNo || '').trim()
|
||||
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待录入'
|
||||
}
|
||||
function syncDrafts() { for (const item of plan.value?.items || []) quantityDrafts[item.id] = item.packageCount }
|
||||
function acceptPlan(next: SmartLoadingPlan) { plan.value = next; noDraft.value = false; syncDrafts() }
|
||||
function syncDrafts(preserved: Record<string, number> = {}) {
|
||||
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
||||
for (const item of plan.value?.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
||||
}
|
||||
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
|
||||
|
||||
async function load() {
|
||||
loading.value = true; loadError.value = null; noDraft.value = false
|
||||
try {
|
||||
container.value = await getContainer(containerId.value)
|
||||
if (['LOADING', 'COMPLETED'].includes(container.value.planStatus)) {
|
||||
if (container.value.planStatus === 'COMPLETED') {
|
||||
plan.value = null
|
||||
candidates.value = []
|
||||
return
|
||||
@@ -109,7 +113,9 @@ async function load() {
|
||||
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
|
||||
else throw error
|
||||
}
|
||||
candidates.value = await listSmartLoadingCandidates(container.value.id, container.value.warehouseId)
|
||||
candidates.value = plan.value?.canAssign
|
||||
? await listSmartLoadingCandidates(container.value.id, container.value.warehouseId)
|
||||
: []
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -150,7 +156,7 @@ async function adjust(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value) return
|
||||
const count = Number(quantityDrafts[item.id])
|
||||
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
|
||||
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try { acceptPlan(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
|
||||
@@ -159,7 +165,7 @@ async function adjust(source: object) {
|
||||
}
|
||||
async function remove(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value) return
|
||||
if (!plan.value || !item.canRemove) return
|
||||
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
if (!confirmed || !plan.value) return
|
||||
mutating.value = true
|
||||
@@ -184,14 +190,12 @@ async function handleMutationError(error: unknown, fallback: string, pickingConf
|
||||
if (details.code === 'A0409') {
|
||||
const preserved = { ...quantityDrafts }
|
||||
await load()
|
||||
for (const item of plan.value?.items || []) {
|
||||
if (preserved[item.id] != null) quantityDrafts[item.id] = preserved[item.id]
|
||||
}
|
||||
syncDrafts(preserved)
|
||||
}
|
||||
const pickingMessage = pickingConflict && details.code === 'A0409' && details.message.includes('拣货')
|
||||
? '该订单在拣货中不许删除'
|
||||
: null
|
||||
mutationError.value = { ...details, message: pickingMessage || (details.code === 'A0409' ? '计划已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' : details.message) }
|
||||
mutationError.value = { ...details, message: pickingMessage || (details.code === 'A0409' ? '计划或 PDA 明细已变化,已刷新最新版本并保留本次输入,请重新操作' : details.message) }
|
||||
ElMessage.error(mutationError.value.message)
|
||||
}
|
||||
|
||||
@@ -215,7 +219,7 @@ onMounted(load)
|
||||
<div class="header-actions">
|
||||
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
|
||||
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -228,27 +232,24 @@ onMounted(load)
|
||||
<p>智能装柜方案已锁定,不能继续调整。请进入装柜清单查看完整记录。</p>
|
||||
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看装柜清单</el-button>
|
||||
</section>
|
||||
<section v-else-if="container?.planStatus === 'LOADING'" class="readonly-status-card" aria-label="已进入拣货流程">
|
||||
<strong>该柜次已进入拣货流程</strong>
|
||||
<p>智能装柜仅处理待装柜和预装编辑中的空柜,请在预装清单继续查看。</p>
|
||||
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看预装清单</el-button>
|
||||
</section>
|
||||
<template v-else>
|
||||
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section>
|
||||
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
|
||||
<el-empty v-if="noDraft" description="该货柜尚未创建排柜计划"><el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建排柜计划</el-button></el-empty>
|
||||
<template v-else-if="plan">
|
||||
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
|
||||
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
|
||||
<template #actions="{ row }"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template><template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
|
||||
@@ -243,6 +243,10 @@ export interface SmartLoadingItem {
|
||||
volumeCbm: number | null
|
||||
loadSequence: number | null
|
||||
version: number
|
||||
pickedPackageCount: number
|
||||
minPackageCount: number
|
||||
canAdjust: boolean
|
||||
canRemove: boolean
|
||||
}
|
||||
|
||||
export interface SmartLoadingPlan {
|
||||
@@ -253,5 +257,12 @@ export interface SmartLoadingPlan {
|
||||
containerNo: string | null
|
||||
voyageNo: string | null
|
||||
version: number
|
||||
canAssign: boolean
|
||||
canEdit: boolean
|
||||
blockedReason: string | null
|
||||
finalCompletionStatus: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED'
|
||||
pdaTaskId: string | null
|
||||
pdaTaskStatus: 'LOADING' | 'COMPLETED' | null
|
||||
pdaTaskVersion: number | null
|
||||
items: SmartLoadingItem[]
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ describe('finance api', () => {
|
||||
|
||||
it('uses the customer statement read-model path', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({
|
||||
code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2 }],
|
||||
code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2, createdBy: 7, createdByName: '创建人A', updatedBy: 8, updatedByName: '更新人B' }],
|
||||
})
|
||||
|
||||
const result = await listFinanceStatements('10001')
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/statements', { params: { customerId: '10001' } })
|
||||
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2 })
|
||||
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2, createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' })
|
||||
})
|
||||
|
||||
it('never serializes an empty customerId for optional finance list filters', async () => {
|
||||
@@ -116,7 +116,7 @@ describe('finance api', () => {
|
||||
})
|
||||
|
||||
it('uses the frozen G3 reconciliation create and action contracts', async () => {
|
||||
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', containerId: 31, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [], revisions: [] }
|
||||
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', containerId: 31, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: 701, transactionType: 'RECEIPT', transactionDate: '2026-08-02', amount: 10, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: 9, createdByName: '收款登记人', createdAt: '2026-08-02T11:00:00' }], revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 120, afterAmount: 100, reason: '优惠', operatorId: 10, operatorName: '调整人', occurredAt: '2026-08-02T12:00:00' }] }
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
@@ -127,12 +127,13 @@ describe('finance api', () => {
|
||||
expect(post.mock.calls[0]).toEqual(['/platform/finance/v1/statements', expect.objectContaining({ customerId: '10001', currency: 'CNY', generationMode: 'BY_CUTOFF' }), { headers: { 'Idempotency-Key': expect.any(String) } }])
|
||||
expect(post.mock.calls[1]).toEqual(['/platform/finance/v1/reconciliation-statements/501/actions', { action: 'SUBMIT', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.any(String) } }])
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/reconciliation-statements/501')
|
||||
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, createdBy: '7', createdAt: '2026-08-01T09:00:00', updatedBy: '8', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: '601', sourceFeeId: '42', sourceHash: 'hash-42', containerId: '31', sourceAmount: 120, amount: 100, adjustmentReason: '优惠调整' }] })
|
||||
expect(loaded.revisions).toEqual([])
|
||||
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: '601', sourceFeeId: '42', sourceHash: 'hash-42', containerId: '31', sourceAmount: 120, amount: 100, adjustmentReason: '优惠调整' }] })
|
||||
expect(loaded.paymentHistory[0]).toMatchObject({ createdBy: '9', createdByName: '收款登记人' })
|
||||
expect(loaded.revisions[0]).toMatchObject({ operatorId: '10', operatorName: '调整人' })
|
||||
})
|
||||
|
||||
it('uses the frozen G4 settlement create, detail and payment contracts', async () => {
|
||||
const detail = { summary: { settlementId: 701, settlementNo: 'AP-701', customerId: 10001, supplierId: 81, reconciliationStatementId: 501, direction: 'PAYABLE', totalAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 0 }, lines: [], transactions: [], revisions: [] }
|
||||
const detail = { summary: { settlementId: 701, settlementNo: 'AP-701', customerId: 10001, supplierId: 81, reconciliationStatementId: 501, direction: 'PAYABLE', totalAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 0 }, lines: [], transactions: [{ transactionId: 801, transactionType: 'PAYMENT', transactionDate: '2026-08-11', amount: 20, method: 'BANK_TRANSFER', referenceNo: 'PAY-801', voucherFileIds: [9001], createdBy: 11, createdByName: '付款登记人', createdAt: '2026-08-11T10:00:00' }], revisions: [{ revisionNo: 1, action: 'VOID', beforeAmount: 200, afterAmount: 200, reason: '测试', operatorId: 12, operatorName: '作废操作人', occurredAt: '2026-08-11T11:00:00' }] }
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
@@ -146,6 +147,8 @@ describe('finance api', () => {
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/settlements/701')
|
||||
expect(post.mock.calls[2]).toEqual(['/platform/finance/v1/settlements/701/transactions', expect.objectContaining({ amount: 200, voucherFileIds: ['9001'], expectedVersion: 0 }), { headers: { 'Idempotency-Key': expect.any(String) } }])
|
||||
expect(loaded).toMatchObject({ id: '701', settlementNo: 'AP-701', customerId: '10001', supplierId: '81', reconciliationStatementId: '501', direction: 'PAYABLE', amount: 200, status: 'ISSUED' })
|
||||
expect(loaded.transactions[0]).toMatchObject({ referenceNo: 'PAY-801', createdBy: '11', createdByName: '付款登记人', createdAt: '2026-08-11T10:00:00' })
|
||||
expect(loaded.revisions[0]).toMatchObject({ operatorId: '12', operatorName: '作废操作人' })
|
||||
})
|
||||
|
||||
it('keeps receipt and cost commands on CNY with real voucher ids', async () => {
|
||||
|
||||
@@ -160,9 +160,9 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
|
||||
return {
|
||||
id: stringValue(item.statementId), billNo: stringValue(item.statementNo), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
|
||||
billDate: stringValue(item.periodEnd), dueDate: '', periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: numberValue(item.creditDays), currency: 'CNY',
|
||||
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedAt: stringValue(item.updatedAt), lines,
|
||||
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), createdBy: stringValue(row.createdBy), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), occurredAt: stringValue(row.occurredAt) } }),
|
||||
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt), lines,
|
||||
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), operatorName: stringValue(row.operatorName), occurredAt: stringValue(row.occurredAt) } }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ function normalizeSettlementDetail(item: RawResource): FinanceSettlementDetail {
|
||||
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), customerName: stringValue(summary.customerName), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), supplierName: stringValue(summary.supplierName), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), businessPeriod: stringValue(summary.businessPeriod), generatedDate: stringValue(summary.generatedDate), dueDate: stringValue(summary.dueDate), paymentStatus: stringValue(summary.paymentStatus) as FinanceSettlementDetail['paymentStatus'], direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
|
||||
amount: numberValue(summary.totalAmount ?? summary.amount), paidAmount: numberValue(summary.paidAmount), outstandingAmount: numberValue(summary.outstandingAmount), currency: 'CNY', status: stringValue(summary.status), version: numberValue(summary.version),
|
||||
lines: (Array.isArray(item.lines) ? item.lines : []).map((line) => normalizeFeeSourceDetail(line as RawResource)),
|
||||
transactions: (Array.isArray(item.transactions) ? item.transactions : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, occurredAt: stringValue(row.occurredAt) } }),
|
||||
transactions: (Array.isArray(item.transactions) ? item.transactions : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), createdAt: stringValue(row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), operatorName: stringValue(row.operatorName), occurredAt: stringValue(row.occurredAt) } }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function actionFinanceSettlement(settlementId: string, payload: imp
|
||||
|
||||
export async function listFinanceStatements(customerId?: string): Promise<FinanceStatementReadModel[]> {
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/statements`, { params: { customerId: customerId?.trim() || undefined } })
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), createdBy: stringValue(item.createdBy), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedAt: stringValue(item.updatedAt) }))
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt) }))
|
||||
}
|
||||
|
||||
export async function listFinanceReceivableSettlements(customerId?: string): Promise<FinanceReceivableSettlement[]> {
|
||||
|
||||
@@ -44,17 +44,23 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(page).not.toContain('class="detail-toolbar"')
|
||||
expect(page).not.toContain('<AppDrawer')
|
||||
for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker)
|
||||
for (const code of ['FEE_INCLUDED', 'FEE_EXCLUDED', 'RECONCILIATION_CREATED', 'SETTLEMENT_ISSUED', 'RECEIPT', 'PAYMENT', 'ADJUSTMENT', 'VOID']) expect(page).toContain(code)
|
||||
for (const obsolete of ["value: 'FEE_SOURCE'", "value: 'RECONCILIATION'", "value: 'SETTLEMENT'"]) expect(page).not.toContain(obsolete)
|
||||
})
|
||||
|
||||
it('keeps term editing aligned with the account summary and nearby actions', () => {
|
||||
for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
|
||||
expect(page).toContain('grid-template-columns:repeat(4,minmax(0,1fr))')
|
||||
expect(page).toContain('grid-template-columns:120px minmax(0,220px)')
|
||||
expect(page).toContain('justify-content:start')
|
||||
expect(page).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
|
||||
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
|
||||
expect(page).toContain('justify-content: start')
|
||||
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
|
||||
expect(page).toContain('min-height:72px')
|
||||
expect(page).toContain('gap:0;padding:0 0 16px')
|
||||
expect(page).toContain('min-height: 64px')
|
||||
expect(page).toContain('padding: 0 0 12px')
|
||||
expect(page).toContain('class="detail-table-scroll"')
|
||||
expect(page).toContain('class="ledger-identifier"')
|
||||
expect(page).toContain('class="ledger-document"')
|
||||
expect(page).toContain('text-overflow: ellipsis')
|
||||
expect(page).toContain('white-space: nowrap')
|
||||
})
|
||||
|
||||
it('shows only derived CNY summaries and no wallet actions', () => {
|
||||
@@ -108,6 +114,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
})
|
||||
|
||||
it('formats known ledger codes, operator ids and timestamps for display', async () => {
|
||||
vi.mocked(listFinanceAccountLedger).mockResolvedValue({ records: [{ ledgerId: '2087867025978863617', occurredAt: '2026-08-13T10:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL', documentId: '2', documentNo: 'REC-00000000000000000001', receivableChange: 10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '结算单生效' }], total: 1, pages: 1 })
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['platform:finance:customer-account:list']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
@@ -118,8 +125,9 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
await router.push('/finance/customer-accounts/10001'); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('收款')
|
||||
expect(wrapper.text()).toContain('操作人信息缺失')
|
||||
expect(wrapper.text()).toContain('结算单生效')
|
||||
expect(wrapper.text()).toContain('REC-00000000000000000001')
|
||||
expect(wrapper.text()).toContain('名称数据缺失')
|
||||
expect(wrapper.text()).not.toContain('operator-1000001')
|
||||
expect(wrapper.text()).not.toContain('1000001')
|
||||
expect(wrapper.text()).not.toContain('2026-08-13T10:00:00')
|
||||
|
||||
@@ -66,13 +66,16 @@ const columns: DataTableColumn[] = [
|
||||
]
|
||||
const ledgerQueryFields: QueryField[] = [
|
||||
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
|
||||
{ label: '费用来源', value: 'FEE_SOURCE' }, { label: '对账单', value: 'RECONCILIATION' }, { label: '结算单', value: 'SETTLEMENT' }, { label: '收付款', value: 'PAYMENT' },
|
||||
{ label: '纳入结算', value: 'FEE_INCLUDED' }, { label: '排除结算', value: 'FEE_EXCLUDED' },
|
||||
{ label: '生成对账单', value: 'RECONCILIATION_CREATED' }, { label: '结算单生效', value: 'SETTLEMENT_ISSUED' },
|
||||
{ label: '收款', value: 'RECEIPT' }, { label: '付款', value: 'PAYMENT' },
|
||||
{ label: '账务调整', value: 'ADJUSTMENT' }, { label: '作废', value: 'VOID' },
|
||||
] },
|
||||
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
|
||||
]
|
||||
const ledgerColumns: DataTableColumn[] = [
|
||||
{ key: 'ledgerId', label: '流水号', minWidth: 160 }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 130, slotName: 'businessType' },
|
||||
{ key: 'documentNo', label: '关联单据', minWidth: 170 }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
|
||||
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
|
||||
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
|
||||
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const summary = computed(() => rows.value.reduce((result, row) => ({
|
||||
@@ -153,20 +156,23 @@ function customerName(customerId: string, snapshot?: string) {
|
||||
}
|
||||
function ledgerBusinessType(value: unknown) {
|
||||
const code = String(value || '').trim().toUpperCase()
|
||||
const known: Record<string, string> = { REC: '收款', RECEIPT: '收款', PAYMENT: '付款', COST: '成本', BILL: '对账单', RECONCILIATION: '对账单', SETTLEMENT: '结算', FEE_SOURCE: '费用来源', ADJUSTMENT: '账务调整' }
|
||||
const known: Record<string, string> = { REC: '收款', RECONCILIATION: '对账单', FEE_SOURCE: '费用来源' }
|
||||
return known[code] || financeBusinessTypeLabel(code)
|
||||
}
|
||||
function displayOperator(value: unknown) {
|
||||
const operator = String(value || '').trim()
|
||||
if (!operator) return '系统'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '操作人信息缺失' : operator
|
||||
if (!operator) return '名称数据缺失'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
|
||||
}
|
||||
function formatDateTime(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return '时间暂不可用'
|
||||
if (!raw) return '时间数据缺失'
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
return Number.isNaN(date.getTime()) ? '时间数据缺失' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function formatLastChangedAt(value: unknown) { return String(value || '').trim() ? formatDateTime(value) : '暂无变动' }
|
||||
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
|
||||
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
|
||||
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||||
function downloadCsv(fileName: string, headers: string[], data: unknown[][]) {
|
||||
const content = `\uFEFF${[headers, ...data].map((line) => line.map(csvCell).join(',')).join('\r\n')}`
|
||||
@@ -215,7 +221,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<template #toolbar-actions><el-button :disabled="loading || !rows.length" @click="exportAccounts"><Download :size="16" />导出</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
|
||||
<template #lastChangedAt="{ row }">{{ formatDateTime(row.lastChangedAt) }}</template>
|
||||
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
|
||||
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
|
||||
</DataTable>
|
||||
@@ -230,7 +236,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<template v-else-if="selected">
|
||||
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
|
||||
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
|
||||
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||
</FormSection>
|
||||
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></div></div></FormSection>
|
||||
</template>
|
||||
@@ -242,5 +248,264 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-account-page{min-width:0}.finance-account-detail{width:100%;min-width:0}.finance-account-detail__surface{width:min(100%,1600px);margin:0 auto;overflow:hidden}.finance-account-detail__layout{width:100%;margin:0 auto}.finance-account-detail__layout :deep(.detail-page-layout){gap:0}.finance-account-detail__layout :deep(.detail-page-layout__header){min-height:72px;align-items:center;padding:16px 24px;border-bottom:1px solid var(--cc-border,#e3e8ef)}.finance-account-detail__layout :deep(.detail-page-layout__summary){padding:16px 24px 0}.finance-account-detail__layout :deep(.detail-page-layout__content){padding:0 24px 20px}.finance-account-detail__layout :deep(.detail-page-layout__footer){padding:0 24px 24px}.row-actions,.detail-footer,.detail-heading,.term-control{display:flex;align-items:center;gap:8px}.row-actions .el-button{margin-left:0}.detail-heading{width:100%;align-items:center}.detail-title{min-width:0}.detail-heading h2{margin:0;color:var(--cc-text);font-size:20px;line-height:1.4}.detail-heading p{margin:4px 0 0;color:var(--cc-muted);font-size:13px}.detail-back{margin-left:auto}.account-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0;padding:0 0 16px;border-bottom:1px solid var(--cc-border,#e3e8ef)}.account-metric{min-width:0;padding:0 20px}.account-metric:first-child{padding-left:0}.account-metric+.account-metric{border-left:1px solid var(--cc-border,#e3e8ef)}.account-metric::before{display:none}.account-metric span{display:block;color:var(--cc-muted);font-size:12px}.account-metric strong{display:inline-block;margin-top:7px;color:var(--cc-text);font-size:22px;line-height:1;font-variant-numeric:tabular-nums}.account-metric small{margin-left:6px;color:var(--cc-muted);font-size:11px}.ledger-section{padding:16px 0 0}.ledger-query{margin-bottom:12px}:deep(.ledger-query.query-toolbar){padding:0;border:0;background:transparent}.detail-table-scroll{overflow-x:auto}.ledger-table{min-width:0}:deep(.ledger-table .data-table__toolbar.is-compact){min-height:42px;padding:0 0 10px;border-bottom:0}:deep(.ledger-table .data-table__pagination){min-height:48px;padding:8px 12px}.term-section{width:100%;padding:16px 0}.term-editor{display:grid;grid-template-columns:120px minmax(0,220px);justify-content:start;align-items:center;gap:24px;padding:4px 0}.term-label{color:var(--cc-text);font-size:14px;font-weight:600}.term-control :deep(.el-input-number){width:220px}.term-unit{color:var(--cc-muted)}.detail-footer{width:100%;justify-content:flex-end;margin-top:-4px;padding-top:14px;border-top:0}@media(max-width:1080px){.account-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.account-metric:nth-child(3){padding-left:0;border-left:0}.finance-account-detail__surface{width:100%}}@media(max-width:720px){.detail-heading{align-items:stretch;flex-direction:column}.detail-back{margin-left:0}.detail-footer .el-button{min-height:44px}.term-editor{grid-template-columns:1fr}.account-metrics{grid-template-columns:1fr;gap:12px}.account-metric,.account-metric:first-child,.account-metric:nth-child(3){padding-left:0}.account-metric+.account-metric{border-left:0}}@media(max-width:480px){.detail-footer .el-button{flex:1 1 120px}}
|
||||
.finance-account-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__surface {
|
||||
width: min(100%, 1920px);
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout__header) {
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout__summary) {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout__content) {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout__footer) {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.row-actions,
|
||||
.detail-footer,
|
||||
.detail-heading,
|
||||
.term-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-actions .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--cc-text);
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail-heading p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--cc-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.account-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.account-metric {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.account-metric:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.account-metric + .account-metric {
|
||||
border-left: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.account-metric::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-metric span {
|
||||
display: block;
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-metric strong {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
color: var(--cc-text);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.account-metric small {
|
||||
margin-left: 6px;
|
||||
color: var(--cc-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ledger-section {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
.ledger-query {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ledger-query.query-toolbar) {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ledger-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ledger-identifier,
|
||||
.ledger-document {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:deep(.ledger-table .data-table__toolbar.is-compact) {
|
||||
min-height: 36px;
|
||||
padding: 0 0 6px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ledger-table .data-table__pagination) {
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.term-section {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.term-editor {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 220px);
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.term-label {
|
||||
color: var(--cc-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.term-control :deep(.el-input-number) {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.term-unit {
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
padding-top: 8px;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.account-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.account-metric:nth-child(3) {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__surface {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.detail-footer .el-button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.term-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-metric,
|
||||
.account-metric:first-child,
|
||||
.account-metric:nth-child(3) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.account-metric + .account-metric {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.detail-footer .el-button {
|
||||
flex: 1 1 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string }
|
||||
submitGeneration(): Promise<void>
|
||||
}
|
||||
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }]
|
||||
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }]
|
||||
Object.assign(vm.generationForm, { mode: 'SELECTED', customerId: '10001', period: ['2026-08-01', '2026-08-31'], remark: '八月费用' })
|
||||
await vm.submitGeneration()
|
||||
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
|
||||
|
||||
@@ -133,8 +133,8 @@ function formatDateTime(value: unknown) {
|
||||
}
|
||||
function displayOperator(value: unknown) {
|
||||
const operator = String(value || '').trim()
|
||||
if (!operator) return '系统'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '操作人信息缺失' : operator
|
||||
if (!operator) return '名称数据缺失'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
|
||||
}
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const reviewStatus = String(filters.value.reviewStatus || '')
|
||||
@@ -210,10 +210,16 @@ function statusLabel(status: string) { return financeStatusLabel(status) }
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
|
||||
function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') {
|
||||
generationForm.mode = mode; generationForm.customerId = String(filters.value.customerId || ''); generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []; generationForm.cutoffDate = generationForm.period[1] || ''; generationForm.remark = ''; generationError.value = null; generationOpen.value = true
|
||||
generationForm.mode = mode
|
||||
generationForm.customerId = ''
|
||||
generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||||
generationForm.cutoffDate = generationForm.period[1] || ''
|
||||
generationForm.remark = ''
|
||||
generationError.value = null
|
||||
generationOpen.value = true
|
||||
}
|
||||
async function submitGeneration() {
|
||||
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.settlementIncluded === true && row.statementGenerated === false)
|
||||
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.settlementIncluded === true && row.statementGenerated === false && row.customerId === generationForm.customerId)
|
||||
const period = generationForm.period
|
||||
if (!generationForm.customerId || !period[0] || !period[1]) { ElMessage.warning('请选择客户和业务期间'); return }
|
||||
if (generationForm.mode === 'SELECTED' && !eligible.length) { ElMessage.warning('请选择审核通过、参与结算且未出账的应收费用'); return }
|
||||
@@ -251,7 +257,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange">
|
||||
<template #toolbar-actions><div v-if="canCreateStatement" class="generation-actions"><el-button :disabled="!filters.customerId" @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></div></template>
|
||||
<template #toolbar-actions><div v-if="canCreateStatement" class="generation-actions"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
||||
<template #documentNo="{ row }">{{ feeDocumentLabel(row.direction, row.feeName) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #sourceId="{ row }">{{ row.sourceId }}</template>
|
||||
@@ -287,41 +293,246 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-arap-page { min-width: 0; }
|
||||
.finance-arap-detail { width: 100%; min-width: 0; }
|
||||
.finance-arap-detail__surface { width: min(100%, 1600px); margin: 0 auto; overflow: hidden; }
|
||||
.finance-arap-detail__layout { width: 100%; margin: 0 auto; }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout) { gap: 0; }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__header) { min-height: 72px; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__summary) { padding: 16px 24px 0; }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__content) { padding: 0 24px 20px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.row-actions .el-button { margin-left: 0; }
|
||||
.generation-actions,.detail-heading,.detail-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.detail-heading { min-width: 0; align-items: flex-start; }
|
||||
.detail-title { min-width: 0; }
|
||||
.detail-heading h2 { margin: 0; font-size: 18px; }
|
||||
.detail-heading p { overflow: hidden; margin: 4px 0 0; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-actions { margin-left: auto; }
|
||||
.muted-action { color: var(--cc-muted); }
|
||||
.arap-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; padding: 0 0 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.arap-metric { min-width: 0; padding: 0 20px; }
|
||||
.arap-metric:first-child { padding-left: 0; }
|
||||
.arap-metric + .arap-metric { border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.arap-metric span { display: block; color: var(--cc-muted); font-size: 12px; }
|
||||
.arap-metric strong { display: inline-block; margin-top: 7px; color: var(--cc-text); font-size: 20px; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.arap-metric small { margin-left: 6px; color: var(--cc-muted); font-size: 11px; }
|
||||
.arap-metric.is-amount strong { color: #4f46e5; }
|
||||
.arap-detail-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, .65fr); gap: 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.arap-information,.arap-settlement,.operation-log { padding: 16px 0; }
|
||||
.arap-settlement { padding-left: 24px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.operation-log { min-height: 150px; }
|
||||
:deep(.arap-detail .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
|
||||
:deep(.arap-detail .el-descriptions__label) { width: 112px; color: var(--cc-muted); }
|
||||
.operation-log :deep(.el-empty) { padding: 12px 0 4px; }
|
||||
.operation-log :deep(.el-empty__image) { display: none; }
|
||||
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
@media (max-width: 1080px) { .finance-arap-detail__surface { width: 100%; } .arap-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .arap-metric:nth-child(3) { padding-left: 0; border-left: 0; } .arap-detail-grid { grid-template-columns: 1fr; gap: 0; } .arap-settlement { padding-left: 0; border-left: 0; border-top: 1px solid var(--cc-border, #e3e8ef); } }
|
||||
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-actions { margin-left: 0; } .arap-metrics { grid-template-columns: 1fr; gap: 12px; } .arap-metric,.arap-metric:first-child,.arap-metric:nth-child(3) { padding-left: 0; } .arap-metric + .arap-metric { border-left: 0; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
|
||||
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
|
||||
.finance-arap-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-arap-detail {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-arap-detail__surface {
|
||||
width: min(100%, 1920px);
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__header) {
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__summary) {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__content) {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.generation-actions,
|
||||
.detail-heading,
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.detail-heading p {
|
||||
overflow: hidden;
|
||||
margin: 3px 0 0;
|
||||
color: var(--cc-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.muted-action {
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
.arap-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.arap-metric {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.arap-metric:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.arap-metric + .arap-metric {
|
||||
border-left: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.arap-metric span {
|
||||
display: block;
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.arap-metric strong {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
color: var(--cc-text);
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.arap-metric small {
|
||||
margin-left: 6px;
|
||||
color: var(--cc-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.arap-metric.is-amount strong {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.arap-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.arap-information,
|
||||
.arap-settlement,
|
||||
.operation-log {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.arap-settlement {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.operation-log {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
:deep(.arap-detail .el-descriptions__content) {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:deep(.arap-detail .el-descriptions__label) {
|
||||
width: 112px;
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
.operation-log :deep(.el-empty) {
|
||||
padding: 10px 0 2px;
|
||||
}
|
||||
|
||||
.operation-log :deep(.el-empty__image) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.operation-log small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.finance-arap-detail__surface {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.arap-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.arap-metric:nth-child(3) {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.arap-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.arap-settlement {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.arap-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.arap-metric,
|
||||
.arap-metric:first-child,
|
||||
.arap-metric:nth-child(3) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.arap-metric + .arap-metric {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
:deep(.arap-detail .el-descriptions__body .el-descriptions__table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:deep(.arap-detail .el-descriptions__cell) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } 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'
|
||||
@@ -24,23 +25,28 @@ function memoryStorage(): Storage {
|
||||
const statement: FinanceStatementReadModel = {
|
||||
id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '',
|
||||
totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
|
||||
createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
}
|
||||
const detail: FinanceReconciliationDetail = {
|
||||
...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单',
|
||||
createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', orderId: '11', inboundId: '21', containerId: '31', feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }],
|
||||
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
||||
revisions: [],
|
||||
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
||||
revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }],
|
||||
}
|
||||
|
||||
async function mountPage(permissions = ['platform:finance:customer-statement:list']) {
|
||||
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
] })
|
||||
await router.push(initialPath); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
@@ -65,10 +71,12 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the main table and linked detail/payment tables on the same page', async () => {
|
||||
const wrapper = await mountPage()
|
||||
it('opens statement details on a dedicated route instead of below the list', async () => {
|
||||
const { wrapper, router } = await mountPage()
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
|
||||
expect(router.currentRoute.value.params.statementId).toBe('501')
|
||||
expect(document.body.textContent).toContain('账单明细表')
|
||||
expect(document.body.textContent).toContain('账单支付历史表')
|
||||
expect(document.body.textContent).toContain('REC-501')
|
||||
@@ -78,9 +86,12 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(document.body.textContent).not.toContain('hash-81')
|
||||
expect(document.body.textContent).toContain('优惠调整')
|
||||
expect(document.body.textContent).toContain('创建人A')
|
||||
expect(document.body.textContent).toContain('调整人')
|
||||
expect(document.body.textContent).toContain('2026-08-20 11:00')
|
||||
expect(document.querySelector('[aria-label="客户对账单详情"]')).toBeTruthy()
|
||||
expect(document.querySelector('[aria-label="所选对账单摘要"]')?.textContent).toContain('200.00 CNY')
|
||||
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
|
||||
expect(document.querySelector('.statement-detail-head')?.textContent).toContain('REC-501')
|
||||
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
@@ -88,15 +99,14 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
|
||||
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
|
||||
await vm.openDetail(statement)
|
||||
vm.activeDetailTab = 'payments'
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
|
||||
expect(document.querySelectorAll('.statement-detail-panel .detail-page-layout')).toHaveLength(1)
|
||||
expect(document.querySelectorAll('.statement-detail-panel .surface-panel')).toHaveLength(0)
|
||||
expect(document.querySelectorAll('.statement-detail-page')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolves customer names from the active customer list and never exposes internal customer or operator ids', async () => {
|
||||
@@ -105,26 +115,26 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: internalCustomerId, tenantName: '虫虫跨境科技', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
const internalStatement = {
|
||||
...statement, customerId: internalCustomerId, customerName: `客户${internalCustomerId}`,
|
||||
createdBy: internalOperatorId, createdAt: '2026-08-14T03:27:34Z', updatedBy: internalOperatorId, updatedAt: '2026-08-14T04:28:35Z',
|
||||
createdBy: internalOperatorId, createdByName: '', createdAt: '2026-08-14T03:27:34Z', updatedBy: internalOperatorId, updatedByName: '', updatedAt: '2026-08-14T04:28:35Z',
|
||||
}
|
||||
const internalDetail = {
|
||||
...detail, ...internalStatement,
|
||||
paymentHistory: [{ ...detail.paymentHistory[0]!, createdBy: internalOperatorId, occurredAt: '2026-08-14T04:28:35Z' }],
|
||||
paymentHistory: [{ ...detail.paymentHistory[0]!, createdBy: internalOperatorId, createdByName: '', occurredAt: '2026-08-14T04:28:35Z' }],
|
||||
revisions: [{ ...detail.revisions[0]!, operatorId: internalOperatorId, operatorName: '' }],
|
||||
}
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([internalStatement])
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue(internalDetail)
|
||||
|
||||
const wrapper = await mountPage(['platform:finance:customer-statement:list', 'platform:customer:list', 'platform:finance:reconciliation:adjust'])
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:customer:list', 'platform:finance:reconciliation:adjust'])
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(internalStatement)
|
||||
await flushPromises()
|
||||
|
||||
const text = document.body.textContent || ''
|
||||
expect(text).toContain('虫虫跨境科技')
|
||||
expect(document.querySelector('[aria-label="客户对账单列表"]')?.textContent).toContain('虫虫跨境科技')
|
||||
expect(document.querySelector('[aria-label="所选对账单联动明细"]')?.textContent).toContain('虫虫跨境科技')
|
||||
expect(document.querySelector('[aria-label="客户对账单详情"]')?.textContent).toContain('虫虫跨境科技')
|
||||
expect(text).not.toContain(`客户${internalCustomerId}`)
|
||||
expect(text).not.toContain(internalOperatorId)
|
||||
expect(text).toContain('操作人信息缺失')
|
||||
expect(text).toContain('名称数据缺失')
|
||||
expect(text).toContain('2026-08-14 03:27')
|
||||
expect(text).not.toContain('2026-08-14T03:27:34Z')
|
||||
|
||||
@@ -135,7 +145,7 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
|
||||
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void>; filters: Record<string, unknown>; filteredRows: FinanceStatementReadModel[] }
|
||||
await vm.load({
|
||||
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-31',
|
||||
@@ -151,7 +161,7 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
|
||||
it('hydrates and exports the currently filtered real statement rows as CSV', async () => {
|
||||
const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const wrapper = await mountPage()
|
||||
const { wrapper } = await mountPage()
|
||||
await (wrapper.vm as unknown as { exportStatements(): Promise<void> }).exportStatements()
|
||||
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
@@ -168,7 +178,7 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
|
||||
it('uses statement list permission for fee-source details without exposing technical trace fields', async () => {
|
||||
const wrapper = await mountPage(['platform:finance:customer-statement:list'])
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
await flushPromises()
|
||||
expect(getFinanceFeeSource).toHaveBeenCalledWith('81')
|
||||
@@ -177,19 +187,20 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
|
||||
it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const { wrapper } = await mountPage()
|
||||
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
|
||||
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({ status: 'ISSUED' })
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('筛选字段加载失败')
|
||||
})
|
||||
|
||||
it('keeps the failed selected row visible and exposes a detail retry state', async () => {
|
||||
const wrapper = await mountPage()
|
||||
it('keeps the dedicated detail route visible and exposes a retry state', async () => {
|
||||
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
const { router } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('已选择 1 项')
|
||||
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
|
||||
expect(document.body.textContent).toContain('客户对账单详情')
|
||||
expect(document.body.textContent).toContain('返回客户对账单')
|
||||
expect(document.body.textContent).toContain('明细加载失败')
|
||||
expect(document.body.textContent).toContain('重试')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
||||
import { ArrowLeft, Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -46,6 +47,8 @@ interface StatementLineSource {
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const filters = ref<QueryModel>({
|
||||
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
|
||||
@@ -66,7 +69,12 @@ const createOpen = ref(false)
|
||||
const createBusy = ref(false)
|
||||
const exportBusy = ref(false)
|
||||
const actionBusy = ref(false)
|
||||
const createForm = ref({ periodStart: new Date().toISOString().slice(0, 8) + '01', periodEnd: new Date().toISOString().slice(0, 10), remark: '' })
|
||||
const createForm = ref({
|
||||
customerId: '',
|
||||
periodStart: new Date().toISOString().slice(0, 8) + '01',
|
||||
periodEnd: new Date().toISOString().slice(0, 10),
|
||||
remark: '',
|
||||
})
|
||||
const adjustOpen = ref(false)
|
||||
const adjustBusy = ref(false)
|
||||
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
||||
@@ -77,11 +85,14 @@ const canView = computed(() => access.can('platform:finance:customer-statement:l
|
||||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||||
const canViewFeeSource = computed(() => access.can('platform:finance:customer-statement:list'))
|
||||
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
|
||||
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 canGenerate = computed(() => canCreate.value && canListCustomers.value)
|
||||
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
|
||||
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '全部审核状态', options: [
|
||||
{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '审核通过', value: 'APPROVED' },
|
||||
@@ -111,9 +122,9 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
|
||||
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key: 'createdBy', label: '创建人', width: 130, slotName: 'createdBy' },
|
||||
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||||
{ key: 'updatedBy', label: '更新人', width: 130, slotName: 'updatedBy' },
|
||||
{ key: 'updatedByName', label: '更新人', width: 130, slotName: 'updatedByName' },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
|
||||
]
|
||||
const detailLineColumns: DataTableColumn[] = [
|
||||
@@ -136,7 +147,7 @@ const paymentColumns: DataTableColumn[] = [
|
||||
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
|
||||
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
|
||||
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
|
||||
{ key: 'createdBy', label: '登记人', minWidth: 140, slotName: 'createdBy' },
|
||||
{ key: 'createdByName', label: '登记人', minWidth: 140, slotName: 'createdByName' },
|
||||
{ key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' },
|
||||
{ key: 'remark', label: '备注', minWidth: 200 },
|
||||
]
|
||||
@@ -157,8 +168,8 @@ function customerDisplayName(customerId: string, value?: string) {
|
||||
|
||||
function operatorDisplayName(value?: string) {
|
||||
const candidate = String(value || '').trim()
|
||||
if (!candidate) return '系统'
|
||||
return /^\d+$/.test(candidate) ? '操作人信息缺失' : candidate
|
||||
if (!candidate || /^(?:operator-)?\d+$/i.test(candidate)) return '名称数据缺失'
|
||||
return candidate
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
@@ -172,8 +183,8 @@ function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconcilia
|
||||
periodStart: current?.periodStart || '', periodEnd: current?.periodEnd || row.billDate,
|
||||
creditDays: current ? current.creditDays : null, settled: current ? current.settled : null,
|
||||
lastPaymentAt: latestPayment, remark: current?.remark || '',
|
||||
createdBy: current?.createdBy || row.createdBy, createdAt: current?.createdAt || row.createdAt,
|
||||
updatedBy: current?.updatedBy || row.updatedBy, updatedAt: current?.updatedAt || row.updatedAt,
|
||||
createdBy: current?.createdBy || row.createdBy, createdByName: current?.createdByName || row.createdByName, createdAt: current?.createdAt || row.createdAt,
|
||||
updatedBy: current?.updatedBy || row.updatedBy, updatedByName: current?.updatedByName || row.updatedByName, updatedAt: current?.updatedAt || row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,15 +284,13 @@ async function fetchDetail(statementId: string, force = false) {
|
||||
return current
|
||||
}
|
||||
|
||||
async function openDetail(row: FinanceStatementReadModel) {
|
||||
selectedId.value = row.id
|
||||
async function loadDetail(statementId: string) {
|
||||
selectedId.value = statementId
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await fetchDetail(row.id, true)
|
||||
const index = rows.value.findIndex((item) => item.id === row.id)
|
||||
if (index >= 0) rows.value[index] = detailToRow(rows.value[index]!, detail.value)
|
||||
detail.value = await fetchDetail(statementId, true)
|
||||
} catch (cause) {
|
||||
detailError.value = getApiErrorDetails(cause, '对账单明细加载失败,请重试')
|
||||
} finally {
|
||||
@@ -289,19 +298,16 @@ async function openDetail(row: FinanceStatementReadModel) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedId.value = ''
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
async function openDetail(row: FinanceStatementReadModel) {
|
||||
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: row.id } })
|
||||
}
|
||||
|
||||
function retrySelectedDetail() {
|
||||
const row = rows.value.find((item) => item.id === selectedId.value)
|
||||
if (row) void openDetail(row)
|
||||
if (selectedId.value) void loadDetail(selectedId.value)
|
||||
}
|
||||
|
||||
function selectedRowClass({ row }: { row: StatementRow }) {
|
||||
return row.id === selectedId.value ? 'is-selected-statement' : ''
|
||||
async function returnToList() {
|
||||
await router.push({ name: 'finance-customer-statements' })
|
||||
}
|
||||
|
||||
function paymentMethodLabel(method: string) {
|
||||
@@ -327,7 +333,7 @@ async function exportStatements() {
|
||||
return
|
||||
}
|
||||
const header = ['审核状态', '对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额', '已收金额', '未收金额', '最后收款时间', '是否结清', '备注', '创建人', '创建时间', '更新人', '更新时间']
|
||||
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, customerDisplayName(row.customerId, row.customerName), row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, formatDateTime(row.lastPaymentAt), row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, operatorDisplayName(row.createdBy), formatDateTime(row.createdAt), operatorDisplayName(row.updatedBy), formatDateTime(row.updatedAt)])
|
||||
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, customerDisplayName(row.customerId, row.customerName), row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, formatDateTime(row.lastPaymentAt), row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, operatorDisplayName(row.createdByName), formatDateTime(row.createdAt), operatorDisplayName(row.updatedByName), formatDateTime(row.updatedAt)])
|
||||
const blob = new Blob([`\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
@@ -341,16 +347,17 @@ async function exportStatements() {
|
||||
|
||||
function openCreate() {
|
||||
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
|
||||
createForm.value = { ...createForm.value, customerId: '' }
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
async function createStatement() {
|
||||
if (!filters.value.customerId) return ElMessage.warning('请先选择客户')
|
||||
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
|
||||
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
|
||||
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
|
||||
createBusy.value = true
|
||||
try {
|
||||
await createReconciliationStatement({ customerId: String(filters.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
|
||||
await createReconciliationStatement({ customerId: String(createForm.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
|
||||
createOpen.value = false
|
||||
ElMessage.success('对账单已生成')
|
||||
await load(filters.value)
|
||||
@@ -403,18 +410,90 @@ async function submitAdjustment() {
|
||||
finally { adjustBusy.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load() })
|
||||
onMounted(() => { if (canListCustomers.value) void loadCustomers() })
|
||||
watch(detailRouteId, (statementId) => {
|
||||
if (statementId) void loadDetail(statementId)
|
||||
else {
|
||||
selectedId.value = ''
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
void load()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-statements-page" spacing="normal">
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" />
|
||||
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page" aria-label="客户对账单详情">
|
||||
<template #header>
|
||||
<header class="statement-detail-page__header">
|
||||
<div><h2>客户对账单详情</h2><p>{{ detail?.billNo || '正在加载对账单信息' }}</p></div>
|
||||
<el-button @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button>
|
||||
</header>
|
||||
</template>
|
||||
<template v-if="detail" #summary>
|
||||
<div class="statement-detail-head">
|
||||
<div><span>客户对账单</span><h2>{{ detail.billNo }}</h2><p>{{ customerDisplayName(detail.customerId, detail.customerName) }} · {{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</p></div>
|
||||
<el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
|
||||
</div>
|
||||
<FormSection class="statement-summary-section" title="对账单概要" aria-label="对账单摘要">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="总应收金额">{{ detail.totalAmount.toFixed(2) }} CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="已收金额">{{ detail.paidAmount.toFixed(2) }} CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="未收金额"><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></el-descriptions-item>
|
||||
<el-descriptions-item label="账期">{{ detail.creditDays }} 天</el-descriptions-item>
|
||||
<el-descriptions-item label="货币">{{ detail.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否结清">{{ detail.settled ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人">{{ operatorDisplayName(detail.createdByName) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新人">{{ operatorDisplayName(detail.updatedByName) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.updatedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detail.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</FormSection>
|
||||
</template>
|
||||
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs">
|
||||
<el-tab-pane label="账单明细表" name="lines">
|
||||
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||||
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
|
||||
<template #inboundCode="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template>
|
||||
<template #containerNo="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template>
|
||||
<template #businessDate="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template>
|
||||
<template #quantity="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template>
|
||||
<template #unitPrice="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template>
|
||||
<template #billNo>{{ detail.billNo }}</template>
|
||||
<template #periodStart>{{ detail.periodStart || '—' }}</template>
|
||||
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
|
||||
</FormSection>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="账单支付历史表" name="payments">
|
||||
<FormSection v-if="detail" title="支付历史" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
|
||||
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
|
||||
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
|
||||
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
|
||||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||||
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</FormSection>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</DetailPageLayout>
|
||||
<ListPageLayout v-else aria-label="客户对账单">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template>
|
||||
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
|
||||
<template v-if="!customerError && !error">
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" :row-class-name="selectedRowClass" @row-click="openDetail">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
|
||||
<template #toolbar-actions>
|
||||
<el-button :loading="exportBusy" :disabled="loading || exportBusy || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
|
||||
@@ -436,81 +515,18 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
|
||||
<template #lastPaymentAt="{ row }">{{ formatDateTime(row.lastPaymentAt) }}</template>
|
||||
<template #settled="{ row }"><el-tag v-if="row.settled !== null" :type="row.settled ? 'success' : 'warning'">{{ row.settled ? '是' : '否' }}</el-tag><span v-else>—</span></template>
|
||||
<template #createdBy="{ row }">{{ operatorDisplayName(row.createdBy) }}</template>
|
||||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
<template #updatedBy="{ row }">{{ operatorDisplayName(row.updatedBy) }}</template>
|
||||
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
|
||||
<DetailPageLayout aria-label="客户对账单详情">
|
||||
<template #header>
|
||||
<header class="statement-detail-header">
|
||||
<div class="statement-detail-context"><span>所选对账单</span><strong>{{ selectedId ? '已选择 1 项' : '尚未选择' }}</strong></div>
|
||||
<el-button v-if="selectedId" link @click="clearSelection">清空</el-button>
|
||||
</header>
|
||||
</template>
|
||||
<template v-if="detail" #summary>
|
||||
<div class="statement-detail-head">
|
||||
<div><span>客户对账单</span><h2>{{ detail.billNo }}</h2><p>{{ customerDisplayName(detail.customerId, detail.customerName) }} · {{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</p></div>
|
||||
<el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
|
||||
</div>
|
||||
<FormSection class="statement-summary-section" title="对账单概要" aria-label="所选对账单摘要">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="总应收金额">{{ detail.totalAmount.toFixed(2) }} CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="已收金额">{{ detail.paidAmount.toFixed(2) }} CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="未收金额"><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></el-descriptions-item>
|
||||
<el-descriptions-item label="账期">{{ detail.creditDays }} 天</el-descriptions-item>
|
||||
<el-descriptions-item label="货币">{{ detail.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否结清">{{ detail.settled ? '是' : '否' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs">
|
||||
<el-tab-pane label="账单明细表" name="lines">
|
||||
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||||
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
|
||||
<template #inboundCode="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template>
|
||||
<template #containerNo="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template>
|
||||
<template #businessDate="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template>
|
||||
<template #quantity="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template>
|
||||
<template #unitPrice="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template>
|
||||
<template #billNo>{{ detail.billNo }}</template>
|
||||
<template #periodStart>{{ detail.periodStart || '—' }}</template>
|
||||
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
|
||||
</FormSection>
|
||||
<el-empty v-else description="请在主表中选择一张对账单" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="账单支付历史表" name="payments">
|
||||
<FormSection v-if="detail" title="支付历史" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
|
||||
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
|
||||
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
|
||||
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
|
||||
<template #createdBy="{ row }">{{ operatorDisplayName(row.createdBy) }}</template>
|
||||
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</FormSection>
|
||||
<el-empty v-else description="请在主表中选择一张对账单" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(520px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><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><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
|
||||
|
||||
<AppDialog v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
|
||||
<el-form label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
@@ -522,30 +538,27 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<style scoped>
|
||||
.finance-statements-page { min-width: 0; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.statement-detail-panel { min-width: 0; overflow: hidden; }
|
||||
.statement-detail-panel :deep(.detail-page-layout) { gap: 0; }
|
||||
.statement-detail-header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 24px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-context { display: grid; min-width: 0; gap: 3px; }
|
||||
.statement-detail-context span { color: var(--cc-text-secondary); font-size: 11px; }
|
||||
.statement-detail-context strong { overflow: hidden; color: var(--cc-text-primary); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.statement-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; padding: 16px 24px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
|
||||
.statement-detail-page :deep(.detail-page-layout) { gap: 0; }
|
||||
.statement-detail-page__header { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-page__header h2 { margin: 0; color: var(--cc-text-primary); font-size: 20px; }
|
||||
.statement-detail-page__header p { margin: 4px 0 0; color: var(--cc-text-secondary); font-size: 13px; }
|
||||
.statement-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-head > div > span { color: var(--cc-primary); font-size: 11px; font-weight: 700; }
|
||||
.statement-detail-head h2 { margin: 2px 0 0; font-size: 16px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.statement-detail-head p { margin: 5px 0 0; color: var(--cc-text-secondary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.statement-summary-section { padding: 16px 24px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-summary-section { padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-outstanding { color: var(--el-color-warning-dark-2); }
|
||||
.statement-detail-tabs { min-width: 0; }
|
||||
.statement-detail-table { min-width: 0; }
|
||||
.detail-table-scroll { min-width: 0; overflow-x: auto; }
|
||||
.statement-detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 24px; }
|
||||
.statement-detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 16px; }
|
||||
.statement-detail-tabs :deep(.el-tabs__content) { min-width: 0; }
|
||||
.statement-detail-tabs :deep(.form-section) { padding: 20px 24px 24px; }
|
||||
.statement-detail-tabs :deep(.form-section) { padding: 12px 16px 12px; }
|
||||
.statement-line-table { min-width: 1840px; }
|
||||
.statement-payment-table { min-width: 1300px; }
|
||||
.revision-collapse { margin-top: 12px; }
|
||||
:deep(.is-selected-statement > td.el-table__cell) { background: var(--el-color-primary-light-9) !important; }
|
||||
@media (max-width: 640px) {
|
||||
.statement-detail-panel { padding-inline: 12px; }
|
||||
.statement-detail-header div { align-items: flex-start; flex-direction: column; gap: 2px; padding-block: 8px; }
|
||||
.statement-detail-page__header { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,6 +29,9 @@ describe('Finance formal settlement desktop presentation', () => {
|
||||
expect(source).toContain("RECEIPT: '收款登记'")
|
||||
expect(source).toContain("PAYMENT: '付款登记'")
|
||||
expect(source).toContain('settlementRevisionActionLabel(scope.row.action)')
|
||||
expect(source).toContain('operatorDisplayName(scope.row.createdByName)')
|
||||
expect(source).toContain('operatorDisplayName(scope.row.operatorName)')
|
||||
expect(source).toContain("'名称数据缺失'")
|
||||
})
|
||||
|
||||
it('keeps receipt and payment dialogs readable at desktop width', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ArrowLeft, CreditCard, Download, Eye, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -57,7 +57,14 @@ const createBusy = ref(false)
|
||||
const statementOptions = ref<FinanceStatementReadModel[]>([])
|
||||
const feeOptions = ref<FinanceFeePoolItem[]>([])
|
||||
const supplierOptions = ref<Supplier[]>([])
|
||||
const createForm = ref({ reconciliationStatementId: '', supplierId: '', feeSourceIds: [] as string[], dueDate: '' })
|
||||
const suppliersLoaded = ref(false)
|
||||
const createForm = ref({
|
||||
customerId: '',
|
||||
reconciliationStatementId: '',
|
||||
supplierId: '',
|
||||
feeSourceIds: [] as string[],
|
||||
dueDate: '',
|
||||
})
|
||||
const paymentOpen = ref(false)
|
||||
const paymentBusy = ref(false)
|
||||
const paymentFile = ref<File | null>(null)
|
||||
@@ -137,6 +144,10 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
|
||||
function displayDate(value?: string) { return value || '—' }
|
||||
function displayDateTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function operatorDisplayName(value?: string) {
|
||||
const candidate = String(value || '').trim()
|
||||
return candidate && !/^(?:operator-)?\d+$/i.test(candidate) ? candidate : '名称数据缺失'
|
||||
}
|
||||
function settlementRevisionActionLabel(action: string) {
|
||||
return ({
|
||||
SUBMIT: '提交审核',
|
||||
@@ -213,27 +224,67 @@ async function loadDetail(settlementId: string) {
|
||||
function backToList() { emit('back') }
|
||||
|
||||
async function openCreate() {
|
||||
const customerId = String(filters.value.customerId || '')
|
||||
if (!customerId) return ElMessage.warning('请先选择客户')
|
||||
createForm.value = { reconciliationStatementId: '', supplierId: '', feeSourceIds: [], dueDate: '' }
|
||||
createForm.value = {
|
||||
customerId: '',
|
||||
reconciliationStatementId: '',
|
||||
supplierId: '',
|
||||
feeSourceIds: [],
|
||||
dueDate: '',
|
||||
}
|
||||
createOpen.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (props.kind === 'receivable') {
|
||||
statementOptions.value = (await listFinanceStatements(customerId)).filter((item) => item.status === 'ISSUED')
|
||||
} else {
|
||||
const [fees, suppliers] = await Promise.all([
|
||||
listFinanceFeePool(customerId, 'PAYABLE'),
|
||||
listSuppliers({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
])
|
||||
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
|
||||
await refreshCreateDependencies()
|
||||
if (props.kind === 'payable' && !suppliersLoaded.value) {
|
||||
const suppliers = await listSuppliers({ page: 1, size: 200, status: 'ACTIVE' })
|
||||
supplierOptions.value = suppliers.records.filter((item) => item.status === 'ACTIVE')
|
||||
suppliersLoaded.value = true
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '结算来源加载失败') }
|
||||
}
|
||||
|
||||
function resetCreateDependentFields() {
|
||||
createForm.value.reconciliationStatementId = ''
|
||||
createForm.value.supplierId = ''
|
||||
createForm.value.feeSourceIds = []
|
||||
createForm.value.dueDate = ''
|
||||
}
|
||||
|
||||
async function refreshCreateDependencies(customerId?: string) {
|
||||
const selectedCustomerId = String((customerId ?? createForm.value.customerId) || '').trim()
|
||||
statementOptions.value = []
|
||||
feeOptions.value = []
|
||||
if (!selectedCustomerId) {
|
||||
resetCreateDependentFields()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.kind === 'receivable') {
|
||||
statementOptions.value = (await listFinanceStatements(selectedCustomerId)).filter((item) => item.status === 'ISSUED')
|
||||
return
|
||||
}
|
||||
|
||||
const fees = await listFinanceFeePool(selectedCustomerId, 'PAYABLE')
|
||||
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
|
||||
if (createForm.value.supplierId && !supplierOptions.value.find((supplier) => String(supplier.id) === String(createForm.value.supplierId))) {
|
||||
createForm.value.supplierId = ''
|
||||
}
|
||||
if (createForm.value.feeSourceIds.length) {
|
||||
const available = new Set(feeOptions.value.map((item) => item.feeSourceId).filter(Boolean) as string[])
|
||||
createForm.value.feeSourceIds = createForm.value.feeSourceIds.filter((id) => available.has(id))
|
||||
if (!createForm.value.feeSourceIds.length && feeOptions.value.length) {
|
||||
createForm.value.feeSourceIds = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => createForm.value.customerId, (customerId) => {
|
||||
if (createOpen.value) void refreshCreateDependencies(customerId)
|
||||
})
|
||||
|
||||
async function submitCreate() {
|
||||
const customerId = String(filters.value.customerId || '')
|
||||
const customerId = String(createForm.value.customerId || '')
|
||||
if (!customerId) return ElMessage.warning('请先选择客户')
|
||||
if (props.kind === 'receivable' && !createForm.value.reconciliationStatementId) return ElMessage.warning('请选择已出账对账单')
|
||||
if (props.kind === 'payable' && (!createForm.value.supplierId || !createForm.value.dueDate || !createForm.value.feeSourceIds.length)) return ElMessage.warning('请完整填写供应商、到期日和费用来源')
|
||||
createBusy.value = true
|
||||
@@ -445,6 +496,9 @@ onMounted(() => {
|
||||
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
|
||||
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||||
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
|
||||
<el-table-column prop="referenceNo" label="支付流水号" min-width="160"><template #default="scope">{{ scope.row.referenceNo || '—' }}</template></el-table-column>
|
||||
<el-table-column label="登记人" min-width="140"><template #default="scope">{{ operatorDisplayName(scope.row.createdByName) }}</template></el-table-column>
|
||||
<el-table-column label="登记时间" min-width="170"><template #default="scope">{{ displayDateTime(scope.row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
@@ -457,6 +511,7 @@ onMounted(() => {
|
||||
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
|
||||
<el-table-column label="操作人" min-width="140"><template #default="scope">{{ operatorDisplayName(scope.row.operatorName) }}</template></el-table-column>
|
||||
<el-table-column prop="occurredAt" label="时间" min-width="160"><template #default="scope">{{ displayDateTime(scope.row.occurredAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
@@ -507,8 +562,9 @@ onMounted(() => {
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<el-form label-position="top">
|
||||
<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>
|
||||
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
|
||||
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.name} · ${categoryLabels[item.category]}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
|
||||
</el-form>
|
||||
@@ -535,43 +591,43 @@ onMounted(() => {
|
||||
.finance-settlement-page { min-width: 0; }
|
||||
.settlement-list-toolbar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 20px; padding: 0 2px 10px; }
|
||||
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
|
||||
.settlement-summary { flex: 1; gap: 8px 0; }
|
||||
.settlement-summary { flex: 1; gap: 6px 0; }
|
||||
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: 8px; }
|
||||
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.summary-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 24px; padding-left: 24px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 18px; padding-left: 18px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.summary-item span { color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||||
.summary-item strong { color: var(--cc-text-primary, #1f2937); font-size: 18px; line-height: 1.2; }
|
||||
.summary-item strong.is-success { color: #16803c; }
|
||||
.summary-item strong.is-danger { color: #c2413a; }
|
||||
.settlement-detail-page { display: flex; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
|
||||
.settlement-detail-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
|
||||
.settlement-detail-header { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 14px; padding: 14px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-heading, .settlement-detail-actions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
|
||||
.settlement-detail-heading h1 { margin: 0; font-size: 20px; line-height: 1.35; letter-spacing: 0; }
|
||||
.settlement-detail-breadcrumb { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 14px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-summary .summary-item { align-items: flex-start; flex-direction: column; gap: 5px; padding: 0 20px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 10px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-summary .summary-item { align-items: flex-start; flex-direction: column; gap: 4px; padding: 0 12px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-summary .summary-item:first-child { padding-left: 0; border-left: 0; }
|
||||
.summary-item strong.is-date { font-size: 20px; }
|
||||
.settlement-detail-state { margin: 20px 24px; }
|
||||
.settlement-detail-content { min-width: 0; padding: 0 24px 20px; overflow: hidden; }
|
||||
.settlement-detail-state { margin: 14px 16px; }
|
||||
.settlement-detail-content { min-width: 0; padding: 0 16px 12px; overflow: hidden; }
|
||||
.settlement-detail-info { min-width: 0; }
|
||||
.detail-table-scroll { min-width: 0; overflow-x: auto; }
|
||||
:deep(.settlement-detail-info .el-descriptions__table) { table-layout: fixed; }
|
||||
:deep(.settlement-detail-info .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
|
||||
:deep(.detail-table-scroll .el-table) { min-width: 920px; }
|
||||
.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 18px; padding: 14px 0; border: 1px solid var(--el-color-primary-light-7); border-radius: 6px; background: var(--el-color-primary-light-9); }
|
||||
.partial-summary div { min-width: 0; padding: 0 12px; border-left: 1px solid var(--el-color-primary-light-7); }
|
||||
.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 12px; padding: 10px 0; border: 1px solid var(--el-color-primary-light-7); border-radius: 6px; background: var(--el-color-primary-light-9); }
|
||||
.partial-summary div { min-width: 0; padding: 0 10px; border-left: 1px solid var(--el-color-primary-light-7); }
|
||||
.partial-summary div:first-child { border-left: 0; }
|
||||
.partial-summary span, .partial-summary strong { display: block; overflow-wrap: anywhere; }
|
||||
.partial-summary span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||
.partial-summary strong { margin-top: 4px; color: var(--cc-text-primary, #1f2937); font-size: 15px; }
|
||||
.partial-summary strong.is-success { color: #16803c; }
|
||||
.partial-summary strong.is-danger { color: #c2413a; }
|
||||
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px; }
|
||||
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 14px; row-gap: 0; }
|
||||
.settlement-operation-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.settlement-operation-form :deep(.el-input-number) { width: 100%; }
|
||||
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
|
||||
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
|
||||
.remaining-preview strong { color: #b45309; font-size: 16px; }
|
||||
.operation-error { margin-bottom: 12px; }
|
||||
.operation-error-meta { margin: -6px 0 12px; }
|
||||
@@ -580,9 +636,9 @@ onMounted(() => {
|
||||
.settlement-detail-summary, .partial-summary, .settlement-operation-form { grid-template-columns: 1fr; }
|
||||
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
|
||||
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
|
||||
.settlement-detail-content { padding: 0 16px 16px; }
|
||||
.settlement-detail-state { margin: 16px; }
|
||||
.partial-summary { gap: 10px; padding: 12px; }
|
||||
.settlement-detail-content { padding: 0 16px 14px; }
|
||||
.settlement-detail-state { margin: 14px; }
|
||||
.partial-summary { gap: 10px; padding: 12px 10px; }
|
||||
.partial-summary div { padding: 0; border-left: 0; }
|
||||
.settlement-list-toolbar { align-items: stretch; flex-direction: column; padding: 0 0 10px; }
|
||||
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } 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'
|
||||
@@ -96,7 +97,12 @@ describe('finance G3/G4 page operations', () => {
|
||||
|
||||
it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => {
|
||||
const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
] })
|
||||
await router.push('/finance/customer-statements'); await router.isReady()
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } 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'
|
||||
@@ -57,7 +58,12 @@ async function mountWithAccess(component: typeof FinanceCustomerStatementsView |
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
] })
|
||||
await router.push('/finance/customer-statements'); await router.isReady()
|
||||
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ describe('FinanceManualPage delivery contract', () => {
|
||||
|
||||
it('uses business selectors instead of asking operators for internal ids', () => {
|
||||
expect(source).toContain("type: 'select', label: '客户名称'")
|
||||
expect(source).toContain('placeholder="请选择客户"')
|
||||
expect(source).toContain("placeholder: '全部客户'")
|
||||
expect(source).toContain('clearable: true')
|
||||
expect(source).toContain("const customerId = String(filters.value.customerId || '').trim() || undefined")
|
||||
expect(source).not.toContain('if (!customerId) { allRows.value = []; total.value = 0; return }')
|
||||
expect(source).toContain('placeholder="选择订单 / 入库单"')
|
||||
expect(source).toContain('placeholder="选择费用来源"')
|
||||
expect(source).toContain("listPlatformUnifiedOrderFeeDetails(form.customerId, inboundId)")
|
||||
|
||||
@@ -103,7 +103,7 @@ const drawerTitle = computed(() => {
|
||||
const rows = computed(() => serverPaged.value ? allRows.value : allRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '业务关键词', width: 220, placeholder: '单号/费用项目/供应商', props: { 'aria-label': '业务关键词' } },
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户名称', filterable: true, loading: customerOptionsLoading.value }, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })) },
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', filterable: true, clearable: true, loading: customerOptionsLoading.value }, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })) },
|
||||
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: financeBillStatusOptions }] : []),
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => {
|
||||
@@ -330,8 +330,7 @@ async function load() {
|
||||
if (!canView.value) return
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (!customerId) { allRows.value = []; total.value = 0; return }
|
||||
const customerId = String(filters.value.customerId || '').trim() || undefined
|
||||
let source: TableRow[]
|
||||
let responseTotal = 0
|
||||
if (props.mode === 'bills') {
|
||||
|
||||
@@ -32,7 +32,9 @@ describe('finance presentation governance', () => {
|
||||
it('uses one continuous desktop detail surface with balanced summary and scroll-safe tables', () => {
|
||||
expect(settlement).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel"')
|
||||
expect(settlement).toContain('class="settlement-detail-content"')
|
||||
expect(settlement).toContain('min-height: 88px; align-items: center; justify-content: space-between')
|
||||
expect(settlement).toContain('min-height: 54px;')
|
||||
expect(settlement).toContain('align-items: center;')
|
||||
expect(settlement).toContain('justify-content: space-between')
|
||||
expect(settlement).toContain('<el-descriptions :column="4" class="settlement-detail-info">')
|
||||
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain(`title="${title}"`)
|
||||
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
|
||||
@@ -43,7 +45,8 @@ describe('finance presentation governance', () => {
|
||||
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(settlement.match(/class="settlement-operation-form"/g)).toHaveLength(2)
|
||||
expect(settlement).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))')
|
||||
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px')
|
||||
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(settlement).toContain('column-gap: 14px;')
|
||||
})
|
||||
|
||||
it('keeps settlement totals and list actions in one compact toolbar', () => {
|
||||
@@ -114,29 +117,40 @@ describe('finance presentation governance', () => {
|
||||
expect(statements).toContain(':data="adjustLines" size="small" max-height="420" border stripe')
|
||||
})
|
||||
|
||||
it('keeps the statement detail as a compact, continuous list-linked surface', () => {
|
||||
expect(statements).toContain('class="surface-panel statement-detail-panel"')
|
||||
it('opens statement details on a compact dedicated route instead of below the list', () => {
|
||||
expect(statements).toContain('v-else-if="isDetailRoute" class="surface-panel statement-detail-page"')
|
||||
expect(statements).toContain('@row-click="openDetail"')
|
||||
expect(statements).toContain('.statement-detail-panel :deep(.detail-page-layout) { gap: 0; }')
|
||||
expect(statements).toContain('min-height: 48px')
|
||||
expect(statements).toContain("name: 'finance-customer-statement-detail'")
|
||||
expect(statements).toContain('.statement-detail-page :deep(.detail-page-layout) { gap: 0; }')
|
||||
expect(statements).toContain('返回客户对账单')
|
||||
expect(statements).not.toContain('aria-label="所选对账单联动明细"')
|
||||
expect(statements).not.toContain('核对账期、金额和结清状态后查看下方业务明细。')
|
||||
expect(statements).not.toContain('展示当前对账单版本中的业务与计费明细。')
|
||||
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
|
||||
})
|
||||
|
||||
it('uses a compact financial summary row for customer accounts', () => {
|
||||
expect(accounts).toContain('.finance-account-detail__layout :deep(.detail-page-layout){gap:0}')
|
||||
expect(accounts).toContain('.account-metric+.account-metric{border-left:1px solid')
|
||||
expect(accounts).toContain('.account-metric::before{display:none}')
|
||||
expect(accounts).toContain(':deep(.ledger-query.query-toolbar){padding:0;border:0;background:transparent}')
|
||||
expect(accounts).toContain('.finance-account-detail__layout :deep(.detail-page-layout)')
|
||||
expect(accounts).toContain('gap: 0;')
|
||||
expect(accounts).toContain('.account-metric + .account-metric')
|
||||
expect(accounts).toContain('border-left: 1px solid')
|
||||
expect(accounts).toContain('.account-metric::before')
|
||||
expect(accounts).toContain('display: none;')
|
||||
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)')
|
||||
expect(accounts).toContain('padding: 0;')
|
||||
expect(accounts).toContain('border: 0;')
|
||||
expect(accounts).toContain('background: transparent;')
|
||||
expect(accounts).not.toContain('展示应收、应付及每次变动后的未收金额。')
|
||||
expect(accounts).not.toContain('到期日按账单日期加客户账期计算;每个客户独立设置,默认 30 天。')
|
||||
})
|
||||
|
||||
it('keeps fee-source details continuous without nested metric and section cards', () => {
|
||||
expect(arap).toContain('.finance-arap-detail__layout :deep(.detail-page-layout) { gap: 0; }')
|
||||
expect(arap).toContain('.arap-metric + .arap-metric { border-left: 1px solid')
|
||||
expect(arap).toContain('.arap-settlement { padding-left: 24px; border-left: 1px solid')
|
||||
expect(arap).toContain('.finance-arap-detail__layout :deep(.detail-page-layout)')
|
||||
expect(arap).toContain('.arap-metric + .arap-metric')
|
||||
expect(arap).toContain('border-left: 1px solid')
|
||||
expect(arap).toContain('.arap-settlement {')
|
||||
expect(arap).toContain('padding-left: 16px;')
|
||||
expect(arap).toContain('border-left: 1px solid')
|
||||
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
|
||||
expect(arap).not.toContain('description="费用来源与关联业务摘要"')
|
||||
expect(arap).not.toContain('description="当前审核、参与结算和出账状态"')
|
||||
|
||||
@@ -6,6 +6,7 @@ export const financeRoutes = [
|
||||
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list' } },
|
||||
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', showPageHeading: false } },
|
||||
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list' } },
|
||||
{ path: 'finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单详情', description: '查看对账单概要、费用明细、支付历史与调整记录', permission: 'platform:finance:customer-statement:list', showPageHeading: false } },
|
||||
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list' } },
|
||||
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', showPageHeading: false } },
|
||||
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list' } },
|
||||
|
||||
@@ -29,12 +29,24 @@ describe('finance status presentation', () => {
|
||||
it('translates source and ledger codes without exposing unknown values', () => {
|
||||
expect(financeSourceTypeLabel('CONTAINER_COST')).toBe('整柜成本')
|
||||
expect(financeBusinessTypeLabel('RECEIPT')).toBe('收款')
|
||||
expect(financeBusinessTypeLabel('FEE_INCLUDED')).toBe('纳入结算')
|
||||
expect(financeBusinessTypeLabel('FEE_EXCLUDED')).toBe('排除结算')
|
||||
expect(financeBusinessTypeLabel('RECONCILIATION_CREATED')).toBe('生成对账单')
|
||||
expect(financeBusinessTypeLabel('SETTLEMENT_ISSUED')).toBe('结算单生效')
|
||||
expect(financeBusinessTypeLabel('PAYMENT')).toBe('付款')
|
||||
expect(financeBusinessTypeLabel('VOID')).toBe('作废')
|
||||
expect(financeBusinessTypeLabel('NEW_BUSINESS')).toBe('未知业务类型')
|
||||
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
|
||||
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
|
||||
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('费用审核通过')
|
||||
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
|
||||
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
|
||||
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
|
||||
expect(financeDirectionLabel('RECEIVABLE')).toBe('应收')
|
||||
expect(financeDirectionLabel('PAYABLE')).toBe('应付')
|
||||
expect(financeDirectionLabel('NEW_DIRECTION')).toBe('未知方向')
|
||||
expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('其他来源')
|
||||
expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('未知来源类型')
|
||||
expect(financeDocumentTypeLabel('NEW_DOCUMENT')).toBe('未知单据类型')
|
||||
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,13 +61,30 @@ const financeSourceTypeLabels: Record<string, string> = {
|
||||
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
|
||||
}
|
||||
const financeBusinessTypeLabels: Record<string, string> = {
|
||||
BILL: '对账单', RECEIPT: '收款', COST: '成本', ADJUSTMENT: '账务调整', SETTLEMENT: '结算',
|
||||
FEE_INCLUDED: '纳入结算',
|
||||
FEE_EXCLUDED: '排除结算',
|
||||
RECONCILIATION_CREATED: '生成对账单',
|
||||
SETTLEMENT_ISSUED: '结算单生效',
|
||||
RECEIPT: '收款',
|
||||
PAYMENT: '付款',
|
||||
ADJUSTMENT: '账务调整',
|
||||
VOID: '作废',
|
||||
// The following values are still returned by older Finance read models.
|
||||
BILL: '对账单',
|
||||
COST: '成本',
|
||||
SETTLEMENT: '结算',
|
||||
}
|
||||
const financeDocumentTypeLabels: Record<string, string> = {
|
||||
BILL: '对账单', RECEIPT: '收款单', COST: '成本单', SETTLEMENT: '结算单', REVISION: '调整记录',
|
||||
}
|
||||
const financeOperationActionLabels: Record<string, string> = {
|
||||
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', INCLUDE: '参与结算', EXCLUDE: '排除结算', UPDATE: '更新',
|
||||
FEE_SOURCE_REVIEW_APPROVE: '费用审核通过', FEE_SOURCE_REVIEW_REJECT: '费用审核驳回',
|
||||
FEE_INCLUDED: '纳入结算', FEE_EXCLUDED: '排除结算',
|
||||
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
|
||||
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
|
||||
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
|
||||
RECEIPT_CREATE: '登记收款', COST_CREATE: '登记成本', VOID: '作废', REOPEN: '重开',
|
||||
}
|
||||
const financeOperationResultLabels: Record<string, string> = {
|
||||
SUCCESS: '操作成功', FAILED: '操作失败', PENDING: '待处理', PROCESSING: '处理中',
|
||||
@@ -82,9 +99,9 @@ function codeLabel(value: unknown, labels: Record<string, string>, unknownLabel:
|
||||
return labels[code] || unknownLabel
|
||||
}
|
||||
|
||||
export const financeSourceTypeLabel = (value: unknown) => codeLabel(value, financeSourceTypeLabels, '其他来源')
|
||||
export const financeBusinessTypeLabel = (value: unknown) => codeLabel(value, financeBusinessTypeLabels, '其他业务')
|
||||
export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '其他单据')
|
||||
export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '其他操作')
|
||||
export const financeSourceTypeLabel = (value: unknown) => codeLabel(value, financeSourceTypeLabels, '未知来源类型')
|
||||
export const financeBusinessTypeLabel = (value: unknown) => codeLabel(value, financeBusinessTypeLabels, '未知业务类型')
|
||||
export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '未知单据类型')
|
||||
export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '未知操作')
|
||||
export const financeOperationResultLabel = (value: unknown) => codeLabel(value, financeOperationResultLabels, '结果未知')
|
||||
export const financeDirectionLabel = (value: unknown) => codeLabel(value, financeDirectionLabels, '未知方向')
|
||||
|
||||
@@ -76,8 +76,10 @@ export interface FinanceStatementReadModel {
|
||||
status: FinanceBillStatus
|
||||
version: number
|
||||
createdBy?: string
|
||||
createdByName?: string
|
||||
createdAt?: string
|
||||
updatedBy?: string
|
||||
updatedByName?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
@@ -196,8 +198,8 @@ export interface FinanceSettlementDetail {
|
||||
status: string
|
||||
version: number
|
||||
lines: FinanceFeeSourceDetail[]
|
||||
transactions: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; voucherFileIds: string[]; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; occurredAt: string }>
|
||||
transactions: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; voucherFileIds: string[]; createdBy: string; createdByName: string; createdAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; operatorName: string; occurredAt: string }>
|
||||
}
|
||||
|
||||
export interface FinanceSettlementActionPayload {
|
||||
@@ -212,11 +214,13 @@ export interface FinanceReconciliationDetail extends FinanceBillDetail {
|
||||
creditDays: number
|
||||
settled: boolean
|
||||
createdBy?: string
|
||||
createdByName?: string
|
||||
createdAt?: string
|
||||
updatedBy?: string
|
||||
updatedByName?: string
|
||||
updatedAt?: string
|
||||
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; occurredAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; occurredAt: string }>
|
||||
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; createdByName: string; occurredAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; operatorName: string; occurredAt: string }>
|
||||
}
|
||||
|
||||
export interface CreateReconciliationPayload {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isActiveBookingAgent } from './bookingAgents'
|
||||
|
||||
describe('ocean booking agent candidates', () => {
|
||||
it('accepts an active booking supplier', () => {
|
||||
expect(isActiveBookingAgent({ category: 'BOOKING', status: 'ACTIVE' })).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ category: 'BOOKING', status: 'INACTIVE' },
|
||||
{ category: 'TRUCKING', status: 'ACTIVE' },
|
||||
])('rejects a non-eligible supplier: %o', (supplier) => {
|
||||
expect(isActiveBookingAgent(supplier)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface BookingAgentCandidate {
|
||||
category: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export function isActiveBookingAgent(candidate: BookingAgentCandidate) {
|
||||
return candidate.category === 'BOOKING'
|
||||
&& candidate.status === 'ACTIVE'
|
||||
}
|
||||
@@ -29,11 +29,18 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
expect(source).not.toContain("customerId: [{ required: true")
|
||||
expect(source).toContain('placeholder="请选择船公司"')
|
||||
expect(source).toContain('placeholder="请选择订舱代理"')
|
||||
expect(source).toContain('listSuppliers({ page: 1, size: 200, category: \'BOOKING\', status: \'ACTIVE\' })')
|
||||
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'BOOKING', status: 'ACTIVE' })")
|
||||
expect(source).toContain('supplierResult.records.filter(isActiveBookingAgent)')
|
||||
expect(source).toContain('bookingSuppliers.value.filter(isActiveBookingAgent)')
|
||||
expect(source).toContain('selectShippingCompany')
|
||||
expect(source).toContain('selectBookingAgent')
|
||||
expect(source).toContain('bookingAgentLabel')
|
||||
expect(source).toContain('暂无启用的订舱供应商')
|
||||
expect(source).toContain('暂无启用的订舱代理')
|
||||
expect(source).toContain(':label="supplier.name"')
|
||||
expect(source).not.toContain('bookingAgentLabel')
|
||||
expect(source).not.toContain("scope: 'INTERNATIONAL'")
|
||||
expect(source).toContain('!editable && form.data.bookingAgentId')
|
||||
expect(source).toContain('form.data.bookingAgentId = supplier ? String(supplier.id) : null')
|
||||
expect(source).toContain('form.data.bookingAgentName = supplier?.name || null')
|
||||
expect(source).not.toContain('label="客户 ID"')
|
||||
expect(source).not.toContain('label="船公司 ID"')
|
||||
expect(source).not.toContain('label="订舱代理 ID"')
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
} from '../types'
|
||||
import { oceanBookingStatusLabel } from '../status'
|
||||
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from '../containerRequirements'
|
||||
import { isActiveBookingAgent } from '../bookingAgents'
|
||||
|
||||
const partyTypes = [
|
||||
{ value: 'SHIPPER', label: '发货人' },
|
||||
@@ -88,9 +89,7 @@ const selectableCustomers = computed(() => customers.value.filter((customer) =>
|
||||
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
|
||||
)))
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
|
||||
const selectableBookingAgents = computed(() => bookingSuppliers.value.filter((supplier) => (
|
||||
supplier.status === 'ACTIVE' || String(supplier.id) === String(form.data.bookingAgentId)
|
||||
)))
|
||||
const selectableBookingAgents = computed(() => bookingSuppliers.value.filter(isActiveBookingAgent))
|
||||
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channels.value.length === 0)
|
||||
|
||||
function blankParties(): OceanBookingParty[] {
|
||||
@@ -140,7 +139,7 @@ async function loadOptions() {
|
||||
])
|
||||
customers.value = customerResult.records.filter((customer) => customer.status === 'ACTIVE')
|
||||
partners.value = partnerResult.records
|
||||
bookingSuppliers.value = supplierResult.records
|
||||
bookingSuppliers.value = supplierResult.records.filter(isActiveBookingAgent)
|
||||
warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC')
|
||||
} catch (cause) {
|
||||
optionsError.value = getApiErrorDetails(cause, '客户、船公司或订舱代理选项加载失败').message
|
||||
@@ -197,13 +196,10 @@ function selectShippingCompany(partnerId: string) {
|
||||
form.data.shippingCompanyName = partner?.partnerName || null
|
||||
}
|
||||
function selectBookingAgent(supplierId: string) {
|
||||
const supplier = bookingSuppliers.value.find((item) => String(item.id) === supplierId)
|
||||
form.data.bookingAgentId = supplierId || null
|
||||
const supplier = selectableBookingAgents.value.find((item) => String(item.id) === supplierId)
|
||||
form.data.bookingAgentId = supplier ? String(supplier.id) : null
|
||||
form.data.bookingAgentName = supplier?.name || null
|
||||
}
|
||||
function bookingAgentLabel(supplier: Supplier) {
|
||||
return `${supplier.name}(${supplier.scope === 'INTERNATIONAL' ? '国外' : '国内'})`
|
||||
}
|
||||
function applyDetail(value: OceanBookingDetail) {
|
||||
detail.value = value
|
||||
form.expectedVersion = value.version
|
||||
@@ -379,7 +375,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
|
||||
<el-form-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" no-data-text="暂无启用的订舱供应商" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="form.data.bookingAgentId && !selectableBookingAgents.some((supplier) => String(supplier.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in selectableBookingAgents" :key="String(supplier.id)" :label="bookingAgentLabel(supplier)" :value="String(supplier.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" no-data-text="暂无启用的订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !selectableBookingAgents.some((supplier) => String(supplier.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in selectableBookingAgents" :key="String(supplier.id)" :label="supplier.name" :value="String(supplier.id)" /></el-select></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="booking-group" aria-label="航线船期">
|
||||
@@ -478,5 +474,5 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<style scoped>
|
||||
.channel-code{display:block;margin-left:8px;color:var(--cc-muted);font-size:11px}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:5px;color:var(--cc-muted);font-size:12px;line-height:20px}.field-error{color:var(--el-color-danger)}
|
||||
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr));gap:16px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:8px;background:#fff}.booking-group>h3{margin:0;padding:14px 16px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:16px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:780px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:10px;margin-bottom:18px;color:var(--cc-muted);font-size:12px}.add-container{margin:0}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.actions :deep(.el-button){min-height:44px}}
|
||||
.booking-detail{min-width:0;max-width:1880px}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:16px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.5fr) repeat(2,minmax(0,1fr));gap:14px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:10px;background:#fff}.booking-group>h3{margin:0;padding:12px 14px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:12px 14px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:10px;row-gap:8px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:14px}.subsection{display:grid;gap:10px;padding:12px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:780px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:10px;margin-bottom:14px;color:var(--cc-muted);font-size:12px}.add-container{margin:0}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:12px}.actions :deep(.el-button){min-height:44px}}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('@/shared/api/http', async (original) => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() },
|
||||
}))
|
||||
|
||||
const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }
|
||||
const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }
|
||||
const resource = { id: 9007199254740993, tenantId: 0, ...payload, createdBy: 9007199254740994, createdAt: '2026-08-13T10:00:00', updatedBy: null, updatedAt: '2026-08-13T10:00:00', deleted: false, version: 1 }
|
||||
|
||||
describe('platform supplier API', () => {
|
||||
@@ -18,8 +18,9 @@ describe('platform supplier API', () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', data: { records: [resource], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
.mockResolvedValueOnce({ code: '200', data: { booking: 1, customs: 2, trucking: 3, certificate: 4 } })
|
||||
const page = await listSuppliers({ page: 1, size: 20, category: 'BOOKING', scope: 'DOMESTIC', keyword: '测试' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/suppliers', { params: expect.objectContaining({ category: 'BOOKING', scope: 'DOMESTIC', keyword: '测试' }) })
|
||||
const page = await listSuppliers({ page: 1, size: 20, category: 'BOOKING', keyword: '测试' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/suppliers', { params: expect.objectContaining({ category: 'BOOKING', keyword: '测试' }) })
|
||||
expect(vi.mocked(http.get).mock.calls[0]?.[1]?.params).not.toHaveProperty('scope')
|
||||
expect(page.records[0]).toMatchObject({ id: expect.any(String), tenantId: '0', licenseFileId: expect.any(String), createdBy: expect.any(String) })
|
||||
expect(await getSupplierStats()).toEqual({ booking: 1, customs: 2, trucking: 3, certificate: 4 })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/suppliers/stats')
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function listSuppliers(query: SupplierQuery): Promise<SupplierPage>
|
||||
const response = await http.get<ApiEnvelope<SupplierPage>, ApiEnvelope<SupplierPage>>(basePath, {
|
||||
params: {
|
||||
page: query.page ?? 1, size: query.size ?? 20, category: query.category,
|
||||
scope: query.scope || undefined, region: query.region?.trim() || undefined,
|
||||
region: query.region?.trim() || undefined,
|
||||
status: query.status || undefined, keyword: query.keyword?.trim() || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
|
||||
import { getSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
|
||||
import { categoryLabels, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('platform:supplier:list'))
|
||||
@@ -19,7 +19,7 @@ const supplierId = computed(() => typeof route.params.id === 'string' ? route.pa
|
||||
const supplier = ref<Supplier | null>(null); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const licenseOpen = ref(false)
|
||||
let loadSequence = 0
|
||||
const items = computed(() => supplier.value ? [
|
||||
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['国内/国外', supplier.value.scope ? scopeLabels[supplier.value.scope] : '—'], ['地区', supplier.value.region],
|
||||
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['地区', supplier.value.region],
|
||||
['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'],
|
||||
['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'],
|
||||
] : [])
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { createSupplier, getSupplier, updateSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type SaveSupplierRequest, type SupplierCategory } from '@/modules/suppliers/types'
|
||||
import { categoryLabels, settlementLabels, statusLabels, type SaveSupplierRequest } from '@/modules/suppliers/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -25,7 +25,7 @@ const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', scope: 'DOMESTIC', region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
|
||||
const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
|
||||
const form = reactive(initial())
|
||||
const rules: FormRules<SaveSupplierRequest> = {
|
||||
name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }],
|
||||
@@ -38,7 +38,7 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor',
|
||||
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
function payload(): SaveSupplierRequest {
|
||||
return { ...form, name: form.name.trim(), scope: form.category === 'BOOKING' ? form.scope : null, region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
|
||||
return { ...form, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
|
||||
}
|
||||
let loadSequence = 0
|
||||
function resetForm() { Object.assign(form, initial()) }
|
||||
@@ -54,7 +54,7 @@ async function load() {
|
||||
try {
|
||||
const data = await getSupplier(requestedId)
|
||||
if (sequence !== loadSequence || requestedId !== supplierId.value) return
|
||||
Object.assign(form, { name: data.name, category: data.category, scope: data.scope, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
|
||||
Object.assign(form, { name: data.name, category: data.category, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
|
||||
captureBaseline()
|
||||
} catch (error) {
|
||||
if (sequence === loadSequence && requestedId === supplierId.value) loadError.value = getApiErrorDetails(error, '供应商详情加载失败')
|
||||
@@ -62,7 +62,6 @@ async function load() {
|
||||
}
|
||||
async function save() {
|
||||
if (!access.can(requiredPermission.value)) return
|
||||
if (form.category === 'BOOKING' && !form.scope) { ElMessage.error('请选择订舱国内/国外范围'); return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true; submitError.value = null
|
||||
try {
|
||||
@@ -73,7 +72,6 @@ async function save() {
|
||||
} catch (error) { submitError.value = getApiErrorDetails(error, editing.value ? '供应商修改失败' : '供应商新增失败') } finally { saving.value = false }
|
||||
}
|
||||
|
||||
watch(() => form.category, (category) => { if (category === 'BOOKING' && !form.scope) form.scope = 'DOMESTIC'; if (category !== 'BOOKING') form.scope = null })
|
||||
watch(supplierId, () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -90,7 +88,6 @@ watch(supplierId, () => { void load() }, { immediate: true })
|
||||
<el-form-item label="供应商名称" prop="name"><el-input v-model="form.name" maxlength="160" placeholder="请输入供应商名称" /></el-form-item>
|
||||
<el-form-item label="供应商类别" prop="category"><el-select v-model="form.category"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="地区" prop="region"><el-input v-model="form.region" maxlength="80" placeholder="请输入地区" /></el-form-item>
|
||||
<el-form-item v-if="form.category === 'BOOKING'" label="国内/国外" prop="scope"><el-select v-model="form.scope"><el-option v-for="(label, value) in scopeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="法人代表" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" maxlength="80" placeholder="请输入法人代表" /></el-form-item>
|
||||
<el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="80" placeholder="请输入联系人" /></el-form-item>
|
||||
<el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="40" placeholder="请输入联系电话" /></el-form-item>
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWor
|
||||
vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({ getSupplier: mocks.getSupplier, updateSupplier: mocks.updateSupplier, createSupplier: vi.fn() }))
|
||||
|
||||
function supplier(id: string, name: string): Supplier {
|
||||
return { id, tenantId: '0', name, category: 'BOOKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
|
||||
return { id, tenantId: '0', name, category: 'BOOKING', region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
|
||||
@@ -22,6 +22,11 @@ describe('supplier formal pages', () => {
|
||||
expect(source).toContain('class="supplier-query-panel"')
|
||||
expect(source).toContain('class="supplier-query-toolbar"')
|
||||
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px')
|
||||
expect(source).not.toContain('订舱范围')
|
||||
expect(source).not.toContain('国内/国外')
|
||||
expect(formSource).not.toContain('form.scope')
|
||||
expect(formSource).not.toContain('prop="scope"')
|
||||
expect(detailSource).not.toContain('国内/国外')
|
||||
})
|
||||
|
||||
it('uses independent routes for list, detail, create and edit', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
|
||||
import { changeSupplierStatus, getSupplierStats, listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type Supplier, type SupplierCategory, type SupplierScope, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
|
||||
import { categoryLabels, settlementLabels, statusLabels, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -30,14 +30,13 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionId = ref('')
|
||||
const licenseOpen = ref(false)
|
||||
const licenseSupplier = ref<Supplier | null>(null)
|
||||
type Filters = QueryModel & { status: SupplierStatus | ''; scope: SupplierScope | ''; region: string; keyword: string }
|
||||
const filters = ref<Filters>({ status: '', scope: '', region: '', keyword: '' })
|
||||
type Filters = QueryModel & { status: SupplierStatus | ''; region: string; keyword: string }
|
||||
const filters = ref<Filters>({ status: '', region: '', keyword: '' })
|
||||
const applied = ref<Filters>({ ...filters.value })
|
||||
let sequence = 0
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: [{ label: '合作中', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
|
||||
...(activeCategory.value === 'BOOKING' ? [{ key: 'scope', type: 'select' as const, label: '订舱范围', placeholder: '全部国内/国外', width: 170, options: [{ label: '国内', value: 'DOMESTIC' }, { label: '国外', value: 'INTERNATIONAL' }] }] : []),
|
||||
{ key: 'region', type: 'input', label: '地区', placeholder: '请输入地区', width: 170, props: { maxlength: 80 } },
|
||||
{ key: 'keyword', type: 'input', label: '名称或联系人', placeholder: '供应商名称/联系人', width: 220, props: { maxlength: 160 } },
|
||||
])
|
||||
@@ -46,7 +45,6 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'name', label: '供应商名称', minWidth: 230, slotName: 'name' },
|
||||
{ key: 'category', label: '类别', width: 90, slotName: 'category' },
|
||||
...(activeCategory.value === 'BOOKING' ? [{ key: 'scope', label: '国内/国外', width: 110, slotName: 'scope' }] : []),
|
||||
{ key: 'region', label: '地区', minWidth: 110 },
|
||||
{ key: 'legalRepresentative', label: '法人', minWidth: 120 },
|
||||
{ key: 'contactName', label: '联系人', minWidth: 110 },
|
||||
@@ -70,7 +68,7 @@ async function load() {
|
||||
loadError.value = null
|
||||
try {
|
||||
const [result, counts] = await Promise.all([
|
||||
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, scope: activeCategory.value === 'BOOKING' ? applied.value.scope : '', region: applied.value.region, keyword: applied.value.keyword }),
|
||||
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, region: applied.value.region, keyword: applied.value.keyword }),
|
||||
getSupplierStats(),
|
||||
])
|
||||
if (current !== sequence) return
|
||||
@@ -87,7 +85,7 @@ async function load() {
|
||||
function search() { applied.value = { ...filters.value }; page.value = 1; void load() }
|
||||
function switchCategory(category: SupplierCategory) {
|
||||
activeCategory.value = category
|
||||
filters.value.scope = ''; applied.value.scope = ''; page.value = 1
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function showLicense(row: Supplier) { licenseSupplier.value = row; licenseOpen.value = true }
|
||||
@@ -150,7 +148,6 @@ onMounted(load)
|
||||
</template>
|
||||
<template #name="{ row }"><strong class="supplier-name">{{ row.name }}</strong></template>
|
||||
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
|
||||
<template #scope="{ row }">{{ row.scope ? scopeLabels[row.scope as SupplierScope] : '—' }}</template>
|
||||
<template #license="{ row }"><el-button size="small" plain type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabels[row.status as SupplierStatus]" /></template>
|
||||
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export type SupplierCategory = 'BOOKING' | 'CUSTOMS' | 'TRUCKING' | 'CERTIFICATE'
|
||||
export type SupplierScope = 'DOMESTIC' | 'INTERNATIONAL'
|
||||
export type SupplierStatus = 'ACTIVE' | 'INACTIVE'
|
||||
export type SettlementPeriod = 'MONTHLY_30' | 'MONTHLY_45' | 'MONTHLY_60' | 'MONTHLY_90' | 'PER_TRANSACTION' | 'IMMEDIATE'
|
||||
|
||||
@@ -8,7 +7,6 @@ export interface Supplier {
|
||||
tenantId: string
|
||||
name: string
|
||||
category: SupplierCategory
|
||||
scope: SupplierScope | null
|
||||
region: string
|
||||
legalRepresentative: string
|
||||
contactName: string
|
||||
@@ -42,7 +40,6 @@ export interface SupplierQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
category?: SupplierCategory
|
||||
scope?: SupplierScope | ''
|
||||
region?: string
|
||||
status?: SupplierStatus | ''
|
||||
keyword?: string
|
||||
@@ -51,7 +48,6 @@ export interface SupplierQuery {
|
||||
export interface SaveSupplierRequest {
|
||||
name: string
|
||||
category: SupplierCategory
|
||||
scope: SupplierScope | null
|
||||
region: string
|
||||
legalRepresentative: string
|
||||
contactName: string
|
||||
@@ -66,7 +62,6 @@ export interface SaveSupplierRequest {
|
||||
}
|
||||
|
||||
export const categoryLabels: Record<SupplierCategory, string> = { BOOKING: '订舱', CUSTOMS: '报关', TRUCKING: '拖车', CERTIFICATE: '证书' }
|
||||
export const scopeLabels: Record<SupplierScope, string> = { DOMESTIC: '国内', INTERNATIONAL: '国外' }
|
||||
export const statusLabels: Record<SupplierStatus, string> = { ACTIVE: '合作中', INACTIVE: '停用' }
|
||||
export const settlementLabels: Record<SettlementPeriod, string> = {
|
||||
MONTHLY_30: '月结30天', MONTHLY_45: '月结45天', MONTHLY_60: '月结60天', MONTHLY_90: '月结90天', PER_TRANSACTION: '票结', IMMEDIATE: '现结',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from './platformTrucking'
|
||||
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
|
||||
|
||||
@@ -20,7 +20,16 @@ describe('truck appointment API', () => {
|
||||
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
|
||||
await expect(createTruckAppointment({ plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
|
||||
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: {} })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } })
|
||||
})
|
||||
|
||||
it('sends the required idempotency header for rescheduling', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'RESCHEDULED' } })
|
||||
await expect(rescheduleTruckAppointment('9001', '2026-08-20T10:00:00', 2, '调整时间')).resolves.toMatchObject({ status: 'RESCHEDULED' })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/reschedule', { expectedVersion: 2, reason: '调整时间' }, {
|
||||
params: { plannedLoadingAt: '2026-08-20T10:00:00' },
|
||||
headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-reschedule-9001-') },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses scoped attachment and immutable history contracts', async () => {
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
} from '../types'
|
||||
|
||||
const basePath = '/platform/v1/truck-appointments'
|
||||
function operationId(action: string, id: string | number) {
|
||||
return `truck-appointment-${action.toLowerCase()}-${id}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function dtoPayload(payload: SaveTruckAppointmentRequest) {
|
||||
return Object.fromEntries(Object.entries(payload).map(([key, value]) => [key, value === '' ? null : value])) as SaveTruckAppointmentRequest
|
||||
}
|
||||
@@ -103,13 +107,17 @@ export async function performTruckAppointmentAction(
|
||||
idempotencyKey?: string,
|
||||
requestHash?: string,
|
||||
) {
|
||||
const operationKey = idempotencyKey || operationId(action, id)
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, {
|
||||
headers: { ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
|
||||
headers: { 'Idempotency-Key': operationKey, ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function rescheduleTruckAppointment(id: string | number, plannedLoadingAt: string, expectedVersion: number, reason?: string) {
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/reschedule`, { expectedVersion, reason: reason || null }, { params: { plannedLoadingAt } })
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/reschedule`, { expectedVersion, reason: reason || null }, {
|
||||
params: { plannedLoadingAt },
|
||||
headers: { 'Idempotency-Key': operationId('RESCHEDULE', id) },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -22,4 +22,12 @@ withDefaults(defineProps<{
|
||||
.admin-page.is-spacing-compact { gap: 12px; }
|
||||
.admin-page.is-spacing-normal { gap: 16px; }
|
||||
.admin-page.is-spacing-relaxed { gap: 20px; }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.admin-page.is-spacing-normal { gap: 12px; }
|
||||
|
||||
.admin-page.is-width-comfortable {
|
||||
width: min(100%, 1920px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -369,5 +369,22 @@ function setPage(value: number) {
|
||||
.data-table__page-buttons { gap: 4px; }
|
||||
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
|
||||
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.data-table__toolbar { min-height: 64px; gap: 12px; padding: 10px 14px; }
|
||||
.data-table__toolbar.is-compact { min-height: 50px; padding: 6px 12px; }
|
||||
.data-table__toolbar-actions,
|
||||
.data-table__business-actions,
|
||||
.data-table__utility-actions,
|
||||
.data-table__toolbar-end { gap: 6px; }
|
||||
.data-table__pagination { min-height: 52px; gap: 12px; padding: 10px 14px; }
|
||||
.data-table__pagination-left,
|
||||
.data-table__pagination-controls,
|
||||
.data-table__page-size,
|
||||
.data-table__page-buttons { gap: 6px; }
|
||||
.data-table__page-button { width: 30px; min-width: 30px; height: 30px; }
|
||||
.data-table__page-ellipsis { width: 22px; height: 30px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) { .data-table__toolbar, .data-table__toolbar.is-compact { min-height: 0; align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: wrap; justify-content: flex-start; } .data-table__toolbar-actions.has-business-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; } .data-table__business-actions { justify-content: flex-start; } .data-table__utility-actions { align-items: flex-start; justify-content: flex-end; } .data-table__toolbar-end { justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { max-width: 100%; min-height: 40px; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
|
||||
</style>
|
||||
|
||||
@@ -18,4 +18,8 @@ withDefaults(defineProps<{
|
||||
<style scoped>
|
||||
.detail-page-layout { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
|
||||
.detail-page-layout__header, .detail-page-layout__summary, .detail-page-layout__content, .detail-page-layout__footer { min-width: 0; }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.detail-page-layout { gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,4 +27,11 @@ withDefaults(defineProps<{
|
||||
.form-section__heading { margin-bottom: 16px; }
|
||||
.form-section__heading h2 { margin: 0; color: var(--cc-text, #172033); font-size: 14px; line-height: 1.4; }
|
||||
.form-section__heading p { margin: 4px 0 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.5; }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.form-section { padding: 14px 0; }
|
||||
.form-section:first-child { padding-top: 0; }
|
||||
.form-section:last-child { padding-bottom: 0; }
|
||||
.form-section__heading { margin-bottom: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,4 +37,8 @@ function hasRegion(name: RegionName) {
|
||||
.list-page-layout { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
|
||||
.list-page-layout__toolbar, .list-page-layout__metrics, .list-page-layout__state, .list-page-layout__content { min-width: 0; }
|
||||
.list-page-layout__toolbar:empty, .list-page-layout__metrics:empty, .list-page-layout__state:empty, .list-page-layout__content:empty { display: none; }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.list-page-layout { gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,6 +33,11 @@ withDefaults(defineProps<{
|
||||
.page-toolbar__description { margin: 5px 0 0; color: var(--cc-muted, #667085); font-size: 13px; line-height: 1.55; }
|
||||
.page-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.page-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.page-toolbar { gap: 12px; }
|
||||
.page-toolbar__actions { gap: 6px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.page-toolbar { flex-direction: column; }
|
||||
.page-toolbar__actions { width: 100%; justify-content: flex-start; }
|
||||
|
||||
@@ -247,6 +247,13 @@ defineExpose({ search, reset })
|
||||
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
|
||||
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
:global(.cc-layout-preference-popper) { max-width: calc(100vw - 24px); }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.query-toolbar { padding: 12px; gap: 8px; }
|
||||
.query-toolbar__filters { gap: 8px 12px; }
|
||||
.query-toolbar__field, .query-toolbar__control { gap: 4px; }
|
||||
.query-toolbar__actions { gap: 6px; }
|
||||
}
|
||||
@media (min-width: 721px) { .query-toolbar__filters { display: contents; } .query-toolbar__tail { display: flex; align-items: flex-end; width: auto; flex: 0 0 auto; gap: 10px; } }
|
||||
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { width: 100%; } .query-toolbar__tail { display: contents; } .query-toolbar__control { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
|
||||
</style>
|
||||
|
||||
@@ -275,6 +275,26 @@
|
||||
.forbidden-page { padding-right: 14px; padding-left: 14px; }
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.business-page { gap: 12px; }
|
||||
.metric-strip { gap: 12px; }
|
||||
.metric-item { padding: 14px 16px; }
|
||||
.metric-item span { margin-bottom: 6px; }
|
||||
.filter-panel { padding: 10px 12px; gap: 12px; }
|
||||
.filter-fields, .filter-actions { gap: 8px; }
|
||||
.table-panel .el-table th.el-table__cell { height: 40px; }
|
||||
.table-panel .el-table td.el-table__cell { padding: 10px 0; }
|
||||
.panel-heading { min-height: 68px; padding: 12px 16px; gap: 14px; }
|
||||
.panel-heading h2 { font-size: 15px; }
|
||||
.table-footer { min-height: 52px; gap: 12px; padding: 8px 14px; }
|
||||
.table-actions .el-button + .el-button { margin-left: 8px; }
|
||||
.form-section { padding: 16px 0; }
|
||||
.form-section h3, .detail-section h3 { margin-bottom: 12px; }
|
||||
.form-grid { gap: 0 12px; }
|
||||
.drawer-form .el-form-item { margin-bottom: 14px; }
|
||||
.form-help { margin: -4px 0 14px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-sidebar, .side-nav-item, .stat-card { transition: none !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user