feat(fleet): connect operations quotes costs and handover workspaces

This commit is contained in:
2026-10-03 12:43:25 +08:00
parent 863ad5249c
commit 0b5094dd8f
41 changed files with 2091 additions and 22 deletions
@@ -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>