192 lines
15 KiB
Vue
192 lines
15 KiB
Vue
<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>
|