feat(fleet): connect operations quotes costs and handover workspaces
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
|
||||
import { createFleetQuote, updateFleetQuote, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ quote: FleetQuote | null; readonly?: boolean; kind: FleetQuoteKind }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
type QuoteForm = Omit<FleetQuoteDraft, 'distanceMiles'> & { distanceMiles: number | null }
|
||||
const empty = (): QuoteForm => ({ kind: props.kind, partyId: '', originCity: '', destinationCode: '', distanceMiles: null, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: null, looseDeliveryPrice: null, validFrom: new Date().toISOString().slice(0, 10), validTo: '', remark: null })
|
||||
const form = reactive<QuoteForm>(empty()), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let pending: { idempotencyKey: string; details: FleetQuoteDraft } | null = null
|
||||
const uncertain = ref(false)
|
||||
watch([open, () => props.quote?.quoteNo], () => {
|
||||
if (!open.value) return
|
||||
uncertain.value = !props.quote && pending !== null
|
||||
Object.assign(form, props.quote?.details || pending?.details || empty()); error.value = null
|
||||
})
|
||||
async function save() {
|
||||
if (props.readonly || saving.value) return
|
||||
const editing = props.quote
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
if (!form.partyId || !form.originCity.trim() || !form.destinationCode.trim() || !form.validFrom || !form.validTo) throw new Error('请选择往来单位并填写路线和有效期')
|
||||
if (form.distanceMiles == null || !Number.isFinite(form.distanceMiles)) throw new Error('请填写有效的预估距离')
|
||||
if (form.selfPickupPrice == null && form.fullDeliveryPrice == null && form.looseDeliveryPrice == null) throw new Error('至少填写一种价格,零表示明确零价')
|
||||
const details: FleetQuoteDraft = { ...form, distanceMiles: form.distanceMiles }
|
||||
if (editing) await updateFleetQuote(editing, details)
|
||||
else { pending ??= { idempotencyKey: crypto.randomUUID(), details }; await createFleetQuote(pending.idempotencyKey, pending.details) }
|
||||
if (!editing) pending = null
|
||||
uncertain.value = false; open.value = false; emit('saved')
|
||||
} catch (e) { error.value = getApiErrorDetails(e, '报价保存失败'); if (!editing && e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending = null; uncertain.value = !editing && pending !== null }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
<template><AppDialog v-model="open" :title="readonly ? '报价详情' : quote ? '编辑报价' : '新增报价'" width="780px" class="admin-density-dialog" :close-on-click-modal="false">
|
||||
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
|
||||
<p v-if="uncertain">保存结果尚未确认,重试将发送原请求和原内容。</p>
|
||||
<el-form label-position="top" class="density-form quote-form" :disabled="readonly || saving || uncertain">
|
||||
<el-form-item label="用途"><el-input :model-value="form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'" disabled /></el-form-item>
|
||||
<el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="readonly || saving || uncertain" /></el-form-item>
|
||||
<el-form-item label="发货城市" required><el-input v-model="form.originCity" maxlength="128" placeholder="请输入发货城市" /></el-form-item>
|
||||
<el-form-item label="目的仓码" required><el-input v-model="form.destinationCode" maxlength="64" placeholder="请输入目的仓码,如 ONT8" /></el-form-item>
|
||||
<el-form-item label="预估距离(mile)" required><el-input-number :model-value="form.distanceMiles" @update:model-value="form.distanceMiles = $event ?? null" :min="0" :precision="1" placeholder="请输入预估英里数" /></el-form-item>
|
||||
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
|
||||
<el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
|
||||
<el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
|
||||
<el-form-item label="生效日" required><el-date-picker v-model="form.validFrom" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="失效日(含当天)" required><el-date-picker v-model="form.validTo" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充报价说明" /></el-form-item>
|
||||
</el-form><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p>
|
||||
<template #footer><el-button @click="open = false">关闭</el-button><el-button v-if="!readonly" type="primary" :loading="saving" @click="save">{{ uncertain ? '按原请求重试' : '保存报价' }}</el-button></template>
|
||||
</AppDialog></template>
|
||||
<style scoped>.quote-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
|
||||
import { importFleetQuotes, saveFleetCsv, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
|
||||
import { fleetQuoteCsvHeader, parseFleetQuoteCsv } from '../fleetQuoteCsv'
|
||||
const open = defineModel<boolean>({ default: false }), props = defineProps<{ kind: FleetQuoteKind }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const partyId = ref(''), rows = ref<FleetQuoteDraft[]>([]), source = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<{ key: string; rows: FleetQuoteDraft[] } | null>(null)
|
||||
const activeKind = computed(() => pending.value?.rows[0]?.kind || props.kind)
|
||||
watch([partyId, () => props.kind], () => { if (!pending.value) preview() })
|
||||
function preview() { try { rows.value = source.value ? parseFleetQuoteCsv(source.value, props.kind, partyId.value) : []; error.value = null } catch (e) { rows.value = []; error.value = getApiErrorDetails(e, 'CSV校验失败') } }
|
||||
async function pick(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; if (file.size > 500000) { error.value = getApiErrorDetails(new Error('CSV不能超过500KB')); return }; source.value = await file.text(); preview() }
|
||||
function template() { saveFleetCsv(new Blob(['\uFEFF' + fleetQuoteCsvHeader.join(',') + '\r\n'], { type: 'text/csv;charset=utf-8' }), '车队报价模板.csv') }
|
||||
async function save() { saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending.value = null } finally { saving.value = false } }
|
||||
</script>
|
||||
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog" :close-on-click-modal="false">
|
||||
<p>本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
|
||||
<FleetQuotePartySelect v-model="partyId" :kind="activeKind" :disabled="saving || !!pending" />
|
||||
<el-button @click="template">下载CSV模板</el-button><input type="file" accept=".csv" aria-label="选择报价CSV" :disabled="saving || !!pending" @change="pick" />
|
||||
<el-alert v-if="error" type="error" :title="error.message" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
|
||||
<p v-if="pending">已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。</p>
|
||||
<p aria-live="polite">共 {{ rows.length }} 条,预览前10条;目录有效性与重复组合由服务端核对。</p>
|
||||
<table class="density-table"><thead><tr><th>发货城市</th><th>目的仓码</th><th>距离(mile)</th><th>币种</th><th>整车自提</th><th>整车派送</th><th>散板派送</th><th>有效期</th></tr></thead><tbody><tr v-for="(row, index) in rows.slice(0,10)" :key="index"><td>{{ row.originCity }}</td><td>{{ row.destinationCode }}</td><td>{{ row.distanceMiles }}</td><td>{{ row.currency }}</td><td>{{ row.selfPickupPrice ?? '—' }}</td><td>{{ row.fullDeliveryPrice ?? '—' }}</td><td>{{ row.looseDeliveryPrice ?? '—' }}</td><td>{{ row.validFrom }} — {{ row.validTo }}</td></tr></tbody></table>
|
||||
<template #footer><el-button @click="open = false">关闭</el-button><el-button type="primary" :disabled="!rows.length || !partyId" :loading="saving" @click="save">{{ pending ? '按原请求重试' : '确认导入' }}</el-button></template>
|
||||
</AppDialog></template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { fleetQuoteCustomers, fleetQuoteSuppliers, type FleetQuoteKind } from '../api/fleetQuotes'
|
||||
const value = defineModel<string>({ default: '' })
|
||||
const props = defineProps<{ kind: FleetQuoteKind; partyName?: string; disabled?: boolean }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<{ id: string; name: string }[]>([]), loading = ref(false), error = ref('')
|
||||
let sequence = 0
|
||||
watch(() => props.kind, () => { rows.value = []; error.value = ''; sequence++ })
|
||||
async function search(keyword = '') {
|
||||
if (props.disabled || (props.kind === 'PURCHASE' && !access.can('platform:supplier:list'))) return
|
||||
const current = ++sequence; loading.value = true; error.value = ''
|
||||
try { const result = props.kind === 'SALE' ? await fleetQuoteCustomers(keyword) : await fleetQuoteSuppliers(keyword); if (sequence === current) rows.value = result }
|
||||
catch { if (sequence === current) error.value = '目录加载失败,请重新搜索' } finally { if (sequence === current) loading.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template><el-select v-model="value" filterable remote :remote-method="search" :loading="loading" :disabled="disabled || (kind === 'PURCHASE' && !access.can('platform:supplier:list'))" placeholder="搜索并选择往来单位" @focus="search()">
|
||||
<el-option v-if="value && partyName && !rows.some(r => r.id === value)" :value="value" :label="partyName" />
|
||||
<el-option v-for="row in rows" :key="row.id" :value="row.id" :label="row.name" />
|
||||
</el-select><p v-if="error" role="alert">{{ error }}</p><p v-if="kind === 'PURCHASE' && !access.can('platform:supplier:list')">选择车队需要供应商列表权限。</p></template>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripCostPanel from './TripCostPanel.vue'
|
||||
import * as api from '../api/tripCosts'
|
||||
const access = vi.hoisted(() => ({ read: true, write: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') ? access.read : access.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/tripCosts', () => ({ listTripCosts: vi.fn(), createTripCost: vi.fn(), updateTripCost: vi.fn(), listTripCostHistory: vi.fn() }))
|
||||
const cost: api.TripCost = { costNo: 'TC-ONE', tripNo: 'TR-ONE', details: { category: 'FUEL', currency: 'USD', amount: 10, incurredAt: '2026-01-01T00:00:00Z', remark: null }, recordVersion: '1', updatedAt: '2026-01-01T00:00:00Z' }
|
||||
const result: api.TripCosts = { page: { records: [cost], page: 1, size: 10, total: 1, pages: 1 }, summary: { departureVersion: null, manifestHash: null, allocationMethod: null, allocationBlockReason: '尚无实际发车装载记录', totals: [] } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
|
||||
function mount() {
|
||||
const wrapper = shallowMount(TripCostPanel, { props: { tripNo: 'TR-ONE' }, global: { stubs: {
|
||||
ElButton: { template: '<button><slot/></button>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
|
||||
ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input,
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.costNo"><slot name="actions" :row="row"/></div></div>' },
|
||||
RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '<div><button v-for="a in actions.filter(a => a.visible !== false)" :key="a.key" @click="$emit(\'action\', a.key)">{{ a.label }}</button></div>' },
|
||||
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
function button(w: VueWrapper, label: string) { return w.findAll('button').find(b => b.text() === label)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.read = true; access.write = true; vi.mocked(api.listTripCosts).mockResolvedValue(structuredClone(result)); vi.mocked(api.createTripCost).mockResolvedValue(cost); vi.mocked(api.updateTripCost).mockResolvedValue(cost)
|
||||
vi.mocked(api.listTripCostHistory).mockResolvedValue({ records: [{ recordVersion: '9', action: 'UPDATE', reason: '更正', occurredAt: cost.updatedAt, snapshot: { cost: { ...cost, recordVersion: '9' }, summary: result.summary } }], page: 1, size: 1, total: 1, pages: 1 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it('loads one scoped page and summary without per-row reads or invented zero totals', async () => {
|
||||
const w = mount(); await flushPromises()
|
||||
expect(api.listTripCosts).toHaveBeenCalledExactlyOnceWith('TR-ONE', 1, 10)
|
||||
expect(api.listTripCostHistory).not.toHaveBeenCalled(); expect(w.text()).toContain('尚未登记直接成本')
|
||||
})
|
||||
it('does not request cost data without list permission, and hides writes for read-only users', async () => {
|
||||
access.read = false; const denied = mount(); await flushPromises(); expect(api.listTripCosts).not.toHaveBeenCalled(); expect(denied.text()).toContain('暂无车次成本查看权限')
|
||||
access.read = true; access.write = false; const readOnly = mount(); await flushPromises()
|
||||
expect(readOnly.text()).not.toContain('登记成本'); expect(readOnly.text()).not.toContain('更正'); expect(readOnly.text()).toContain('历史')
|
||||
})
|
||||
it('ignores late responses after switching trips and resets later pagination once', async () => {
|
||||
const w = mount(); await flushPromises(); w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 3); await flushPromises()
|
||||
let finish!: (value: api.TripCosts) => void
|
||||
vi.mocked(api.listTripCosts).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises()
|
||||
vi.mocked(api.listTripCosts).mockResolvedValueOnce({ ...result, page: { ...result.page, records: [{ ...cost, costNo: 'TC-THREE', tripNo: 'TR-THREE' }] } })
|
||||
await w.setProps({ tripNo: 'TR-THREE' }); await flushPromises(); finish(result); await flushPromises()
|
||||
expect(api.listTripCosts).toHaveBeenNthCalledWith(3, 'TR-TWO', 1, 10)
|
||||
expect(api.listTripCosts).toHaveBeenNthCalledWith(4, 'TR-THREE', 1, 10)
|
||||
expect(w.findComponent({ name: 'DataTable' }).props('data')[0].costNo).toBe('TC-THREE')
|
||||
})
|
||||
it('retains the exact key and contents when a creation response is uncertain', async () => {
|
||||
vi.mocked(api.createTripCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce(cost)
|
||||
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5); await button(w, '保存').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.createTripCost).mock.calls[0]!
|
||||
expect(w.text()).toContain('正在核对上次登记结果')
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual(first)
|
||||
})
|
||||
it('keeps the successful save authoritative when pagination changes while it is pending', async () => {
|
||||
let finish!: (value: api.TripCost) => void
|
||||
vi.mocked(api.createTripCost).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
finish(cost); await flushPromises()
|
||||
expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false)
|
||||
expect(w.text()).not.toContain('正在核对上次登记结果')
|
||||
expect(api.listTripCosts).toHaveBeenLastCalledWith('TR-ONE', 1, 10)
|
||||
expect(api.createTripCost).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('does not let an old save clear a new pending save after switching away and back', async () => {
|
||||
let finishFirst!: (value: api.TripCost) => void
|
||||
let finishSecond!: (value: api.TripCost) => void
|
||||
vi.mocked(api.createTripCost)
|
||||
.mockImplementationOnce(() => new Promise(resolve => { finishFirst = resolve }))
|
||||
.mockImplementationOnce(() => new Promise(resolve => { finishSecond = resolve }))
|
||||
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises()
|
||||
await w.setProps({ tripNo: 'TR-ONE' }); await flushPromises()
|
||||
await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
finishFirst(cost); await flushPromises()
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
expect(api.createTripCost).toHaveBeenCalledTimes(2)
|
||||
finishSecond(cost); await flushPromises()
|
||||
expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false)
|
||||
})
|
||||
it('reads the current version before editing and avoids writing unchanged contents', async () => {
|
||||
const w = mount(); await flushPromises(); await button(w, '更正').trigger('click'); await flushPromises()
|
||||
expect(api.listTripCostHistory).toHaveBeenCalledExactlyOnceWith('TR-ONE', 'TC-ONE', 1, 1)
|
||||
await button(w, '保存').trigger('click'); await flushPromises(); expect(api.updateTripCost).not.toHaveBeenCalled()
|
||||
await button(w, '更正').trigger('click'); await flushPromises(); w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
expect(api.updateTripCost).toHaveBeenCalledWith('TR-ONE', expect.objectContaining({ recordVersion: '9' }), expect.objectContaining({ amount: 15 }), '')
|
||||
})
|
||||
it('failed refresh clears both rows and summary instead of keeping stale evidence', async () => {
|
||||
const w = mount(); await flushPromises(); vi.mocked(api.listTripCosts).mockRejectedValueOnce(new Error('查询失败'))
|
||||
await button(w, '刷新').trigger('click'); await flushPromises()
|
||||
expect(w.findComponent({ name: 'DataTable' }).props('data')).toEqual([]); expect(w.text()).toContain('车次成本加载失败'); expect(w.text()).not.toContain('尚无实际发车装载记录')
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { listTripCosts, createTripCost, updateTripCost, listTripCostHistory,
|
||||
type CostCategory, type CostCurrency, type TripCost, type TripCostDraft, type CostSummary, type TripCostHistory } from '../api/tripCosts'
|
||||
|
||||
const props = defineProps<{ tripNo: string }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const allowed = computed(() => access.can(permission.tripList))
|
||||
const rows = ref<TripCost[]>([]), summary = ref<CostSummary | null>(null), page = ref(1), size = ref(10), total = ref(0), loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false), editing = ref<TripCost | null>(null), opening = ref(false), saving = ref(false), reason = ref('')
|
||||
const form = reactive<{ category: CostCategory; currency: CostCurrency; amount: number | null; incurredAt: string; remark: string }>({ category: 'FUEL', currency: 'USD', amount: null, incurredAt: '', remark: '' })
|
||||
const pendingCreate = ref<{ key: string; details: TripCostDraft } | null>(null)
|
||||
const writable = computed(() => allowed.value && access.can(editing.value ? permission.tripCostEdit : permission.tripCostCreate))
|
||||
const historyOpen = ref(false), historyNo = ref(''), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
|
||||
const histories = ref<TripCostHistory[]>([]), historyError = ref<ApiErrorDetails | null>(null)
|
||||
const categories: Record<CostCategory, string> = { FUEL: '燃油', DRIVER: '司机费用', TOLL: '通行费', PARKING: '停车费', OTHER: '其他直接费用' }
|
||||
let generation = 0, historyGeneration = 0, scopeGeneration = 0, lastSaved = ''
|
||||
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
const money = (value: number | null) => value == null ? '暂未分摊' : value.toFixed(2)
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'costNo', prop: 'costNo', label: '成本编号', minWidth: 220 },
|
||||
{ key: 'category', label: '费用类别', width: 140, slotName: 'category' },
|
||||
{ key: 'amount', label: '登记金额', width: 160, slotName: 'amount' },
|
||||
{ key: 'incurredAt', label: '发生时间', minWidth: 180, slotName: 'time' },
|
||||
{ key: 'remark', label: '备注', minWidth: 260, slotName: 'remark' },
|
||||
]
|
||||
const allocationColumns: DataTableColumn[] = [
|
||||
{ key: 'sourceType', label: '货物来源', width: 120, slotName: 'source' },
|
||||
{ key: 'businessNo', prop: 'businessNo', label: '业务编号', minWidth: 220 },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车', minWidth: 220 },
|
||||
{ key: 'palletSpaces', prop: 'palletSpaces', label: '实际标准托位', minWidth: 140 },
|
||||
{ key: 'amount', label: '分摊金额', minWidth: 160, slotName: 'amount' },
|
||||
]
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'time' },
|
||||
{ key: 'action', label: '操作', width: 90, slotName: 'action' },
|
||||
{ key: 'recordVersion', prop: 'recordVersion', label: '版本', width: 80 },
|
||||
{ key: 'amount', label: '当时金额', minWidth: 150, slotName: 'amount' },
|
||||
{ key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
|
||||
]
|
||||
function draft(): TripCostDraft { return { category: form.category, currency: form.currency, amount: form.amount!, incurredAt: form.incurredAt, remark: form.remark.trim() || null } }
|
||||
function actions(): RowActionItem[] { return [
|
||||
{ key: 'edit', label: '更正', visible: access.can(permission.tripCostEdit), disabled: opening.value || saving.value },
|
||||
{ key: 'history', label: '历史', disabled: opening.value || saving.value },
|
||||
] }
|
||||
async function load() {
|
||||
const request = ++generation
|
||||
rows.value = []; total.value = 0; summary.value = null; error.value = null
|
||||
if (!allowed.value) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listTripCosts(props.tripNo, page.value, size.value)
|
||||
if (request === generation) { rows.value = result.page.records; total.value = result.page.total; summary.value = result.summary }
|
||||
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '车次成本加载失败') }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
watch([() => props.tripNo, page, size, allowed], (current, previous) => {
|
||||
if (previous?.length && current[0] !== previous[0]) { scopeGeneration++; generation++; historyGeneration++; editorOpen.value = false; historyOpen.value = false; pendingCreate.value = null; opening.value = false; saving.value = false }
|
||||
if (!current[3]) { scopeGeneration++; editorOpen.value = false; historyOpen.value = false; histories.value = []; historyGeneration++; opening.value = false; saving.value = false }
|
||||
if (previous?.length && (current[0] !== previous[0] || current[2] !== previous[2]) && page.value !== 1) { page.value = 1; return }
|
||||
void load()
|
||||
}, { immediate: true })
|
||||
async function beginEdit(row: TripCost | null) {
|
||||
if (opening.value || saving.value) return
|
||||
if (row && pendingCreate.value) { ElMessage.warning('请先重新打开登记窗口,核对上次保存结果'); return }
|
||||
if (!allowed.value || !access.can(row ? permission.tripCostEdit : permission.tripCostCreate)) return
|
||||
const scope = props.tripNo, request = scopeGeneration
|
||||
opening.value = true; saveError.value = null
|
||||
try {
|
||||
let current = row
|
||||
if (row) {
|
||||
const result = await listTripCostHistory(scope, row.costNo, 1, 1)
|
||||
current = result.records[0]?.snapshot.cost ?? null
|
||||
if (!current) throw new Error('成本记录最新版本不存在,请刷新列表')
|
||||
}
|
||||
if (request !== scopeGeneration || scope !== props.tripNo) return
|
||||
editing.value = current; reason.value = ''
|
||||
if (!pendingCreate.value) Object.assign(form, current ? { ...current.details, remark: current.details.remark ?? '' }
|
||||
: { category: 'FUEL', currency: 'USD', amount: null, incurredAt: new Date().toISOString(), remark: '' })
|
||||
lastSaved = current ? JSON.stringify(draft()) : ''
|
||||
editorOpen.value = true
|
||||
} catch (cause) { if (scope === props.tripNo && request === scopeGeneration) error.value = getApiErrorDetails(cause, '成本详情加载失败') }
|
||||
finally { if (scope === props.tripNo && request === scopeGeneration) opening.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value || !writable.value) return
|
||||
if (form.amount == null || !Number.isFinite(form.amount) || form.amount < 0 || !form.incurredAt) { ElMessage.warning('请填写金额和实际发生时间'); return }
|
||||
const details = pendingCreate.value?.details ?? draft()
|
||||
if (editing.value && JSON.stringify(details) === lastSaved) { editorOpen.value = false; return }
|
||||
const scope = props.tripNo, request = scopeGeneration
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
if (editing.value) await updateTripCost(scope, editing.value, details, reason.value)
|
||||
else {
|
||||
pendingCreate.value ??= { key: crypto.randomUUID(), details }
|
||||
await createTripCost(scope, pendingCreate.value.key, pendingCreate.value.details)
|
||||
}
|
||||
if (scope !== props.tripNo || request !== scopeGeneration) return
|
||||
pendingCreate.value = null; editorOpen.value = false; ElMessage.success('车次成本已保存')
|
||||
if (page.value !== 1) page.value = 1
|
||||
else await load()
|
||||
} catch (cause) {
|
||||
if (scope !== props.tripNo || request !== scopeGeneration) return
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingCreate.value = null
|
||||
saveError.value = getApiErrorDetails(cause, '成本保存失败')
|
||||
} finally { if (scope === props.tripNo && request === scopeGeneration) saving.value = false }
|
||||
}
|
||||
function showHistory(row: TripCost) { historyNo.value = row.costNo; historyPage.value = 1; historyOpen.value = true }
|
||||
async function loadHistory() {
|
||||
const request = ++historyGeneration
|
||||
histories.value = []; historyTotal.value = 0; historyError.value = null
|
||||
if (!historyOpen.value || !allowed.value) return
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const result = await listTripCostHistory(props.tripNo, historyNo.value, historyPage.value, 10)
|
||||
if (request === historyGeneration) { histories.value = result.records; historyTotal.value = result.total }
|
||||
} catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '成本历史加载失败') }
|
||||
finally { if (request === historyGeneration) historyLoading.value = false }
|
||||
}
|
||||
watch([historyOpen, historyNo, historyPage], loadHistory)
|
||||
onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="车次直接成本">
|
||||
<p v-if="!allowed">暂无车次成本查看权限。</p>
|
||||
<template v-else>
|
||||
<p class="cost-note">按实际费用登记,金额按币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p>
|
||||
<LoadErrorState v-if="error" title="车次成本加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="summary">
|
||||
<p v-if="!summary.totals.length" class="cost-note">尚未登记直接成本。</p>
|
||||
<p v-if="summary.allocationBlockReason" class="cost-note">{{ summary.allocationBlockReason }}</p>
|
||||
<div v-for="value in summary.totals" :key="value.currency" class="cost-summary">
|
||||
<strong>{{ value.currency }} · 已登记直接成本 {{ value.directCost.toFixed(2) }}</strong>
|
||||
<span>自有货分摊 {{ money(value.ownCost) }} · 外接货分摊 {{ money(value.externalCost) }}</span>
|
||||
<DataTable v-if="value.allocations.length" :data="value.allocations" :columns="allocationColumns" row-key="businessNo">
|
||||
<template #source="{ row }">{{ row.sourceType === 'EXTERNAL' ? '外接货' : '自有业务货物' }}</template>
|
||||
<template #amount="{ row }">{{ value.currency }} {{ row.amount.toFixed(2) }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="costNo" empty-text="暂无直接成本记录">
|
||||
<template #toolbar-actions><el-button :disabled="loading || saving" @click="load">刷新</el-button><el-button v-if="access.can(permission.tripCostCreate)" type="primary" :disabled="opening || saving" @click="beginEdit(null)">登记成本</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'edit' ? beginEdit(row as TripCost) : showHistory(row as TripCost)" /></template>
|
||||
<template #category="{ row }">{{ categories[row.details.category as CostCategory] }}</template>
|
||||
<template #amount="{ row }">{{ row.details.currency }} {{ row.details.amount.toFixed(2) }}</template>
|
||||
<template #time="{ row }">{{ time(row.details.incurredAt) }}</template>
|
||||
<template #remark="{ row }">{{ row.details.remark || '—' }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
<AppDialog v-model="editorOpen" :title="editing ? '更正车次直接成本' : '登记车次直接成本'" width="680px" class="admin-density-dialog" confirm-text="保存成本" :confirm-loading="saving" :confirm-disabled="!writable" @confirm="save">
|
||||
<LoadErrorState v-if="saveError" title="成本保存未完成" :message="saveError.message" @retry="save"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="pendingCreate" class="cost-note">正在核对上次登记结果,重试会使用同一请求,不重复记账。</p>
|
||||
<el-form label-position="top" :disabled="saving || !writable || !!pendingCreate" class="cost-form">
|
||||
<el-form-item label="费用类别" required><el-select v-model="form.category" placeholder="请选择费用类别"><el-option v-for="(label, key) in categories" :key="key" :value="key" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="币种" required><el-select v-model="form.currency" placeholder="请选择原始币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item label="实际金额" required><el-input-number v-model="form.amount" :min="0" :max="999999999999.99" :precision="2" placeholder="请输入金额,可明确登记为零" /></el-form-item>
|
||||
<el-form-item label="发生时间" required><el-date-picker v-model="form.incurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择实际发生时间" /></el-form-item>
|
||||
<el-form-item label="备注(选填)" class="cost-wide"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充费用用途或凭据说明" /></el-form-item>
|
||||
<el-form-item v-if="editing" label="更正说明(选填)" class="cost-wide"><el-input v-model="reason" maxlength="1000" placeholder="补充本次更正原因" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="historyOpen" :title="`${historyNo} · 成本历史`" width="1080px" class="admin-density-dialog" :show-confirm="false" cancel-text="关闭">
|
||||
<LoadErrorState v-if="historyError" title="成本历史加载失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState>
|
||||
<DataTable v-model:page="historyPage" :page-size="10" :data="histories" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="recordVersion">
|
||||
<template #time="{ row }">{{ time(row.occurredAt) }}</template>
|
||||
<template #action="{ row }">{{ row.action === 'CREATE' ? '登记' : '更正' }}</template>
|
||||
<template #amount="{ row }">{{ row.snapshot.cost.details.currency }} {{ row.snapshot.cost.details.amount.toFixed(2) }}</template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.cost-note{color:var(--cc-muted);margin:0 0 16px}.cost-summary{display:grid;gap:12px;margin:0 0 20px}.cost-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px}.cost-wide{grid-column:1/-1}.cost-form :deep(.el-select),.cost-form :deep(.el-input-number),.cost-form :deep(.el-date-editor){width:100%}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripHandoverPanel from './TripHandoverPanel.vue'
|
||||
import * as api from '../api/tripHandovers'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const access = vi.hoisted(() => ({ read: true, write: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') ? access.read : access.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/tripHandovers', async original => ({ ...(await original<typeof api>()), listTripHandovers: vi.fn(), previewTripHandover: vi.fn(), confirmTripHandover: vi.fn(), releaseHandoverResource: vi.fn() }))
|
||||
vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() }))
|
||||
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() }))
|
||||
const date = '2026-10-03T01:00:00Z'
|
||||
const detail: TripDetail = { trip: { tripNo: 'TR-ONE', status: 'IN_TRANSIT', warehouseId: '1', warehouseCode: 'WH', warehouseName: '海外仓', vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone', maxPalletSpaces: 2, maxLoadKg: 200, maxVolumeCbm: 4, plannedDepartureAt: date, expectedDriverEndAt: '2026-10-04T01:00:00Z', departedAt: date, completedAt: null, stops: [], remark: null, recordVersion: 7, updatedAt: date, vehicleRelease: null, driverRelease: null }, driverQualification: null, dispatches: [], loadingReview: { status: 'APPROVED', reason: null, reviewerId: null, reviewedAt: date, recordVersion: 7 } }
|
||||
const previous = { vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone' }
|
||||
const replacement = { vehicleId: '12', vehiclePlate: 'NEW-PLATE', driverId: '22', driverName: '接替司机', driverPhone: 'phone' }
|
||||
const load = { palletSpaces: 1, weightKg: 100, volumeCbm: 2 }
|
||||
const cargo = { remainingCargo: [{ customerId: 'secret-customer-id', dispatchNo: 'PC-A', itemId: 'secret-item-id', palletId: 'secret-pallet-id', destinationKey: 'private-hash', packages: 10 }], completedDeliveries: [], remainingPalletIds: ['secret-pallet-id'], occupied: load }
|
||||
const preview: api.HandoverPreview = { tripNo: 'TR-ONE', recordVersion: 7, cargoHash: 'a'.repeat(64), previous, replacement, capacity: load, cargo, driverQualification: { canDispatch: true, expiringSoon: true, reason: 'ELIGIBLE', licenseExpiresOn: '2026-10-15' } }
|
||||
const record: api.HandoverRecord = { handoverNo: 'HO-ONE', tripNo: 'TR-ONE', recordVersion: 0, snapshot: { previous, replacement, handedOverAt: date, location: '实际交接点', reason: '交接', expectedDriverEndAt: date, capacity: load, cargo, recordedAt: date }, vehicleRelease: null, driverRelease: null }
|
||||
const wrappers: VueWrapper[] = []
|
||||
const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
|
||||
function mount() {
|
||||
const wrapper = shallowMount(TripHandoverPanel, { props: { detail: structuredClone(detail) }, global: { stubs: {
|
||||
ElButton: { template: '<button><slot/></button>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
|
||||
ElInput: { ...input, name: 'ElInput' }, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: input,
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.handoverNo"><slot name="actions" :row="row"/></div></div>' },
|
||||
RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '<div><button v-for="a in actions" :key="a.key" @click="$emit(\'action\', a.key)">{{ a.label }}</button></div>' },
|
||||
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
const button = (w: VueWrapper, text: string) => w.findAll('button').find(row => row.text() === text)!
|
||||
beforeEach(() => { vi.clearAllMocks(); access.read = true; access.write = true
|
||||
vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [record], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(api.previewTripHandover).mockResolvedValue(preview); vi.mocked(api.confirmTripHandover).mockResolvedValue(record)
|
||||
})
|
||||
afterEach(() => wrappers.splice(0).forEach(row => row.unmount()))
|
||||
it('only loads one page and does not expose internal IDs or write controls without permission', async () => {
|
||||
access.write = false; const w = mount(); await flushPromises()
|
||||
expect(api.listTripHandovers).toHaveBeenCalledExactlyOnceWith('TR-ONE', 1, 10)
|
||||
expect(api.previewTripHandover).not.toHaveBeenCalled(); expect(w.text()).not.toContain('核对交接货物')
|
||||
expect(w.text()).not.toContain('结束原车辆任务'); expect(w.text()).not.toContain('secret-')
|
||||
})
|
||||
it('does not request data without list permission', async () => {
|
||||
access.read = false; const w = mount(); await flushPromises()
|
||||
expect(api.listTripHandovers).not.toHaveBeenCalled(); expect(w.text()).toContain('暂无车次交接查看权限')
|
||||
})
|
||||
it('invalidates preview when replacement or trip version changes', async () => {
|
||||
const w = mount(); await flushPromises(); await button(w, '核对交接货物').trigger('click'); await flushPromises()
|
||||
expect(w.text()).toContain('PC-A'); expect(w.text()).not.toContain('secret-'); expect(w.text()).not.toContain('private-hash')
|
||||
await w.setProps({ detail: { ...detail, trip: { ...detail.trip, recordVersion: 8 } } }); await flushPromises()
|
||||
expect(w.text()).not.toContain('确认实际交接')
|
||||
})
|
||||
it('retries exactly the original request after an unknown confirmation response', async () => {
|
||||
vi.mocked(api.confirmTripHandover).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(record)
|
||||
const w = mount(); await flushPromises()
|
||||
w.findAllComponents({ name: 'ElInput' })[0]!.vm.$emit('update:modelValue', '实际交接点')
|
||||
await button(w, '核对交接货物').trigger('click'); await flushPromises()
|
||||
await button(w, '确认实际交接').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.confirmTripHandover).mock.calls[0]
|
||||
expect(w.text()).toContain('上次交接结果尚待确认')
|
||||
await button(w, '重试原交接请求').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.confirmTripHandover).mock.calls[1]).toEqual(first)
|
||||
expect(w.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
it('ignores old trip response after switching scope', async () => {
|
||||
let complete!: (value: Awaited<ReturnType<typeof api.listTripHandovers>>) => void
|
||||
vi.mocked(api.listTripHandovers).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
|
||||
const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, tripNo: 'TR-TWO' } } }); await flushPromises()
|
||||
complete({ records: [{ ...record, handoverNo: 'OLD-RESULT' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(w.findComponent({ name: 'DataTable' }).props('data')[0].handoverNo).toBe('HO-ONE')
|
||||
})
|
||||
it('retains the original old-driver release request when its response is unknown', async () => {
|
||||
vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new Error('unknown response')).mockResolvedValueOnce({ ...record, recordVersion: 1, driverRelease: { actualEndAt: date, location: '结束地点', vehicleStatus: null } })
|
||||
const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises()
|
||||
await button(w, '结束原司机任务').trigger('click'); await flushPromises()
|
||||
w.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', '结束地点')
|
||||
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.releaseHandoverResource).mock.calls[0]
|
||||
expect(first?.[2].resourceKind).toBe('DRIVER'); expect(first?.[2].vehicleStatus).toBeUndefined()
|
||||
expect(w.text()).toContain('上次任务结束结果尚待确认')
|
||||
await button(w, '重试原任务结束请求').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]).toEqual(first)
|
||||
})
|
||||
it('reloads the handover version after a definite release conflict', async () => {
|
||||
vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new ApiResponseError('交接记录已变化', 'A0409')).mockResolvedValueOnce({ ...record, recordVersion: 2 })
|
||||
const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises()
|
||||
await button(w, '结束原司机任务').trigger('click'); await flushPromises()
|
||||
w.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', '结束地点')
|
||||
vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [{ ...record, recordVersion: 1 }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
|
||||
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].recordVersion).toBe(1)
|
||||
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].idempotencyKey).not.toBe(vi.mocked(api.releaseHandoverResource).mock.calls[0]?.[2].idempotencyKey)
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { confirmTripHandover, previewTripHandover, listTripHandovers, releaseHandoverResource, handoverNumber, handoverVersion,
|
||||
type HandoverCommand, type HandoverPreview, type HandoverRecord, type HandoverRelease, type HandoverSelection } from '../api/tripHandovers'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { DriverDetail } from '../driverTypes'
|
||||
|
||||
const props = defineProps<{ detail: TripDetail }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const readable = computed(() => access.can('platform:fleet:trip:list'))
|
||||
const writable = computed(() => readable.value && access.can('platform:fleet:trip:handover'))
|
||||
const releasable = computed(() => readable.value && access.can('platform:fleet:trip:release'))
|
||||
const rows = ref<HandoverRecord[]>([]), page = ref(1), size = ref(10), total = ref(0), loading = ref(false), busy = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), commandError = ref<ApiErrorDetails | null>(null)
|
||||
const preview = ref<HandoverPreview | null>(null), selected = ref<HandoverRecord | null>(null)
|
||||
const pending = ref<HandoverCommand | null>(null), pendingRelease = ref<{ number: string; command: HandoverRelease } | null>(null)
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||||
const vehicleLoading = ref(false), driverLoading = ref(false)
|
||||
const form = reactive({ vehicleId: '', driverId: '', handedOverAt: '', expectedDriverEndAt: '', location: '', reason: '' })
|
||||
const ending = ref<'VEHICLE' | 'DRIVER' | null>(null)
|
||||
const endForm = reactive({ actualEndAt: '', location: '', vehicleStatus: 'IN_SERVICE' })
|
||||
let scope = 0, query = 0, vehicleQuery = 0, driverQuery = 0, previewQuery = 0
|
||||
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'handoverNo', prop: 'handoverNo', label: '交接编号', minWidth: 220 },
|
||||
{ key: 'time', label: '实际交接时间', minWidth: 180, slotName: 'time' },
|
||||
{ key: 'vehicles', label: '交接前后车辆', minWidth: 200, slotName: 'vehicles' },
|
||||
{ key: 'drivers', label: '交接前后司机', minWidth: 180, slotName: 'drivers' },
|
||||
{ key: 'location', label: '交接地点', minWidth: 200, slotName: 'location' },
|
||||
]
|
||||
function canEnd(row: HandoverRecord, kind: 'VEHICLE' | 'DRIVER') {
|
||||
const vehicle = kind === 'VEHICLE', before = row.snapshot.previous, after = row.snapshot.replacement
|
||||
return releasable.value && (vehicle ? before.vehicleId !== after.vehicleId && !row.vehicleRelease && before.vehicleId !== props.detail.trip.vehicleId
|
||||
: before.driverId !== after.driverId && !row.driverRelease && before.driverId !== props.detail.trip.driverId)
|
||||
}
|
||||
function actions(row: HandoverRecord): RowActionItem[] {
|
||||
return [{ key: 'detail', label: '查看交接' }, ...(canEnd(row, 'VEHICLE') ? [{ key: 'VEHICLE', label: '结束原车辆任务' }] : []),
|
||||
...(canEnd(row, 'DRIVER') ? [{ key: 'DRIVER', label: '结束原司机任务' }] : [])]
|
||||
}
|
||||
function show(row: HandoverRecord, action: string) {
|
||||
if (busy.value || pending.value || pendingRelease.value) return
|
||||
selected.value = row; ending.value = action === 'VEHICLE' || action === 'DRIVER' ? action : null
|
||||
endForm.actualEndAt = new Date().toISOString(); endForm.location = ''; endForm.vehicleStatus = 'IN_SERVICE'; commandError.value = null
|
||||
}
|
||||
async function load() {
|
||||
const token = ++query
|
||||
if (!readable.value) { rows.value = []; total.value = 0; return }
|
||||
loading.value = true; error.value = null
|
||||
try { const result = await listTripHandovers(props.detail.trip.tripNo, page.value, size.value); if (token === query) { rows.value = result.records; total.value = result.total } }
|
||||
catch (cause) { if (token === query) error.value = getApiErrorDetails(cause, '交接记录加载失败') }
|
||||
finally { if (token === query) loading.value = false }
|
||||
}
|
||||
watch(() => props.detail.trip.tripNo, () => {
|
||||
scope++; query++; previewQuery++; vehicleQuery++; driverQuery++; pending.value = null; pendingRelease.value = null
|
||||
busy.value = false; preview.value = null; selected.value = null; ending.value = null; commandError.value = null
|
||||
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
|
||||
Object.assign(form, { vehicleId: props.detail.trip.vehicleId || '', driverId: props.detail.trip.driverId || '', handedOverAt: new Date().toISOString(), expectedDriverEndAt: props.detail.trip.expectedDriverEndAt, location: '', reason: '' })
|
||||
page.value = 1
|
||||
}, { immediate: true })
|
||||
watch([() => props.detail.trip.tripNo, page, size, readable], load, { immediate: true })
|
||||
watch([() => props.detail.trip.recordVersion, () => form.vehicleId, () => form.driverId, () => form.handedOverAt, () => form.expectedDriverEndAt], () => { previewQuery++; preview.value = null })
|
||||
async function searchVehicles(keyword = '') {
|
||||
const token = ++vehicleQuery; vehicleLoading.value = true
|
||||
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (token === vehicleQuery) vehicles.value = result.records }
|
||||
catch (cause) { if (token === vehicleQuery) commandError.value = getApiErrorDetails(cause, '车辆候选读取失败') }
|
||||
finally { if (token === vehicleQuery) vehicleLoading.value = false }
|
||||
}
|
||||
async function searchDrivers(keyword = '') {
|
||||
const token = ++driverQuery; driverLoading.value = true
|
||||
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: form.expectedDriverEndAt }); if (token === driverQuery) drivers.value = result.records }
|
||||
catch (cause) { if (token === driverQuery) commandError.value = getApiErrorDetails(cause, '司机候选读取失败') }
|
||||
finally { if (token === driverQuery) driverLoading.value = false }
|
||||
}
|
||||
function selection(): HandoverSelection {
|
||||
return { recordVersion: props.detail.trip.recordVersion, vehicleId: form.vehicleId, driverId: form.driverId,
|
||||
handedOverAt: new Date(form.handedOverAt).toISOString(), expectedDriverEndAt: new Date(form.expectedDriverEndAt).toISOString() }
|
||||
}
|
||||
async function inspect() {
|
||||
if (busy.value || pending.value || !writable.value) return
|
||||
if (!form.vehicleId || !form.driverId || !form.handedOverAt || !form.expectedDriverEndAt) { ElMessage.warning('请选择接替资源并填写交接及预计任务结束时间'); return }
|
||||
const token = ++previewQuery, generation = scope; busy.value = true; commandError.value = null; preview.value = null
|
||||
try { const result = await previewTripHandover(props.detail.trip.tripNo, selection()); if (token === previewQuery && generation === scope) preview.value = result }
|
||||
catch (cause) { if (generation === scope) commandError.value = getApiErrorDetails(cause, '交接核对未通过') }
|
||||
finally { if (generation === scope) busy.value = false }
|
||||
}
|
||||
async function confirm() {
|
||||
if (busy.value || !writable.value || (!pending.value && !preview.value)) return
|
||||
if (!pending.value && !form.location.trim()) { ElMessage.warning('请填写实际交接地点'); return }
|
||||
const generation = scope; busy.value = true; commandError.value = null
|
||||
try {
|
||||
pending.value ??= { selection: selection(), cargoHash: preview.value!.cargoHash, location: form.location.trim(), reason: form.reason.trim() || undefined, idempotencyKey: crypto.randomUUID() }
|
||||
const result = await confirmTripHandover(props.detail.trip.tripNo, pending.value)
|
||||
if (generation !== scope) return
|
||||
pending.value = null; preview.value = null; selected.value = result; emit('saved'); ElMessage.success('交接已记录,请分别核对原资源任务结束')
|
||||
await load()
|
||||
} catch (cause) { if (generation === scope) { if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pending.value = null; preview.value = null } commandError.value = getApiErrorDetails(cause, '交接未确认,请核对或重试') } }
|
||||
finally { if (generation === scope) busy.value = false }
|
||||
}
|
||||
async function release() {
|
||||
if (busy.value || !releasable.value || (!pendingRelease.value && (!selected.value || !ending.value))) return
|
||||
if (!pendingRelease.value && (!endForm.actualEndAt || !endForm.location.trim())) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
|
||||
const generation = scope; busy.value = true; commandError.value = null
|
||||
try {
|
||||
pendingRelease.value ??= { number: selected.value!.handoverNo, command: { recordVersion: handoverVersion(selected.value!.recordVersion), resourceKind: ending.value!, actualEndAt: new Date(endForm.actualEndAt).toISOString(), location: endForm.location.trim(), vehicleStatus: ending.value === 'VEHICLE' ? endForm.vehicleStatus : undefined, idempotencyKey: crypto.randomUUID() } }
|
||||
const result = await releaseHandoverResource(props.detail.trip.tripNo, pendingRelease.value.number, pendingRelease.value.command)
|
||||
if (generation !== scope) return
|
||||
pendingRelease.value = null; selected.value = result; ending.value = null; emit('saved'); ElMessage.success('原资源任务已结束'); await load()
|
||||
} catch (cause) {
|
||||
if (generation === scope) {
|
||||
commandError.value = getApiErrorDetails(cause, '任务结束未确认,请核对或重试')
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) {
|
||||
pendingRelease.value = null
|
||||
const number = selected.value?.handoverNo
|
||||
await load()
|
||||
if (generation === scope) {
|
||||
selected.value = rows.value.find(row => row.handoverNo === number) || null
|
||||
if (!selected.value) ending.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { if (generation === scope) busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driverQuery++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="在途交接" class="handover-panel">
|
||||
<p v-if="!readable">暂无车次交接查看权限。</p>
|
||||
<template v-else>
|
||||
<p>交接后原车辆和司机分别结束实际任务。已经签收或完成退回的货物保留原运输记录。</p>
|
||||
<LoadErrorState v-if="error" title="交接记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="writable && (detail.trip.status === 'IN_TRANSIT' || pending)">
|
||||
<p v-if="pending">上次交接结果尚待确认;重试使用原请求及原内容。</p>
|
||||
<el-form label-position="top" :disabled="busy || !!pending || !!pendingRelease" class="density-form handover-form">
|
||||
<el-form-item label="接替车辆" required><el-select v-model="form.vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" @focus="searchVehicles()">
|
||||
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="vehicle.plateNumber" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="接替司机" required><el-select v-model="form.driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" @focus="searchDrivers()">
|
||||
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="实际交接时间" required><el-date-picker v-model="form.handedOverAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="实际交接地点" required><el-input v-model="form.location" maxlength="500" placeholder="请输入实际交接地点" /></el-form-item>
|
||||
<el-form-item label="交接说明(选填)"><el-input v-model="form.reason" maxlength="1000" placeholder="如车辆故障或司机替班(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button :loading="busy" :disabled="!!pending || !!pendingRelease" @click="inspect">核对交接货物</el-button>
|
||||
<el-button :disabled="busy || !!pending || !!pendingRelease" @click="emit('saved')">刷新车次</el-button>
|
||||
<template v-if="preview">
|
||||
<p v-if="preview.driverQualification.expiringSoon">驾驶证将在30天内到期,当前预计任务结束仍在有效期内。</p>
|
||||
<p>本次交接 {{ preview.cargo.remainingPalletIds.length }} 个实托,占 {{ handoverNumber(preview.cargo.occupied.palletSpaces) }} 托位、{{ handoverNumber(preview.cargo.occupied.weightKg) }} kg、{{ handoverNumber(preview.cargo.occupied.volumeCbm) }} m³。</p>
|
||||
<table class="density-table"><thead><tr><th>客户排车单</th><th>交接箱数</th></tr></thead><tbody><tr v-for="(cargo, index) in preview.cargo.remainingCargo" :key="index"><td>{{ cargo.dispatchNo }}</td><td>{{ cargo.packages }}</td></tr></tbody></table>
|
||||
</template>
|
||||
<el-button v-if="preview || pending" type="primary" :loading="busy" @click="confirm">{{ pending ? '重试原交接请求' : '确认实际交接' }}</el-button>
|
||||
</div>
|
||||
<div v-if="commandError" role="alert"><p>{{ commandError.message }}</p><ApiErrorMeta :details="commandError" /></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="handoverNo" empty-text="暂无在途交接记录">
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as HandoverRecord)" @action="show(row as HandoverRecord, $event)" /></template>
|
||||
<template #time="{ row }">{{ time(row.snapshot.handedOverAt) }}</template>
|
||||
<template #vehicles="{ row }">{{ row.snapshot.previous.vehiclePlate || '—' }} → {{ row.snapshot.replacement.vehiclePlate || '—' }}</template>
|
||||
<template #drivers="{ row }">{{ row.snapshot.previous.driverName || '—' }} → {{ row.snapshot.replacement.driverName || '—' }}</template>
|
||||
<template #location="{ row }">{{ row.snapshot.location }}</template>
|
||||
</DataTable>
|
||||
<div v-if="selected" class="handover-detail">
|
||||
<h4>交接记录 {{ selected.handoverNo }}</h4>
|
||||
<p>{{ selected.snapshot.reason }} · {{ selected.snapshot.location }}</p>
|
||||
<p>原车辆任务:{{ selected.snapshot.previous.vehicleId === selected.snapshot.replacement.vehicleId ? '车辆继续执行' : selected.vehicleRelease ? `已结束 · ${time(selected.vehicleRelease.actualEndAt)} · ${selected.vehicleRelease.location}` : '尚未结束' }}</p>
|
||||
<p>原司机任务:{{ selected.snapshot.previous.driverId === selected.snapshot.replacement.driverId ? '司机继续执行' : selected.driverRelease ? `已结束 · ${time(selected.driverRelease.actualEndAt)} · ${selected.driverRelease.location}` : '尚未结束' }}</p>
|
||||
<p>交接前已完成派送:{{ selected.snapshot.cargo.completedDeliveries.map(row => row.deliveryNo).join('、') || '—' }}</p>
|
||||
<template v-if="ending || pendingRelease">
|
||||
<p v-if="pendingRelease">上次任务结束结果尚待确认;重试不会释放后续车次占用。</p>
|
||||
<el-form label-position="top" class="density-form handover-form" :disabled="busy || !!pendingRelease">
|
||||
<el-form-item label="实际任务结束时间" required><el-date-picker v-model="endForm.actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="实际任务结束地点" required><el-input v-model="endForm.location" maxlength="500" placeholder="请输入原车辆或司机实际结束任务的地点" /></el-form-item>
|
||||
<el-form-item v-if="ending === 'VEHICLE'" label="车辆实际状态" required><el-select v-model="endForm.vehicleStatus"><el-option value="IN_SERVICE" label="在役" /><el-option value="MAINTENANCE" label="维修中" /><el-option value="INACTIVE" label="停用" /><el-option value="RETURNED" label="已归还" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<el-button v-if="releasable" type="primary" :loading="busy" @click="release">{{ pendingRelease ? '重试原任务结束请求' : '确认原资源任务结束' }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.handover-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}
|
||||
.handover-form :deep(.el-select){width:100%}.handover-detail{margin-top:var(--admin-space-6)}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TripOperationsCharts, TripOperationsSeries } from '../api/tripOperations'
|
||||
const props = defineProps<{ charts: TripOperationsCharts }>()
|
||||
const sections = computed(() => [
|
||||
{ title: '实运托盘', note: '按实际发车日(UTC)汇总,物理托盘去重', series: props.charts.physicalPallets },
|
||||
{ title: '派送目的地', note: '各目的地涉及的车次,同车多站可分别出现', series: props.charts.destinationTrips },
|
||||
{ title: '外接约定金额', note: '按币种分别展示,不代表财务应收或实收', series: props.charts.externalAgreedAmounts },
|
||||
{ title: '外接按托报价均价', note: '按确认计价托位加权,排除整单报价', series: props.charts.externalPalletRates },
|
||||
])
|
||||
function width(value: number, series: TripOperationsSeries) { const max = Math.max(...series.points.map(p => p.value), 0); return max ? value / max * 100 : 0 }
|
||||
</script>
|
||||
<template><section class="operations-charts" aria-label="出车数据统计"><article v-for="section in sections" :key="section.title" class="chart">
|
||||
<strong>{{ section.title }}</strong><p>{{ section.note }}</p>
|
||||
<p v-if="!section.series.some(s => s.points.length)">暂无对应统计</p>
|
||||
<div class="chart-values"><div v-for="(series, si) in section.series" :key="`${series.label}-${si}`"><b>{{ series.label }}({{ series.unit }})</b>
|
||||
<div v-for="(point, pi) in series.points" :key="pi" class="point"><span>{{ point.label }}</span><div class="bar-track" aria-hidden="true"><div class="bar" :style="{ width: width(point.value, series) + '%' }" /></div><span>{{ point.value }}</span></div>
|
||||
</div></div>
|
||||
</article></section></template>
|
||||
<style scoped>
|
||||
.operations-charts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--admin-space-3); }
|
||||
.chart { padding:var(--admin-space-3); border:1px solid var(--el-border-color); border-radius:var(--admin-radius); background:var(--admin-surface); }
|
||||
.chart p { color:var(--el-text-color-secondary); font-size:var(--admin-font-caption); }
|
||||
.chart-values { max-height:220px; overflow:auto; }
|
||||
.point { display:grid; grid-template-columns:minmax(95px,1fr) minmax(45px,1fr) minmax(40px,auto); gap:var(--admin-space-2); align-items:center; margin:var(--admin-space-2) 0; }
|
||||
.bar-track { height:10px; background:var(--el-fill-color-light); }.bar { height:100%; background:var(--el-color-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripOperationsRecordDialog from './TripOperationsRecordDialog.vue'
|
||||
import * as api from '../api/tripOperations'
|
||||
vi.mock('../api/tripOperations', () => ({ getTripOperationsRecord: vi.fn(), saveTripOperationsRecord: vi.fn(), listTripOperationsRecordHistory: vi.fn() }))
|
||||
const row: api.TripOperationsRow = { tripNo: 'TR1', status: 'IN_TRANSIT', vehiclePlate: 'TEST-1', driverName: '司机', warehouseName: '起运仓', departedAt: '2026-10-01T00:00:00Z', completedAt: null, destinations: [], physicalPallets: 1, palletSpaces: 1, ownPackages: 1, externalPackages: 0, externalAgreedAmounts: [], externalPalletRates: [], measured: null, financeStatus: 'NOT_QUERIED', costStatus: 'FORBIDDEN', costs: null }
|
||||
const record: api.TripOperationsRecord = { tripNo: 'TR1', recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null }, updatedAt: null }
|
||||
const wrappers: VueWrapper[] = []
|
||||
const slot = { template: '<div><slot/></div>' }
|
||||
const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',Number($event.target.value))" />' }
|
||||
async function mount(readonly = false) {
|
||||
const wrapper = shallowMount(TripOperationsRecordDialog, { props: { modelValue: false, row, readonly }, global: { stubs: {
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot/><slot name="footer"/></div>' }, ElForm: slot, ElFormItem: slot,
|
||||
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: slot, ElInputNumber: input,
|
||||
ElInput: { ...input, name: 'ElInput' }, ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' },
|
||||
ElAlert: { props: ['title'], template: '<div>{{ title }}<slot/></div>' }, ApiErrorMeta: true, DataTable: true,
|
||||
} } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises(); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record); vi.mocked(api.listTripOperationsRecordHistory).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it('loads current record on open, history only when selected, and hides writes for readers', async () => {
|
||||
const wrapper = await mount(true)
|
||||
expect(api.getTripOperationsRecord).toHaveBeenCalledExactlyOnceWith('TR1'); expect(api.listTripOperationsRecordHistory).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('保存记录')
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
|
||||
expect(api.listTripOperationsRecordHistory).toHaveBeenCalledExactlyOnceWith('TR1', 1, 20)
|
||||
})
|
||||
it('unknown write retry preserves original key, version and measured values', async () => {
|
||||
const wrapper = await mount(); vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network'))
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 12.5); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
||||
expect(original).toMatchObject({ recordVersion: '0', details: { actualDistanceMiles: 12.5, actualFuelLitres: null } })
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 99)
|
||||
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
||||
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
it('a failed initial read cannot save a guessed record version', async () => {
|
||||
vi.mocked(api.getTripOperationsRecord).mockRejectedValue(new Error('unavailable')); const wrapper = await mount()
|
||||
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeDefined()
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, type TripOperationsRow, type TripOperationsRecord, type TripOperationsRecordHistory, type TripOperationsRecordSave, type TripOperationsMeasured } from '../api/tripOperations'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ row: TripOperationsRow | null; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const form = reactive<TripOperationsMeasured>({ actualDistanceMiles: null, actualFuelLitres: null, remark: null }), reason = ref('')
|
||||
const record = ref<TripOperationsRecord | null>(null), loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), tab = ref('measured')
|
||||
const pending = new Map<string, TripOperationsRecordSave>(), uncertain = ref(false)
|
||||
const history = ref<TripOperationsRecordHistory[]>([]), total = ref(0), page = ref(1), size = ref(20)
|
||||
let sequence = 0
|
||||
const columns: DataTableColumn[] = [{ key: 'recordVersion', label: '版本', width: 90 }, { key: 'occurredAt', label: '更正时间', minWidth: 190 }, { key: 'distance', label: '里程(mile)', width: 130, slotName: 'distance' }, { key: 'fuel', label: '油耗(L)', width: 120, slotName: 'fuel' }, { key: 'reason', label: '更正说明', minWidth: 180 }, { key: 'remark', label: '备注', minWidth: 200, slotName: 'remark' }]
|
||||
async function load() {
|
||||
if (!open.value || !props.row) return
|
||||
const no = props.row.tripNo, current = ++sequence; loading.value = true; error.value = null; record.value = null
|
||||
try { const result = await getTripOperationsRecord(no); if (current !== sequence) return; record.value = result; const retry = pending.get(no); uncertain.value = !!retry; Object.assign(form, retry?.details || result.details); reason.value = retry?.reason || '' }
|
||||
catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '实测记录读取失败') } finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function loadHistory() {
|
||||
if (!open.value || !props.row || tab.value !== 'history') return
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try { const result = await listTripOperationsRecordHistory(props.row.tripNo, page.value, size.value); if (current === sequence) { history.value = result.records; total.value = result.total } } catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '更正历史读取失败') } finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!record.value || !props.row || props.readonly || saving.value) return
|
||||
const no = props.row.tripNo; saving.value = true; error.value = null
|
||||
const request = pending.get(no) || { recordVersion: record.value.recordVersion, idempotencyKey: crypto.randomUUID(), details: { actualDistanceMiles: form.actualDistanceMiles ?? null, actualFuelLitres: form.actualFuelLitres ?? null, remark: form.remark || null }, reason: reason.value || null }
|
||||
pending.set(no, request)
|
||||
try { await saveTripOperationsRecord(no, request); pending.delete(no); uncertain.value = false; open.value = false; emit('saved') }
|
||||
catch (e) { error.value = getApiErrorDetails(e, '实测记录保存失败'); if (e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending.delete(no); uncertain.value = pending.has(no) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
watch([open, () => props.row?.tripNo], () => { sequence++; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
|
||||
watch([tab, page, size], () => { if (tab.value === 'history') void loadHistory() }); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template><AppDialog v-model="open" title="出车实测记录" width="820px" class="admin-density-dialog" :close-on-click-modal="false">
|
||||
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /><el-button @click="tab === 'history' ? loadHistory() : load()">重新读取</el-button></el-alert>
|
||||
<p v-if="row">{{ row.tripNo }} · {{ row.vehiclePlate || '—' }} · 实际发车 {{ row.departedAt }} · 货物结案 {{ row.completedAt || '—' }}</p>
|
||||
<el-tabs v-model="tab" class="density-element-tabs"><el-tab-pane label="实测记录" name="measured">
|
||||
<p v-if="uncertain">上次保存结果未确认,将按原请求重试。</p>
|
||||
<el-form label-position="top" class="density-form record-form" :disabled="readonly || !record || loading || saving || uncertain">
|
||||
<el-form-item label="本趟实际里程(mile)"><el-input-number :model-value="form.actualDistanceMiles" @update:model-value="form.actualDistanceMiles = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际英里数" /></el-form-item>
|
||||
<el-form-item label="本趟实际油耗(L)"><el-input-number :model-value="form.actualFuelLitres" @update:model-value="form.actualFuelLitres = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际用油升数" /></el-form-item>
|
||||
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充实测情况" /></el-form-item>
|
||||
<el-form-item v-if="!readonly" label="更正说明(选填)" class="full"><el-input v-model="reason" maxlength="1000" placeholder="选填,留空使用标准更正说明" /></el-form-item>
|
||||
</el-form><p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p>
|
||||
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template></DataTable></el-tab-pane></el-tabs>
|
||||
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
|
||||
</AppDialog></template>
|
||||
<style scoped>.record-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }</style>
|
||||
@@ -11,7 +11,7 @@ const access = vi.hoisted(() => ({ write: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || access.write }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { vehicleId: '71' } }), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('../api/vehicleCare', () => ({ listVehicleCare: vi.fn(), getVehicleCare: vi.fn(), createVehicleCare: vi.fn(), updateVehicleCare: vi.fn(), uploadVehicleCarePhoto: vi.fn(), downloadVehicleCarePhoto: vi.fn() }))
|
||||
vi.mock('../api/vehicleCare', async original => ({ ...(await original<typeof import('../api/vehicleCare')>()), listVehicleCare: vi.fn(), getVehicleCare: vi.fn(), createVehicleCare: vi.fn(), updateVehicleCare: vi.fn(), uploadVehicleCarePhoto: vi.fn(), downloadVehicleCarePhoto: vi.fn(), listVehicleCareFinance: vi.fn(), submitVehicleCareFinance: vi.fn(), listVehicleCareSuppliers: vi.fn() }))
|
||||
vi.mock('../api/overseasMasterData', () => ({ getVehicle: vi.fn() }))
|
||||
const row: VehicleCareRecord = { recordNo: 'VR-ONE', vehicleId: '71', vehiclePlate: 'VEH-A', recordVersion: 0, details: { recordType: 'FUEL', occurredAt: '2026-01-01T00:00:00Z', distanceUnit: 'MI', odometer: 100, lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL', verificationStatus: 'PENDING', remark: null, incident: null }, files: [], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'operator', paymentStatus: null }
|
||||
const container = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
@@ -27,7 +27,10 @@ function mount(component: object, props: Record<string, unknown>): VueWrapper {
|
||||
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: true, DataTable: table, RowActions: true, VehicleCareTable: component === VehicleCareTable ? false : true, VehicleCareEditor: component === VehicleCareEditor ? false : true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [structuredClone(row)], page: 1, size: 10, total: 1, pages: 1 }) })
|
||||
beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [structuredClone(row)], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(api.listVehicleCareFinance).mockImplementation(async (_vehicle, numbers) => numbers.map(recordNo => ({ recordNo, status: 'UNLINKED', sourceVersion: null, submittedRecordVersion: null, finance: null })))
|
||||
vi.mocked(api.listVehicleCareSuppliers).mockResolvedValue([])
|
||||
})
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('saving an unchanged record does not create a correction due to JSON property order', async () => {
|
||||
const record = structuredClone(row)
|
||||
@@ -146,7 +149,7 @@ it('renders completed rescue and zero reported cost separately from payment and
|
||||
vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [rescue], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'RESCUE' }); await flushPromises()
|
||||
expect(wrapper.get('[data-test="status"]').text()).toBe('已完成')
|
||||
expect(wrapper.get('[data-test="payment"]').text()).toBe('未关联财务')
|
||||
expect(wrapper.get('[data-test="payment"]').text()).toBe('未提交财务')
|
||||
expect(wrapper.get('[data-test="cost"]').text()).toBe('USD 0.00')
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')[0].key).toBe('actions')
|
||||
expect(wrapper.findComponent({ name: 'RowActions' }).props('actions')[0].label).toBe('更新处理')
|
||||
|
||||
@@ -120,7 +120,7 @@ onBeforeUnmount(() => { editSession++ })
|
||||
<el-form-item v-if="costRecord" label="费用(申报金额)"><el-input-number v-model="form.incident.reportedCost" :min="0" :precision="2" placeholder="未确定时留空" /></el-form-item>
|
||||
<el-form-item v-if="costRecord" label="币种" :required="form.incident.reportedCost != null"><el-select v-model="form.incident.currency" filterable allow-create default-first-option clearable placeholder="请选择或输入三位币种"><el-option v-for="currency in ['USD','CNY','CAD','EUR','GBP','MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
|
||||
<el-form-item label="处理状态"><el-select v-model="form.incident.processingStatus"><el-option v-for="(label, key) in processingLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
|
||||
<p v-if="costRecord" class="care-hint full-width">费用仅作运维记录,尚未关联财务付款。处理完成不会标记为已付款。</p>
|
||||
<p v-if="costRecord" class="care-hint full-width">此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。</p>
|
||||
</template>
|
||||
<el-form-item v-if="meterRecord" label="核验状态"><el-select v-model="form.verificationStatus"><el-option label="待核验" value="PENDING" /><el-option label="已核验" value="VERIFIED" /></el-select></el-form-item>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充本次记录的其他情况(选填)" /></el-form-item>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import VehicleCareFinanceLedger from './VehicleCareFinanceLedger.vue'
|
||||
import * as api from '../api/companyVehicleCosts'
|
||||
const access = vi.hoisted(() => ({ permissions: new Set<string>() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/companyVehicleCosts', async original => ({ ...(await original<typeof import('../api/companyVehicleCosts')>()), getCompanyCost: vi.fn(), commandCompanyCost: vi.fn(), payCompanyCost: vi.fn(), listCompanyCostHistory: vi.fn(), listCompanyCostPayments: vi.fn(), uploadCompanyCostVoucher: vi.fn(), downloadCompanyCostVoucher: vi.fn() }))
|
||||
const detail: api.CompanyCost = { costNo: 'VC-ONE', subjectCode: 'PLATFORM_DEFAULT', supplierName: '维修供应商', currency: 'CNY', status: 'APPROVED', recordVersion: '2', paidAmount: 0, outstandingAmount: 70,
|
||||
source: { sourceType: 'REPAIR', sourceNo: 'VR-ONE', sourceVersion: '1', vehicleId: '71', vehiclePlate: 'VEH-A', tripNo: null, supplierId: '8', feeName: '维修', businessDate: '2026-01-01', originalCurrency: 'USD', originalAmount: 10, exchangeRate: 7, exchangeRateMonth: '2026-01', amount: 70, remark: null } }
|
||||
const empty = { records: [], page: 1, size: 10, total: 0, pages: 0 }
|
||||
const voucher = { fileId: '11', fileName: 'payment.png', contentType: 'image/png', sizeBytes: 3 }
|
||||
const wrappers: VueWrapper[] = []
|
||||
const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
|
||||
function mount() {
|
||||
const w = shallowMount(VehicleCareFinanceLedger, { props: { costNo: 'VC-ONE' }, global: { stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
|
||||
ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input,
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total', 'page'], emits: ['update:page'], template: '<div/>' },
|
||||
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(w); return w
|
||||
}
|
||||
const button = (w: VueWrapper, label: string) => w.findAll('button').find(value => value.text() === label)!
|
||||
async function files(w: VueWrapper, names = ['payment.png']) {
|
||||
const element = w.get('input[type="file"]')
|
||||
Object.defineProperty(element.element, 'files', { configurable: true, value: names.map(name => new File(['png'], name, { type: 'image/png' })) })
|
||||
await element.trigger('change'); await flushPromises()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); access.permissions = new Set(Object.values(api.companyCostPermissions))
|
||||
vi.mocked(api.getCompanyCost).mockResolvedValue(structuredClone(detail))
|
||||
vi.mocked(api.commandCompanyCost).mockResolvedValue({ ...detail, status: 'VOIDED', outstandingAmount: null, recordVersion: '3' })
|
||||
vi.mocked(api.payCompanyCost).mockResolvedValue({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' })
|
||||
vi.mocked(api.listCompanyCostHistory).mockResolvedValue(empty); vi.mocked(api.listCompanyCostPayments).mockResolvedValue(empty)
|
||||
vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValue(voucher)
|
||||
})
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it('requests detail once and reads separately paged history only on demand', async () => {
|
||||
const w = mount(); await flushPromises()
|
||||
expect(api.getCompanyCost).toHaveBeenCalledExactlyOnceWith('VC-ONE')
|
||||
expect(api.listCompanyCostHistory).not.toHaveBeenCalled(); expect(api.listCompanyCostPayments).not.toHaveBeenCalled()
|
||||
await button(w, '操作历史').trigger('click'); await flushPromises()
|
||||
expect(api.listCompanyCostHistory).toHaveBeenCalledExactlyOnceWith('VC-ONE', 1)
|
||||
w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(api.listCompanyCostHistory).toHaveBeenLastCalledWith('VC-ONE', 2)
|
||||
await button(w, '付款流水').trigger('click'); await flushPromises()
|
||||
expect(api.listCompanyCostPayments).toHaveBeenCalledExactlyOnceWith('VC-ONE', 1)
|
||||
})
|
||||
it('has no requests without Finance list permission and does not give readers write buttons', async () => {
|
||||
access.permissions.clear(); const denied = mount(); await flushPromises(); expect(api.getCompanyCost).not.toHaveBeenCalled()
|
||||
expect(denied.text()).toContain('暂无公司成本财务查询权限')
|
||||
access.permissions.add(api.companyCostPermissions.list); const w = mount(); await flushPromises()
|
||||
expect(button(w, '登记付款')).toBeUndefined(); expect(button(w, '作废费用')).toBeUndefined(); expect(button(w, '确认入账')).toBeUndefined()
|
||||
})
|
||||
it('keeps one immutable payment request and key after an unknown response', async () => {
|
||||
vi.mocked(api.payCompanyCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' })
|
||||
const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20); await files(w)
|
||||
await button(w, '确认操作').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.payCompanyCost).mock.calls[0]!
|
||||
expect(first).toEqual(['VC-ONE', expect.any(String), expect.objectContaining({ expectedVersion: '2', amount: 20, voucherFileIds: ['11'] })])
|
||||
expect(w.text()).toContain('上次操作结果尚未确认')
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 60)
|
||||
await button(w, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.payCompanyCost).mock.calls[1]).toEqual(first)
|
||||
expect(w.text()).toContain('部分付款'); expect(w.text()).toContain('未付 CNY 50.00')
|
||||
expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1); expect(w.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
it('preserves successfully uploaded vouchers and resumes remaining uploads without paying', async () => {
|
||||
vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValueOnce(voucher).mockRejectedValueOnce(new Error('upload failed'))
|
||||
.mockResolvedValueOnce({ ...voucher, fileId: '12', fileName: 'second.png' })
|
||||
const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
|
||||
await files(w, ['payment.png', 'second.png'])
|
||||
expect(w.text()).toContain('已上传:payment.png'); expect(w.text()).toContain('1 张凭证待完成上传')
|
||||
expect(api.payCompanyCost).not.toHaveBeenCalled()
|
||||
await button(w, '重试剩余凭证').trigger('click'); await flushPromises()
|
||||
expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(3)
|
||||
expect((vi.mocked(api.uploadCompanyCostVoucher).mock.calls[2]![1]).name).toBe('second.png')
|
||||
expect(w.text()).toContain('已上传:second.png'); expect(api.payCompanyCost).not.toHaveBeenCalled()
|
||||
})
|
||||
it('refuses zero, excess or unproven payments locally and sends void only after explicit confirmation', async () => {
|
||||
const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 0); await button(w, '确认操作').trigger('click'); await flushPromises()
|
||||
await files(w); w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 71)
|
||||
await button(w, '确认操作').trigger('click'); await flushPromises(); expect(api.payCompanyCost).not.toHaveBeenCalled()
|
||||
await button(w, '取消操作').trigger('click'); await button(w, '作废费用').trigger('click'); await flushPromises()
|
||||
expect(api.commandCompanyCost).not.toHaveBeenCalled()
|
||||
await button(w, '确认操作').trigger('click'); await flushPromises()
|
||||
expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'void', expect.any(String), { expectedVersion: '2', reason: null })
|
||||
expect(w.text()).toContain('已作废')
|
||||
})
|
||||
it('cannot void or pay again when the server says paid, and booking is an independent action', async () => {
|
||||
vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, status: 'PAID', paidAmount: 70, outstandingAmount: 0 })
|
||||
const paid = mount(); await flushPromises(); expect(button(paid, '作废费用')).toBeUndefined(); expect(button(paid, '登记付款')).toBeUndefined()
|
||||
vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, status: 'PENDING', recordVersion: '0', outstandingAmount: null })
|
||||
const pending = mount(); await flushPromises(); await button(pending, '确认入账').trigger('click'); await button(pending, '确认操作').trigger('click'); await flushPromises()
|
||||
expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'approve', expect.any(String), { expectedVersion: '0', reason: null })
|
||||
expect(api.payCompanyCost).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { companyCostPermissions as permissions, getCompanyCost, commandCompanyCost, payCompanyCost,
|
||||
listCompanyCostHistory, listCompanyCostPayments, uploadCompanyCostVoucher, downloadCompanyCostVoucher,
|
||||
type CompanyCost, type CompanyCostCommand, type CompanyCostPayment, type CompanyCostHistory, type CompanyCostPaid, type CompanyCostVoucher } from '../api/companyVehicleCosts'
|
||||
|
||||
const props = withDefaults(defineProps<{ costNo: string; active?: boolean }>(), { active: true })
|
||||
const emit = defineEmits<{ changed: []; busy: [value: boolean] }>()
|
||||
const access = useAccessPreviewStore(), allowed = computed(() => access.can(permissions.list))
|
||||
const cost = ref<CompanyCost | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const action = ref<'approve' | 'void' | 'pay' | null>(null), saving = ref(false), uploading = ref(false), writeError = ref<ApiErrorDetails | null>(null)
|
||||
const reason = ref(''), vouchers = ref<CompanyCostVoucher[]>([]), pendingFiles = ref<File[]>([])
|
||||
const form = reactive<{ amount: number | null; paidAt: string; method: CompanyCostPayment['method']; referenceNo: string; remark: string }>({ amount: null, paidAt: '', method: 'BANK_TRANSFER', referenceNo: '', remark: '' })
|
||||
type Pending = { action: 'approve' | 'void'; key: string; request: CompanyCostCommand } | { action: 'pay'; key: string; request: CompanyCostPayment }
|
||||
const pending = ref<Pending | null>(null)
|
||||
const tab = ref<'current' | 'history' | 'payments'>('current'), historyPage = ref(1), paymentPage = ref(1)
|
||||
const history = ref<CompanyCostHistory[]>([]), payments = ref<CompanyCostPaid[]>([]), historyTotal = ref(0), paymentTotal = ref(0)
|
||||
const recordsLoading = ref(false), recordsError = ref<ApiErrorDetails | null>(null)
|
||||
let epoch = 0, readRequest = 0, recordsRequest = 0, disposed = false
|
||||
const previews = new Set<{ target: Window; url: string; timer: ReturnType<typeof setInterval> }>()
|
||||
const busy = computed(() => saving.value || uploading.value || !!pending.value)
|
||||
watch(busy, value => emit('busy', value))
|
||||
const canApprove = computed(() => allowed.value && access.can(permissions.approve) && cost.value?.status === 'PENDING')
|
||||
const canVoid = computed(() => allowed.value && access.can(permissions.void) && !!cost.value && ['PENDING', 'APPROVED'].includes(cost.value.status) && cost.value.paidAmount === 0)
|
||||
const canPay = computed(() => allowed.value && access.can(permissions.pay) && !!cost.value && ['APPROVED', 'PARTIALLY_PAID'].includes(cost.value.status) && (cost.value.outstandingAmount ?? 0) > 0)
|
||||
const statusLabels = { PENDING: '待财务入账', APPROVED: '已入账 · 未付款', PARTIALLY_PAID: '部分付款', PAID: '已付款', VOIDED: '已作废' }
|
||||
const methods = { BANK_TRANSFER: '银行转账', ONLINE: '线上支付', CASH: '现金', OTHER: '其他' }
|
||||
const money = (value: number | null) => value == null ? '未形成应付' : `CNY ${value.toFixed(2)}`
|
||||
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'action', label: '操作', width: 90, slotName: 'action' }, { key: 'occurredAt', label: '时间', minWidth: 170, slotName: 'time' },
|
||||
{ key: 'operatorName', prop: 'operatorName', label: '操作人', minWidth: 100 }, { key: 'amount', label: '当时金额', minWidth: 130, slotName: 'amount' },
|
||||
{ key: 'reason', prop: 'reason', label: '说明', minWidth: 160 },
|
||||
]
|
||||
const paymentColumns: DataTableColumn[] = [
|
||||
{ key: 'paymentNo', prop: 'paymentNo', label: '付款编号', minWidth: 170 }, { key: 'amount', label: '已付金额', minWidth: 130, slotName: 'amount' },
|
||||
{ key: 'paidAt', label: '实际付款时间', minWidth: 170, slotName: 'time' }, { key: 'method', label: '方式', width: 100, slotName: 'method' },
|
||||
{ key: 'referenceNo', prop: 'referenceNo', label: '流水号', minWidth: 150 }, { key: 'files', label: '付款凭证', minWidth: 130, slotName: 'files' },
|
||||
]
|
||||
async function load() {
|
||||
const request = ++readRequest, scope = epoch; error.value = null; cost.value = null
|
||||
if (!allowed.value) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try { const value = await getCompanyCost(props.costNo); if (!disposed && scope === epoch && request === readRequest) cost.value = value }
|
||||
catch (cause) { if (!disposed && scope === epoch && request === readRequest) error.value = getApiErrorDetails(cause, '公司费用读取失败') }
|
||||
finally { if (scope === epoch && request === readRequest) loading.value = false }
|
||||
}
|
||||
watch([() => props.costNo, allowed], () => {
|
||||
epoch++; readRequest++; recordsRequest++; pending.value = null; action.value = null; saving.value = false; uploading.value = false
|
||||
vouchers.value = []; pendingFiles.value = []; history.value = []; payments.value = []; tab.value = 'current'; historyPage.value = 1; paymentPage.value = 1
|
||||
writeError.value = null; recordsError.value = null; void load()
|
||||
}, { immediate: true })
|
||||
watch(() => props.active, value => { if (value && !busy.value) void load() })
|
||||
function choose(next: 'approve' | 'void' | 'pay') {
|
||||
if (busy.value || !(next === 'approve' ? canApprove.value : next === 'void' ? canVoid.value : canPay.value)) return
|
||||
action.value = next; tab.value = 'current'; writeError.value = null; reason.value = ''
|
||||
if (next === 'pay' && !form.paidAt) form.paidAt = new Date().toISOString()
|
||||
}
|
||||
function selectFiles(event: Event) {
|
||||
if (busy.value || !access.can(permissions.upload)) return
|
||||
const input = event.target as HTMLInputElement, files = Array.from(input.files ?? [])
|
||||
if (vouchers.value.length + pendingFiles.value.length + files.length > 20 || files.some(file => !file.size || file.size > 10 * 1024 * 1024 || !['image/png', 'image/jpeg'].includes(file.type))) {
|
||||
ElMessage.warning('最多20张非空JPG/PNG付款凭证,每张不超过10MiB'); input.value = ''; return
|
||||
}
|
||||
pendingFiles.value.push(...files); input.value = ''; void uploadFiles()
|
||||
}
|
||||
async function uploadFiles() {
|
||||
if (uploading.value || saving.value || pending.value || !access.can(permissions.upload)) return
|
||||
const scope = epoch, no = props.costNo; uploading.value = true; writeError.value = null
|
||||
try {
|
||||
while (pendingFiles.value.length) {
|
||||
const file = await uploadCompanyCostVoucher(no, pendingFiles.value[0]!)
|
||||
if (disposed || scope !== epoch) return
|
||||
if (!vouchers.value.some(value => value.fileId === file.fileId)) vouchers.value.push(file)
|
||||
pendingFiles.value.shift()
|
||||
}
|
||||
} catch (cause) { if (!disposed && scope === epoch) writeError.value = getApiErrorDetails(cause, '凭证上传未完成;已成功上传的凭证已保留,尚未登记付款') }
|
||||
finally { if (scope === epoch) uploading.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
const chosen = pending.value?.action ?? action.value
|
||||
if (!chosen || saving.value || uploading.value || !allowed.value || !access.can(permissions[chosen])) return
|
||||
if (!pending.value) {
|
||||
if (!cost.value || !(chosen === 'approve' ? canApprove.value : chosen === 'void' ? canVoid.value : canPay.value)) return
|
||||
if (chosen === 'pay') {
|
||||
if (form.amount == null || !Number.isFinite(form.amount) || form.amount <= 0 || form.amount > (cost.value.outstandingAmount ?? 0)
|
||||
|| !form.paidAt || !Number.isFinite(Date.parse(form.paidAt)) || !vouchers.value.length || pendingFiles.value.length) {
|
||||
ElMessage.warning('请填写不超过未付余额的正数金额、实际付款时间,并完成凭证上传'); return
|
||||
}
|
||||
pending.value = { action: chosen, key: crypto.randomUUID(), request: { expectedVersion: cost.value.recordVersion, amount: form.amount,
|
||||
paidAt: form.paidAt, method: form.method, referenceNo: form.referenceNo.trim() || null, voucherFileIds: vouchers.value.map(v => v.fileId), remark: form.remark.trim() || null } }
|
||||
} else pending.value = { action: chosen, key: crypto.randomUUID(), request: { expectedVersion: cost.value.recordVersion, reason: reason.value.trim() || null } }
|
||||
}
|
||||
const command = pending.value, scope = epoch, no = props.costNo
|
||||
saving.value = true; writeError.value = null; readRequest++
|
||||
try {
|
||||
const result = command.action === 'pay' ? await payCompanyCost(no, command.key, command.request) : await commandCompanyCost(no, command.action, command.key, command.request)
|
||||
if (disposed || scope !== epoch) return
|
||||
cost.value = result; pending.value = null; action.value = null; vouchers.value = []; pendingFiles.value = []
|
||||
Object.assign(form, { amount: null, paidAt: '', method: 'BANK_TRANSFER', referenceNo: '', remark: '' })
|
||||
ElMessage.success(chosen === 'pay' ? '实际付款已登记' : chosen === 'void' ? '公司费用已作废' : '公司费用已入账')
|
||||
emit('changed')
|
||||
if (tab.value !== 'current') void loadRecords()
|
||||
} catch (cause) {
|
||||
if (disposed || scope !== epoch) return
|
||||
if (cause instanceof ApiResponseError && (/^A04/.test(cause.code) || ['FIN_400', 'FIN_404', 'FIN_409'].includes(cause.code))) pending.value = null
|
||||
writeError.value = getApiErrorDetails(cause, '财务操作未完成,请保留输入并重试')
|
||||
} finally { if (scope === epoch) saving.value = false }
|
||||
}
|
||||
async function loadRecords() {
|
||||
const request = ++recordsRequest, scope = epoch, current = tab.value
|
||||
history.value = []; payments.value = []; historyTotal.value = 0; paymentTotal.value = 0; recordsError.value = null
|
||||
if (!allowed.value || current === 'current') { recordsLoading.value = false; return }
|
||||
recordsLoading.value = true
|
||||
try {
|
||||
if (current === 'history') {
|
||||
const result = await listCompanyCostHistory(props.costNo, historyPage.value)
|
||||
if (!disposed && scope === epoch && request === recordsRequest) { history.value = result.records; historyTotal.value = result.total }
|
||||
} else {
|
||||
const result = await listCompanyCostPayments(props.costNo, paymentPage.value)
|
||||
if (!disposed && scope === epoch && request === recordsRequest) { payments.value = result.records; paymentTotal.value = result.total }
|
||||
}
|
||||
} catch (cause) { if (!disposed && scope === epoch && request === recordsRequest) recordsError.value = getApiErrorDetails(cause, '财务记录读取失败') }
|
||||
finally { if (scope === epoch && request === recordsRequest) recordsLoading.value = false }
|
||||
}
|
||||
watch([tab, historyPage, paymentPage], loadRecords)
|
||||
async function preview(fileId: string) {
|
||||
if (!allowed.value) return
|
||||
const target = window.open('about:blank', '_blank')
|
||||
if (!target) { ElMessage.warning('请允许浏览器打开付款凭证'); return }
|
||||
target.opener = null; const scope = epoch
|
||||
try {
|
||||
const blob = await downloadCompanyCostVoucher(props.costNo, fileId)
|
||||
if (disposed || scope !== epoch || target.closed) { target.close(); return }
|
||||
const url = URL.createObjectURL(blob); target.location.href = url
|
||||
const item = { target, url, timer: setInterval(() => { if (target.closed) { clearInterval(item.timer); URL.revokeObjectURL(url); previews.delete(item) } }, 1000) }
|
||||
previews.add(item)
|
||||
} catch (cause) { target.close(); if (!disposed && scope === epoch) writeError.value = getApiErrorDetails(cause, '付款凭证读取失败') }
|
||||
}
|
||||
onBeforeUnmount(() => { disposed = true; epoch++; readRequest++; recordsRequest++; previews.forEach(item => { clearInterval(item.timer); URL.revokeObjectURL(item.url) }); emit('busy', false) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="公司成本财务处理">
|
||||
<p v-if="!allowed">暂无公司成本财务查询权限。</p>
|
||||
<template v-else>
|
||||
<div class="ledger-actions"><el-button :disabled="loading || saving || uploading" @click="load">刷新费用</el-button><el-button @click="tab = 'current'">费用详情</el-button><el-button @click="tab = 'history'">操作历史</el-button><el-button @click="tab = 'payments'">付款流水</el-button></div>
|
||||
<LoadErrorState v-if="error" title="公司费用读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="writeError" title="财务操作未完成" :message="writeError.message" @retry="pendingFiles.length ? uploadFiles() : submit()"><template #details><ApiErrorMeta :details="writeError" /></template></LoadErrorState>
|
||||
<p v-if="pending">上次操作结果尚未确认。重试使用原版本、原内容和原请求键。</p>
|
||||
<template v-if="tab === 'current' && cost">
|
||||
<p>{{ cost.costNo }} · {{ statusLabels[cost.status] }} · {{ cost.supplierName }}</p>
|
||||
<p>{{ cost.source.feeName }} · {{ cost.source.businessDate }} · 原币 {{ cost.source.originalCurrency }} {{ cost.source.originalAmount.toFixed(2) }}</p>
|
||||
<p>汇率月份 {{ cost.source.exchangeRateMonth }} · 汇率 {{ cost.source.exchangeRate }} · 折算 {{ money(cost.source.amount) }}</p>
|
||||
<p>已付 {{ money(cost.paidAmount) }} · 未付 {{ money(cost.outstandingAmount) }}</p>
|
||||
<div class="ledger-actions"><el-button v-if="canApprove" :disabled="busy" type="primary" @click="choose('approve')">确认入账</el-button><el-button v-if="canVoid" :disabled="busy" type="danger" plain @click="choose('void')">作废费用</el-button><el-button v-if="canPay" :disabled="busy" type="primary" @click="choose('pay')">登记付款</el-button></div>
|
||||
<el-form v-if="action" label-position="top" :disabled="saving || uploading || !!pending" class="ledger-form">
|
||||
<template v-if="action === 'pay'">
|
||||
<el-form-item label="实际付款金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :max="cost.outstandingAmount ?? 0" :precision="2" placeholder="请输入本次实际付款金额" /></el-form-item>
|
||||
<el-form-item label="实际付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="付款方式" required><el-select v-model="form.method"><el-option v-for="(label, key) in methods" :key="key" :label="label" :value="key" /></el-select></el-form-item>
|
||||
<el-form-item label="付款流水号(选填)"><el-input v-model="form.referenceNo" maxlength="128" placeholder="填写银行流水号或支付凭据号" /></el-form-item>
|
||||
<el-form-item label="付款凭证" required class="ledger-wide">
|
||||
<input v-if="access.can(permissions.upload)" type="file" accept="image/png,image/jpeg" multiple :disabled="busy" aria-label="上传公司付款凭证" @change="selectFiles" />
|
||||
<p v-else>登记新付款需公司付款凭证上传权限。</p>
|
||||
<p v-for="file in vouchers" :key="file.fileId">已上传:{{ file.fileName }}</p>
|
||||
<p v-if="pendingFiles.length">{{ pendingFiles.length }} 张凭证待完成上传。</p>
|
||||
<el-button v-if="pendingFiles.length" :disabled="busy" @click="uploadFiles">重试剩余凭证</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="付款说明(选填)" class="ledger-wide"><el-input v-model="form.remark" type="textarea" maxlength="1000" placeholder="补充本次付款用途或说明" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else label="说明(选填)" class="ledger-wide"><el-input v-model="reason" maxlength="1000" placeholder="补充本次入账或作废原因" /></el-form-item>
|
||||
</el-form>
|
||||
<template v-if="action">
|
||||
<p>{{ action === 'pay' ? '确认实际付款及凭证后登记。此操作不会发起银行转账。' : action === 'void' ? '确认作废这笔未付款费用;历史记录保留,重新登记须重新入账。' : '确认将这笔公司费用入账为应付;不会自动付款。' }}</p>
|
||||
<el-button :loading="saving" :disabled="uploading || !access.can(permissions[action])" :type="action === 'void' ? 'danger' : 'primary'" @click="submit">{{ pending ? '重试原操作' : '确认操作' }}</el-button>
|
||||
<el-button :disabled="busy" @click="action = null">取消操作</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<LoadErrorState v-if="recordsError" title="财务记录读取失败" :message="recordsError.message" @retry="loadRecords"><template #details><ApiErrorMeta :details="recordsError" /></template></LoadErrorState>
|
||||
<DataTable v-if="tab === 'history'" v-model:page="historyPage" :page-size="10" :data="history" :columns="historyColumns" :total="historyTotal" :loading="recordsLoading" row-key="recordVersion">
|
||||
<template #action="{ row }">{{ ({ RECEIVE: '接收', APPROVE: '入账', VOID: '作废', PAY: '付款' } as Record<string, string>)[row.action] || '—' }}</template>
|
||||
<template #time="{ row }">{{ time(row.occurredAt) }}</template><template #amount="{ row }">{{ money(row.snapshot.source.amount) }}</template>
|
||||
</DataTable>
|
||||
<DataTable v-if="tab === 'payments'" v-model:page="paymentPage" :page-size="10" :data="payments" :columns="paymentColumns" :total="paymentTotal" :loading="recordsLoading" row-key="paymentNo">
|
||||
<template #amount="{ row }">{{ money(row.amount) }}</template><template #time="{ row }">{{ time(row.paidAt) }}</template>
|
||||
<template #method="{ row }">{{ methods[row.method as keyof typeof methods] || '—' }}</template>
|
||||
<template #files="{ row }"><el-button v-for="(fileId, index) in row.voucherFileIds" :key="fileId" link type="primary" @click="preview(fileId)">凭证 {{ Number(index) + 1 }}</el-button></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ledger-actions { display:flex; flex-wrap:wrap; gap:var(--admin-space-2); margin-bottom:var(--admin-space-3); }
|
||||
.ledger-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }
|
||||
.ledger-wide { grid-column:1/-1; }
|
||||
.ledger-form :deep(.el-select), .ledger-form :deep(.el-input-number), .ledger-form :deep(.el-date-editor) { width:100%; }
|
||||
</style>
|
||||
@@ -6,12 +6,15 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { listVehicleCare, getVehicleCare, downloadVehicleCarePhoto } from '../api/vehicleCare'
|
||||
import { listVehicleCare, getVehicleCare, downloadVehicleCarePhoto, listVehicleCareFinance, submitVehicleCareFinance, listVehicleCareSuppliers,
|
||||
vehicleCareFinanceSubmitPermission, vehicleCareFinanceLabels, type VehicleCareFinanceStatus, type VehicleCareFinanceSubmit } from '../api/vehicleCare'
|
||||
import { distanceLabels, fuelLabels, vehicleCareLabels, processingLabels, exceptionLabels, type VehicleCareFile, type VehicleCareRecord, type VehicleCareType } from '../vehicleCareTypes'
|
||||
import VehicleCareEditor from './VehicleCareEditor.vue'
|
||||
import VehicleCareFinanceLedger from './VehicleCareFinanceLedger.vue'
|
||||
|
||||
const props = defineProps<{ vehicleId: string; plate: string; recordType: VehicleCareType }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<VehicleCareRecord[]>([]), total = ref(0), page = ref(1), size = ref(10), loading = ref(false)
|
||||
@@ -20,10 +23,25 @@ const photoError = ref<ApiErrorDetails | null>(null), openingFile = ref('')
|
||||
const openingRecord = ref(false)
|
||||
const name = computed(() => vehicleCareLabels[props.recordType])
|
||||
const meterRecord = computed(() => ['MAINTENANCE', 'FUEL'].includes(props.recordType))
|
||||
let request = 0, disposed = false
|
||||
const costRecord = computed(() => ['REPAIR', 'RESCUE'].includes(props.recordType))
|
||||
const financeStates = ref<Record<string, VehicleCareFinanceStatus>>({}), financeError = ref<ApiErrorDetails | null>(null), financeLoading = ref(false)
|
||||
const financeOpen = ref(false), financeTarget = ref<VehicleCareRecord | null>(null), financeSaving = ref(false), financeOpening = ref(false)
|
||||
const ledgerCostNo = ref(''), ledgerBusy = ref(false)
|
||||
const financeSaveError = ref<ApiErrorDetails | null>(null), supplierId = ref(''), businessDate = ref('')
|
||||
const suppliers = ref<Array<{ id: string; name: string }>>([]), suppliersLoading = ref(false)
|
||||
const pendingFinance = ref<{ vehicleId: string; recordNo: string; request: VehicleCareFinanceSubmit } | null>(null)
|
||||
const financeStatus = computed(() => financeTarget.value ? financeStates.value[financeTarget.value.recordNo] : null)
|
||||
const canSubmitFinance = computed(() => access.can(vehicleCareFinanceSubmitPermission) && access.can('platform:supplier:list')
|
||||
&& !!financeStatus.value && ['UNLINKED', 'VOIDED'].includes(financeStatus.value.status))
|
||||
let request = 0, operationEpoch = 0, supplierRequest = 0, disposed = false
|
||||
watch([() => props.vehicleId, () => props.recordType], () => {
|
||||
operationEpoch++; supplierRequest++; financeOpen.value = false; financeTarget.value = null
|
||||
financeOpening.value = false; financeSaving.value = false; pendingFinance.value = null
|
||||
ledgerCostNo.value = ''; ledgerBusy.value = false
|
||||
})
|
||||
const previews = new Set<{ target: Window; url: string; timer: ReturnType<typeof setInterval> }>()
|
||||
const columns = computed<DataTableColumn[]>(() => {
|
||||
const result: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }]
|
||||
const result: DataTableColumn[] = [{ key: 'actions', label: '操作', width: costRecord.value ? 170 : 100, fixed: 'left', slotName: 'actions' }]
|
||||
if (props.recordType === 'EXCEPTION') result.push({ key: 'exceptionType', label: '异常类型', minWidth: 135, slotName: 'exceptionType' })
|
||||
result.push({ key: 'date', label: '发生时间', minWidth: 165, slotName: 'date' })
|
||||
if (meterRecord.value) result.push(...(props.recordType === 'MAINTENANCE' ? [
|
||||
@@ -46,14 +64,70 @@ const columns = computed<DataTableColumn[]>(() => {
|
||||
return result
|
||||
})
|
||||
async function load() {
|
||||
const id = ++request; loading.value = true; error.value = null
|
||||
try { const result = await listVehicleCare(props.vehicleId, props.recordType, page.value, size.value); if (id === request) { rows.value = result.records; total.value = result.total } }
|
||||
const id = ++request; loading.value = true; error.value = null; financeStates.value = {}; financeError.value = null
|
||||
try { const result = await listVehicleCare(props.vehicleId, props.recordType, page.value, size.value); if (id === request) { rows.value = result.records; total.value = result.total; void loadFinance() } }
|
||||
catch (cause) { if (id === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, `${name.value}加载失败`) } }
|
||||
finally { if (id === request) loading.value = false }
|
||||
}
|
||||
watch([() => props.vehicleId, () => props.recordType, page, size], load, { immediate: true })
|
||||
function actions(): RowActionItem[] { return [{ key: 'edit', label: access.can(permission.vehicleRecordEdit) ? (meterRecord.value || props.recordType === 'EXCEPTION' ? '编辑' : '更新处理') : '上传', disabled: openingRecord.value,
|
||||
visible: access.can(permission.vehicleRecordEdit) || (meterRecord.value && access.can(permission.vehicleRecordUpload)) }] }
|
||||
function actions(): RowActionItem[] { return [{ key: 'edit', label: access.can(permission.vehicleRecordEdit) ? (meterRecord.value || props.recordType === 'EXCEPTION' ? '编辑' : '更新处理') : '上传', disabled: openingRecord.value || financeSaving.value || ledgerBusy.value,
|
||||
visible: access.can(permission.vehicleRecordEdit) || (meterRecord.value && access.can(permission.vehicleRecordUpload)) },
|
||||
{ key: 'finance', label: '财务', visible: costRecord.value, disabled: financeOpening.value || financeSaving.value }] }
|
||||
async function loadFinance() {
|
||||
if (!costRecord.value || !rows.value.length) { financeLoading.value = false; return }
|
||||
const scope = request; financeLoading.value = true; financeError.value = null; financeStates.value = {}
|
||||
try { const result = await listVehicleCareFinance(props.vehicleId, rows.value.map(row => row.recordNo))
|
||||
if (!disposed && scope === request) financeStates.value = Object.fromEntries(result.map(row => [row.recordNo, row]))
|
||||
} catch (cause) { if (!disposed && scope === request) financeError.value = getApiErrorDetails(cause, '财务状态未确认') }
|
||||
finally { if (scope === request) financeLoading.value = false }
|
||||
}
|
||||
async function openFinance(row: VehicleCareRecord) {
|
||||
if (financeOpening.value || financeSaving.value) return
|
||||
if (ledgerBusy.value) {
|
||||
if (financeTarget.value?.recordNo === row.recordNo) financeOpen.value = true
|
||||
else ElMessage.warning('请先核对当前财务操作结果')
|
||||
return
|
||||
}
|
||||
if (pendingFinance.value && (pendingFinance.value.recordNo !== row.recordNo || pendingFinance.value.vehicleId !== props.vehicleId)) { ElMessage.warning('请先核对上次财务提交结果'); return }
|
||||
const scope = operationEpoch, vehicleId = props.vehicleId
|
||||
financeOpening.value = true; financeSaveError.value = null
|
||||
try {
|
||||
const current = await getVehicleCare(vehicleId, row.recordNo)
|
||||
const statuses = await listVehicleCareFinance(vehicleId, [row.recordNo])
|
||||
if (disposed || scope !== operationEpoch) return
|
||||
financeTarget.value = current; financeStates.value[row.recordNo] = statuses[0]!
|
||||
ledgerCostNo.value = statuses[0]?.finance?.costNo ?? ''
|
||||
if (!pendingFinance.value) { supplierId.value = ''; businessDate.value = new Date(current.details.occurredAt).toLocaleDateString('en-CA'); suppliers.value = [] }
|
||||
financeOpen.value = true
|
||||
} catch (cause) { if (!disposed && scope === operationEpoch) financeError.value = getApiErrorDetails(cause, '财务详情读取失败') }
|
||||
finally { if (scope === operationEpoch) financeOpening.value = false }
|
||||
}
|
||||
async function searchSuppliers(keyword = '') {
|
||||
if (!access.can('platform:supplier:list')) return
|
||||
const scope = ++supplierRequest; suppliersLoading.value = true
|
||||
try { const result = await listVehicleCareSuppliers(keyword); if (!disposed && scope === supplierRequest) suppliers.value = result }
|
||||
catch (cause) { if (!disposed && scope === supplierRequest) financeSaveError.value = getApiErrorDetails(cause, '供应商读取失败') }
|
||||
finally { if (scope === supplierRequest) suppliersLoading.value = false }
|
||||
}
|
||||
async function submitFinance() {
|
||||
if (financeSaving.value || ledgerBusy.value || !financeTarget.value || !access.can(vehicleCareFinanceSubmitPermission)) return
|
||||
if (!pendingFinance.value && !canSubmitFinance.value) return
|
||||
if (!pendingFinance.value && (!supplierId.value || !businessDate.value)) { ElMessage.warning('请选择供应商和费用业务日期'); return }
|
||||
const target = financeTarget.value, vehicleId = props.vehicleId, scope = operationEpoch
|
||||
pendingFinance.value ??= { vehicleId, recordNo: target.recordNo, request: { recordVersion: target.recordVersion, supplierId: supplierId.value, businessDate: businessDate.value, idempotencyKey: crypto.randomUUID() } }
|
||||
const pending = pendingFinance.value
|
||||
financeSaving.value = true; financeSaveError.value = null
|
||||
try {
|
||||
await submitVehicleCareFinance(pending.vehicleId, pending.recordNo, pending.request)
|
||||
if (disposed || scope !== operationEpoch) return
|
||||
pendingFinance.value = null; financeOpen.value = false; ElMessage.success('提交请求已登记,等待财务接收;尚未入账或付款')
|
||||
await loadFinance()
|
||||
} catch (cause) {
|
||||
if (disposed || scope !== operationEpoch) return
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingFinance.value = null
|
||||
financeSaveError.value = getApiErrorDetails(cause, '财务提交未完成')
|
||||
} finally { if (scope === operationEpoch) financeSaving.value = false }
|
||||
}
|
||||
async function edit(row: VehicleCareRecord | null) {
|
||||
if (openingRecord.value) return
|
||||
if (!row) { editing.value = null; editorOpen.value = true; return }
|
||||
@@ -65,7 +139,7 @@ async function edit(row: VehicleCareRecord | null) {
|
||||
}
|
||||
function saved(row: VehicleCareRecord) {
|
||||
const index = rows.value.findIndex(value => value.recordNo === row.recordNo)
|
||||
if (index >= 0) rows.value[index] = row
|
||||
if (index >= 0) { rows.value[index] = row; if (costRecord.value) void loadFinance() }
|
||||
else if (page.value !== 1) page.value = 1
|
||||
else void load()
|
||||
}
|
||||
@@ -83,16 +157,17 @@ async function viewPhoto(row: VehicleCareRecord, file: VehicleCareFile) {
|
||||
} catch (cause) { target.close(); if (!disposed) photoError.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { openingFile.value = '' }
|
||||
}
|
||||
onBeforeUnmount(() => { request++; disposed = true; for (const item of previews) { clearInterval(item.timer); URL.revokeObjectURL(item.url) } previews.clear() })
|
||||
onBeforeUnmount(() => { request++; operationEpoch++; supplierRequest++; disposed = true; for (const item of previews) { clearInterval(item.timer); URL.revokeObjectURL(item.url) } previews.clear() })
|
||||
</script>
|
||||
<template>
|
||||
<section class="care-records" :aria-label="name">
|
||||
<h3>{{ name }}</h3>
|
||||
<LoadErrorState v-if="error" :title="`${name}加载失败`" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="photoError" role="alert">{{ photoError.message }}<ApiErrorMeta :details="photoError" /></p>
|
||||
<LoadErrorState v-if="financeError" title="财务状态未确认" :message="financeError.message" @retry="loadFinance"><template #details><ApiErrorMeta :details="financeError" /></template></LoadErrorState>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="recordNo" :empty-text="`暂无${name}`">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleRecordCreate)" type="primary" @click="edit(null)"><Plus :size="14" />添加{{ name }}</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions()" @action="edit(row as VehicleCareRecord)" /></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'finance' ? openFinance(row as VehicleCareRecord) : edit(row as VehicleCareRecord)" /></template>
|
||||
<template #date="{ row }">{{ new Date(row.details.occurredAt).toLocaleString('zh-CN', { hour12: false }) }}</template>
|
||||
<template #last="{ row }">{{ row.details.lastServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
|
||||
<template #current="{ row }">{{ row.details.odometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
|
||||
@@ -100,11 +175,29 @@ onBeforeUnmount(() => { request++; disposed = true; for (const item of previews)
|
||||
<template #fuel="{ row }">{{ row.details.fuelQuantity }} {{ fuelLabels[row.details.fuelUnit as keyof typeof fuelLabels] }}</template>
|
||||
<template #exceptionType="{ row }">{{ exceptionLabels[row.details.incident?.exceptionType as keyof typeof exceptionLabels] || '—' }}</template>
|
||||
<template #cost="{ row }">{{ row.details.incident?.reportedCost == null ? '—' : `${row.details.incident.currency} ${row.details.incident.reportedCost.toFixed(2)}` }}</template>
|
||||
<template #payment="{ row }">{{ row.paymentStatus === 'UNLINKED' ? '未关联财务' : '—' }}</template>
|
||||
<template #payment="{ row }">{{ financeLoading ? '查询中…' : financeError ? '状态未确认' : financeStates[row.recordNo] ? vehicleCareFinanceLabels[financeStates[row.recordNo]!.status] : '未查询' }}</template>
|
||||
<template #files="{ row }"><el-button v-for="file in row.files" :key="file.fileId" link type="primary" :disabled="!!openingFile" @click="viewPhoto(row as VehicleCareRecord, file)">{{ file.fileName }}</el-button><span v-if="!row.files.length">暂无附件</span></template>
|
||||
<template #status="{ row }">{{ meterRecord ? (row.details.verificationStatus === 'VERIFIED' ? '已核验' : '待核验') : processingLabels[row.details.incident?.processingStatus as keyof typeof processingLabels] || '—' }}</template>
|
||||
</DataTable>
|
||||
<VehicleCareEditor v-model="editorOpen" :vehicle-id="vehicleId" :plate="plate" :record-type="recordType" :record="editing" @saved="saved" />
|
||||
<AppDialog v-model="financeOpen" title="公司车辆成本" width="620px" class="admin-density-dialog" confirm-text="提交财务"
|
||||
:show-confirm="canSubmitFinance || !!pendingFinance" :confirm-loading="financeSaving" :confirm-disabled="ledgerBusy || !access.can(vehicleCareFinanceSubmitPermission)" @confirm="submitFinance">
|
||||
<p>按公司成本登记,不生成客户应收。财务接收、入账和付款分别确认。</p>
|
||||
<p v-if="financeTarget">{{ financeTarget.recordNo }} · {{ financeTarget.vehiclePlate }} · 申报费用 {{ financeTarget.details.incident?.currency || '—' }} {{ financeTarget.details.incident?.reportedCost?.toFixed(2) ?? '—' }}</p>
|
||||
<p v-if="financeStatus">{{ vehicleCareFinanceLabels[financeStatus.status] }}</p>
|
||||
<template v-if="financeStatus?.finance">
|
||||
<p>{{ financeStatus.finance.costNo }} · {{ financeStatus.finance.supplierName }} · CNY {{ financeStatus.finance.source.amount.toFixed(2) }}</p>
|
||||
<p>已付 CNY {{ financeStatus.finance.paidAmount.toFixed(2) }} · 未付 {{ financeStatus.finance.outstandingAmount == null ? '未形成应付' : `CNY ${financeStatus.finance.outstandingAmount.toFixed(2)}` }}</p>
|
||||
</template>
|
||||
<LoadErrorState v-if="financeSaveError" title="财务提交未完成" :message="financeSaveError.message" @retry="submitFinance"><template #details><ApiErrorMeta :details="financeSaveError" /></template></LoadErrorState>
|
||||
<p v-if="pendingFinance">提交结果待核对;重试使用原请求,不重复登记。</p>
|
||||
<el-form v-if="canSubmitFinance || pendingFinance" label-position="top" :disabled="financeSaving || !!pendingFinance">
|
||||
<el-form-item label="供应商" required><el-select v-model="supplierId" filterable remote :remote-method="searchSuppliers" :loading="suppliersLoading" placeholder="搜索启用的供应商" @focus="searchSuppliers()"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
|
||||
<el-form-item label="费用业务日期" required><el-date-picker v-model="businessDate" type="date" value-format="YYYY-MM-DD" placeholder="用于确定原币折算月份" /></el-form-item>
|
||||
</el-form>
|
||||
<p v-if="access.can(vehicleCareFinanceSubmitPermission) && !access.can('platform:supplier:list')">提交公司成本还需供应商查询权限。</p>
|
||||
<VehicleCareFinanceLedger v-if="ledgerCostNo" :cost-no="ledgerCostNo" :active="financeOpen" @changed="loadFinance" @busy="ledgerBusy = $event" />
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.care-records { min-width: 0; }.care-records h3 { margin: 0 0 var(--admin-space-4); }</style>
|
||||
|
||||
Reference in New Issue
Block a user