Files
admin-web/src/modules/overseas-master-data/components/TripCostPanel.vue
T
2026-10-20 23:34:11 +08:00

192 lines
15 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 '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
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<{ tripNo: string; 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; 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 (pendingCreate.value && pendingCreate.value.tripNo !== props.tripNo) { ElMessage.warning(`请先返回车次 ${pendingCreate.value.tripNo} 核对上次登记结果`); 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, pendingCreate.value.details, { remark: pendingCreate.value.details.remark ?? '' })
else 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 (pendingCreate.value && pendingCreate.value.tripNo !== props.tripNo) 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 ??= { tripNo: scope, 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>
<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>
<div class="workbench-table" v-if="value.allocations.length"><DataTable :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>
</div>
</template>
<div class="workbench-table"><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><InlineHelp title="直接成本说明"><p>按实际费用登记,各币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p></InlineHelp></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></div>
</template>
<AppDialog v-model="editorOpen" :title="editing ? '更正车次直接成本' : '登记车次直接成本'" width="680px" class="admin-density-dialog admin-workbench is-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 admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="historyError" title="成本历史加载失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState>
<div class="workbench-table"><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></div>
</AppDialog>
</section>
</template>
<style scoped>
.cost-note{color:var(--workbench-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>