修复财务页面竞态并整理采购与运输展示

This commit is contained in:
2026-10-30 18:50:53 +08:00
parent 19a618c9d2
commit 7704532e8f
29 changed files with 591 additions and 217 deletions
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
@@ -29,15 +29,6 @@ import { actionReconciliationStatement, createReconciliationStatement, getReconc
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
statementLineId: string
feeItemName: string
originalAmount: number
adjustedAmount: number
reason: string
}
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
periodStart: string
periodEnd: string
@@ -85,12 +76,9 @@ const createForm = ref({
remark: '',
})
let createSourceLoadSequence = 0
const adjustOpen = ref(false)
const adjustBusy = ref(false)
const adjustError = ref<ApiErrorDetails | null>(null)
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
let selectedCreateGeneration = 0
let pageActive = true
let initialLoadCompleted = false
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
@@ -100,7 +88,6 @@ const canCreate = computed(() => access.can('platform:finance:reconciliation:cre
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
@@ -407,6 +394,7 @@ function defaultCreateForm() {
function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
invalidateCreateLoads()
createForm.value = defaultCreateForm()
createSourceOptions.value = []
createSourcesError.value = null
@@ -440,6 +428,7 @@ async function loadEligibleCreateSources(preferredIds: string[] = createForm.val
businessDateFrom: createForm.value.periodStart,
businessDateTo: createForm.value.periodEnd,
})
if (sequence !== createSourceLoadSequence) return
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
@@ -467,7 +456,9 @@ function queryText(value: unknown) {
}
async function openSelectedCreateFromRoute() {
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
if (!pageActive || route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
const generation = ++selectedCreateGeneration
const sourcePath = route.fullPath
if (!canGenerate.value || !canSelectSources.value) {
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
return
@@ -484,8 +475,12 @@ async function openSelectedCreateFromRoute() {
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
return
}
createError.value = null
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
await loadEligibleCreateSources(feeSourceIds)
// 带入候选只属于当前激活页面及本次路由,旧响应不能重新打开弹框或覆盖用户导航。
if (!pageActive || generation !== selectedCreateGeneration || route.fullPath !== sourcePath
|| !canGenerate.value || !canSelectSources.value) return
createOpen.value = true
await router.replace({ name: 'finance-customer-statements' })
}
@@ -535,18 +530,6 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
finally { actionBusy.value = false }
}
async function openAdjustment(row: FinanceStatementReadModel) {
adjustError.value = null
adjustBusy.value = true
try {
const current = await fetchDetail(row.id, true)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
adjustOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value },
@@ -554,14 +537,13 @@ function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
{ key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value },
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
]
}
function handleStatementAction(command: string, row: FinanceStatementReadModel) {
if (!statementActions(row).some(item => item.key === command && item.visible !== false)) return
if (command === 'detail') void openDetail(row)
else if (command === 'adjust') void openAdjustment(row)
else {
const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
@@ -571,35 +553,39 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel)
}
}
async function submitAdjustment() {
if (adjustBusy.value) return
const statement = adjustStatement.value
const selected = adjustLines.value.filter((line) => line.selected)
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细')
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
adjustBusy.value = true
adjustError.value = null
try {
await actionReconciliationStatement(statement.id, {
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load(appliedFilters.value)
await openDetail(statement)
} catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value }
finally { adjustBusy.value = false }
function invalidateCreateLoads() {
selectedCreateGeneration += 1
createSourceLoadSequence += 1
createSourcesLoading.value = false
}
function deactivateCreateLoads() {
pageActive = false
invalidateCreateLoads()
}
onMounted(async () => {
if (canListCustomers.value) await loadCustomers()
await (canListCustomers.value ? loadCustomers() : undefined)
initialLoadCompleted = true
await openSelectedCreateFromRoute()
})
onActivated(() => {
pageActive = true
if (initialLoadCompleted) void openSelectedCreateFromRoute()
})
onDeactivated(deactivateCreateLoads)
onBeforeUnmount(deactivateCreateLoads)
watch(() => route.name, (name) => {
if (name !== 'finance-customer-statements') invalidateCreateLoads()
}, { flush: 'sync' })
watch(
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
([open, mode]) => {
if (!open || mode !== 'SELECTED') return
([open, mode], [wasOpen]) => {
if (!open) {
if (wasOpen === true) invalidateCreateLoads()
return
}
if (mode !== 'SELECTED') return
void loadEligibleCreateSources()
},
)
@@ -744,16 +730,7 @@ watch(detailRouteId, (statementId) => {
</section>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustOpen" :title="`调整客户对账单 · ${adjustStatement?.billNo || '—'}`" width="min(900px, 96vw)" fullscreenable :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<OperationErrorToast :details="adjustError" />
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="adjustBusy">
<section v-if="adjustStatement" class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
<div class="workbench-evidence"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table></div>
<div class="workbench-table"><el-table :data="adjustLines" size="small" border stripe row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
</div></section>
</el-form>
</AppDialog>
</AdminPage>
</template>
@@ -761,10 +738,8 @@ watch(detailRouteId, (statementId) => {
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; }
.statement-create-table, .statement-dialog-facts { table-layout: fixed; }
.statement-create-table { table-layout: fixed; }
.statement-create-table td:not([colspan]) { width: 50%; }
.statement-dialog-facts th { width: var(--admin-label-width); }
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
.statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); }