215 lines
19 KiB
Vue
215 lines
19 KiB
Vue
<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
|
||
let financeRequest = 0, financeBatch = 0
|
||
const financeVersions = new Map<string, number>()
|
||
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() {
|
||
const batch = ++financeBatch
|
||
if (!costRecord.value || !rows.value.length) { financeLoading.value = false; return }
|
||
const revision = ++financeRequest, numbers = rows.value.map(row => row.recordNo)
|
||
for (const number of numbers) financeVersions.set(number, revision)
|
||
const scope = request; financeLoading.value = true; financeError.value = null; financeStates.value = {}
|
||
try { const result = await listVehicleCareFinance(props.vehicleId, numbers)
|
||
if (!disposed && scope === request && batch === financeBatch) {
|
||
for (const row of result) if (financeVersions.get(row.recordNo) === revision) financeStates.value[row.recordNo] = row
|
||
}
|
||
} catch (cause) { if (!disposed && scope === request && batch === financeBatch) financeError.value = getApiErrorDetails(cause, '财务状态未确认') }
|
||
finally { if (scope === request && batch === financeBatch) 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)
|
||
if (disposed || scope !== operationEpoch) return
|
||
const revision = ++financeRequest
|
||
financeVersions.set(row.recordNo, revision)
|
||
const statuses = await listVehicleCareFinance(vehicleId, [row.recordNo])
|
||
if (disposed || scope !== operationEpoch) return
|
||
financeTarget.value = current
|
||
if (financeVersions.get(row.recordNo) === revision) financeStates.value[row.recordNo] = statuses[0]!
|
||
ledgerCostNo.value = financeStates.value[row.recordNo]?.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>
|