Files
admin-web/src/modules/overseas-master-data/components/VehicleCareTable.vue
T

204 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@lucide/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 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, 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)
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editing = ref<VehicleCareRecord | null>(null)
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))
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: 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' ? [
{ key: 'last', label: '上次保养', minWidth: 120, slotName: 'last' },
{ key: 'current', label: '记录时里程', minWidth: 120, slotName: 'current' },
{ key: 'next', label: '下次保养', minWidth: 120, slotName: 'next' },
] : [
{ key: 'fuel', label: '加油量', minWidth: 120, slotName: 'fuel' },
{ key: 'current', label: '记录时里程', minWidth: 120, slotName: 'current' },
]), { key: 'files', label: '图片', minWidth: 130, slotName: 'files' })
else if (['REPAIR', 'RESCUE'].includes(props.recordType)) {
const project = { key: 'item', prop: 'details.incident.item', label: props.recordType === 'REPAIR' ? '维修项目' : '救援项目', minWidth: 170 }
const place = { key: 'place', prop: 'details.incident.place', label: props.recordType === 'REPAIR' ? '维修地点' : '救援地点', minWidth: 190 }
result.push(...(props.recordType === 'REPAIR' ? [project, place] : [place, project]), { key: 'cost', label: '费用(申报金额)', minWidth: 160, slotName: 'cost' })
} else result.push({ key: 'description', prop: 'details.incident.description', label: '描述', minWidth: 260 })
result.push({ key: 'status', label: meterRecord.value ? '核验状态' : '处理状态', width: 105, slotName: 'status' })
if (!meterRecord.value) result.push({ key: 'handler', prop: 'details.incident.handler', label: '处理人', minWidth: 120 })
if (['REPAIR', 'RESCUE'].includes(props.recordType)) result.push({ key: 'payment', label: '付款状态', minWidth: 130, slotName: 'payment' }, { key: 'description', prop: 'details.incident.description', label: '描述', minWidth: 260 })
result.push({ key: 'remark', prop: 'details.remark', label: '备注', minWidth: 150 })
return result
})
async function load() {
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 || 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 }
const vehicleId = props.vehicleId
openingRecord.value = true
try { const current = await getVehicleCare(vehicleId, row.recordNo); if (!disposed && props.vehicleId === vehicleId) { editing.value = current; editorOpen.value = true } }
catch (cause) { if (!disposed && props.vehicleId === vehicleId) error.value = getApiErrorDetails(cause, '记录详情加载失败') }
finally { openingRecord.value = false }
}
function saved(row: VehicleCareRecord) {
const index = rows.value.findIndex(value => value.recordNo === row.recordNo)
if (index >= 0) { rows.value[index] = row; if (costRecord.value) void loadFinance() }
else if (page.value !== 1) page.value = 1
else void load()
}
async function viewPhoto(row: VehicleCareRecord, file: VehicleCareFile) {
if (openingFile.value) return
const target = window.open('about:blank', '_blank')
if (!target) { ElMessage.warning('请允许浏览器打开图片标签页'); return }
target.opener = null; photoError.value = null; openingFile.value = file.fileId
try {
const blob = await downloadVehicleCarePhoto(props.vehicleId, row.recordNo, file.fileId)
if (disposed || target.closed) { target.close(); return }
const url = URL.createObjectURL(blob); target.location.href = url
const preview = { target, url, timer: setInterval(() => { if (target.closed) { clearInterval(preview.timer); URL.revokeObjectURL(url); previews.delete(preview) } }, 1000) }
previews.add(preview)
} catch (cause) { target.close(); if (!disposed) photoError.value = getApiErrorDetails(cause, '图片读取失败') }
finally { openingFile.value = '' }
}
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="$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>
<template #next="{ row }">{{ row.details.nextServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
<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 }">{{ 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>