Files
admin-web/src/modules/finance/pages/FinanceManualPage.vue
T
2026-08-11 18:06:35 +08:00

616 lines
51 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, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Eye, FileCheck2, FileUp, MoreHorizontal, Pencil, Plus, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { adjustFinanceBill, createFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBillRevisions, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, uploadFinanceVoucher, voidFinanceBill } from '../api/finance'
import type { FinanceBillDetail, FinanceBillRevision, FinancePayableSettlement, FinanceReceivableSettlement, FinanceStatementReadModel, FinanceVoucherResource } from '../types'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import {
listPlatformUnifiedOrderFeeDetails,
listPlatformUnifiedOrders,
type UnifiedOrderFeeDetailResource,
type UnifiedOrderResource,
} from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
type Mode = 'bills' | 'receipts' | 'costs'
type DrawerMode = 'create' | 'detail' | 'edit' | 'adjust'
type TableRow = Record<string, string | number | null>
interface BillLineDraft {
sourceFeeId: string
sourceFeeVersion: number
feeItemName: string
amount: string
orderId: string
inboundId: string
}
const props = defineProps<{ mode: Mode }>()
const access = useAccessPreviewStore()
const allRows = ref<TableRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const serverPaged = ref(false)
const filters = reactive<QueryModel>({ keyword: '', customerId: '', status: '' })
const drawerOpen = ref(false)
const drawerMode = ref<DrawerMode>('create')
const drawerLoading = ref(false)
const drawerError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const selectedBill = ref<FinanceBillDetail | null>(null)
const selectedLedgerRow = ref<TableRow | null>(null)
const billRevisions = ref<FinanceBillRevision[]>([])
const revisionLoading = ref(false)
const revisionError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
const file = ref<File | null>(null)
const uploadedVoucher = ref<FinanceVoucherResource | null>(null)
const voucherError = ref('')
const customers = ref<PlatformTenantResource[]>([])
const customerOptionsLoading = ref(false)
const customerOptionsError = ref<ApiErrorDetails | null>(null)
const billingOrders = ref<UnifiedOrderResource[]>([])
const billingOrdersLoading = ref(false)
const billingOrdersError = ref('')
const receiptBills = ref<FinanceStatementReadModel[]>([])
const receiptBillsLoading = ref(false)
const receiptBillsError = ref('')
const costOrders = ref<UnifiedOrderResource[]>([])
const costContainers = ref<ContainerBookingResource[]>([])
const costOptionsLoading = ref(false)
const costOptionsError = ref('')
const feeSourcesByInbound = reactive<Record<string, UnifiedOrderFeeDetailResource[]>>({})
const feeSourcesLoading = reactive<Record<string, boolean>>({})
const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
const selectableFormCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === form.customerId
)))
const allocations = reactive([{ billId: '', amount: '' }])
const billLines = reactive<BillLineDraft[]>([emptyBillLine()])
let billingOrdersRequest = 0
let receiptBillsRequest = 0
let costOptionsRequest = 0
const title = computed(() => ({ bills: '客户对账单', receipts: '结算单应收', costs: '结算单应付' }[props.mode]))
const viewPermission = computed(() => ({ bills: 'platform:finance:bill:view', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view' }[props.mode]))
const createPermission = computed(() => ({ bills: 'platform:finance:bill:create', receipts: 'platform:finance:receipt:create', costs: 'platform:finance:cost:create' }[props.mode]))
const canView = computed(() => access.can(viewPermission.value))
const canCreate = computed(() => Boolean(createPermission.value) && access.can(createPermission.value))
const canIssue = computed(() => access.can('platform:finance:bill:issue'))
const canUpdate = computed(() => access.can('platform:finance:bill:update'))
const canAdjust = computed(() => access.can('platform:finance:bill:adjust'))
const canVoid = computed(() => access.can('platform:finance:bill:void'))
const stateError = computed(() => customerOptionsError.value || loadError.value)
const drawerTitle = computed(() => {
if (props.mode !== 'bills') return drawerMode.value === 'detail' ? props.mode === 'receipts' ? '应收结算详情' : '应付结算详情' : props.mode === 'receipts' ? '登记人工收款' : '登记应付成本'
return { create: '新建客户对账单', detail: '对账单详情', edit: '编辑草稿对账单', adjust: '调整已出账对账单' }[drawerMode.value]
})
const rows = computed(() => serverPaged.value ? allRows.value : allRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '客户/单号/费用项目', props: { 'aria-label': '关键词' } },
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customerOptionsLoading.value }, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })) },
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }] }] : []),
])
const columns = computed<DataTableColumn[]>(() => {
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
if (props.mode === 'receipts') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '结算单号', minWidth: 170 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '应收(CNY)', width: 130 }, { key: 'paidAmount', label: '已收(CNY)', width: 130 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 130 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
if (props.mode === 'costs') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '结算单号', minWidth: 170 }, { key: 'costType', label: '成本类型', width: 130, slotName: 'costType' }, { key: 'referenceNo', label: '来源追溯', minWidth: 180 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'supplierName', label: '供应商', minWidth: 150 }, { key: 'amount', label: '应付(CNY)', width: 130 }, { key: 'paidAmount', label: '已付(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'createdAt', label: '登记时间', width: 150 }]
return []
})
const rules: FormRules = {
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
billDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }],
referenceNo: [{ required: true, message: '请选择关联业务对象', trigger: 'change' }],
feeName: [{ required: true, message: '请输入费用项目', trigger: 'blur' }],
paymentMethod: [{ required: true, message: '请选择收款方式', trigger: 'change' }],
}
function mapStatement(item: FinanceStatementReadModel): TableRow { return { id: item.id, customerId: item.customerId, billNo: item.billNo, customerName: item.customerName, billDate: item.billDate, dueDate: item.dueDate, amount: item.totalAmount, paidAmount: item.receivedAmount, outstandingAmount: item.outstandingAmount, status: item.status, version: item.version } }
function mapReceivable(item: FinanceReceivableSettlement): TableRow { return { id: item.id, customerId: item.customerId, settlementNo: item.settlementNo, billDate: item.billDate, dueDate: item.dueDate, amount: item.receivableAmount, paidAmount: item.receivedAmount, outstandingAmount: item.outstandingAmount, status: item.status, version: item.version } }
function mapPayable(item: FinancePayableSettlement): TableRow { return { id: item.id, customerId: item.customerId, settlementNo: item.settlementNo, costType: item.costType, referenceNo: item.containerId ? `柜次 ${item.containerId}` : item.inboundId ? `入库单 ${item.inboundId}` : item.orderId ? `订单 ${item.orderId}` : '—', feeName: item.feeItemName, supplierName: item.supplierName || '未填写', amount: item.payableAmount, paidAmount: item.paidAmount, outstandingAmount: item.outstandingAmount, status: item.status, createdAt: item.createdAt, version: item.version } }
function customerNameById(customerId: string | number | null) {
return customers.value.find((customer) => String(customer.id) === String(customerId))?.tenantName || '客户信息暂不可用'
}
function emptyBillLine(): BillLineDraft {
return { sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }
}
async function loadCustomerOptions() {
customerOptionsLoading.value = true
customerOptionsError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customerOptionsError.value = getApiErrorDetails(error, '客户列表加载失败')
} finally {
customerOptionsLoading.value = false
}
}
async function loadBillingOrders(customerId: string) {
const request = ++billingOrdersRequest
billingOrders.value = []
billingOrdersError.value = ''
if (!customerId) return
billingOrdersLoading.value = true
try {
const result = await listPlatformUnifiedOrders({ tenantId: customerId, customerId, page: 1, size: 200 })
if (request === billingOrdersRequest) billingOrders.value = result.records.filter((order) => order.inboundId != null)
} catch (error) {
if (request === billingOrdersRequest) billingOrdersError.value = getApiErrorDetails(error, '客户订单加载失败').message
} finally {
if (request === billingOrdersRequest) billingOrdersLoading.value = false
}
}
async function listAllCustomerOrders(customerId: string) {
const records: UnifiedOrderResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformUnifiedOrders({ tenantId: customerId, customerId, page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
return records
}
async function loadReceiptBills(customerId: string) {
const request = ++receiptBillsRequest
receiptBills.value = []
receiptBillsError.value = ''
if (!customerId) return
receiptBillsLoading.value = true
try {
const records = await listFinanceStatements(customerId)
if (request === receiptBillsRequest) receiptBills.value = records.filter((bill) => ['ISSUED', 'PARTIALLY_PAID'].includes(bill.status) && bill.outstandingAmount > 0)
} catch (error) {
if (request === receiptBillsRequest) receiptBillsError.value = getApiErrorDetails(error, '未结清账单加载失败').message
} finally {
if (request === receiptBillsRequest) receiptBillsLoading.value = false
}
}
async function loadCostOptions(customerId: string) {
const request = ++costOptionsRequest
costOrders.value = []
costContainers.value = []
costOptionsError.value = ''
if (!customerId) return
costOptionsLoading.value = true
try {
const orders = await listAllCustomerOrders(customerId)
if (request !== costOptionsRequest) return
costOrders.value = orders
const relatedContainerKeys = new Set(orders.flatMap((order) => [order.transport?.containerNo, order.transport?.cabinetNo]).filter((value): value is string => Boolean(value)))
if (!relatedContainerKeys.size) return
const firstPage = await listContainers({ page: 1, size: 100 })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
if (request !== costOptionsRequest) return
costContainers.value = [firstPage, ...remainingPages]
.flatMap((result) => result.records)
.filter((container) => relatedContainerKeys.has(container.containerNo || '') || relatedContainerKeys.has(container.cabinetNo))
} catch (error) {
if (request === costOptionsRequest) costOptionsError.value = getApiErrorDetails(error, '关联订单和柜次加载失败').message
} finally {
if (request === costOptionsRequest) costOptionsLoading.value = false
}
}
function orderLabel(order: UnifiedOrderResource) {
return `${order.orderNo || `订单 ${order.orderId}`} · ${order.inboundCode || `入库单 ${order.inboundId}`}`
}
async function loadFeeSources(inboundId: string) {
if (!inboundId || feeSourcesByInbound[inboundId] || feeSourcesLoading[inboundId]) return
feeSourcesLoading[inboundId] = true
try {
feeSourcesByInbound[inboundId] = await listPlatformUnifiedOrderFeeDetails(form.customerId, inboundId)
} catch (error) {
feeSourcesByInbound[inboundId] = []
ElMessage.error(getApiErrorDetails(error, '费用来源加载失败').message)
} finally {
feeSourcesLoading[inboundId] = false
}
}
function handleCustomerChange(customerId: string) {
uploadedVoucher.value = null
file.value = null
billingOrders.value = []
receiptBills.value = []
costOrders.value = []
costContainers.value = []
form.referenceNo = ''
if (props.mode !== 'bills') form.amount = ''
allocations.splice(0, allocations.length, { billId: '', amount: '' })
for (const key of Object.keys(feeSourcesByInbound)) delete feeSourcesByInbound[key]
billLines.splice(0, billLines.length, emptyBillLine())
if (props.mode === 'bills') void loadBillingOrders(customerId)
else if (props.mode === 'receipts') void loadReceiptBills(customerId)
else void loadCostOptions(customerId)
}
function handleCostTypeChange() {
form.referenceNo = ''
}
function orderOptionLabel(order: UnifiedOrderResource) {
return [order.orderNo || `订单 ${order.orderId}`, order.inboundCode ? `入库单 ${order.inboundCode}` : ''].filter(Boolean).join(' · ')
}
function containerOptionLabel(container: ContainerBookingResource) {
return [container.cabinetNo || `柜次 ${container.id}`, container.containerNo ? `柜号 ${container.containerNo}` : '', container.warehouseName].filter(Boolean).join(' · ')
}
function receiptBillLabel(bill: FinanceStatementReadModel) {
return `${bill.billNo || `账单 ${bill.id}`} · 未收 ${bill.outstandingAmount.toFixed(2)} CNY`
}
function allocationBillDisabled(billId: string, index: number) {
return allocations.some((allocation, allocationIndex) => allocationIndex !== index && allocation.billId === billId)
}
function syncReceiptAmount() {
form.amount = allocations.reduce((sum, allocation) => sum + (positiveNumber(allocation.amount) ? Number(allocation.amount) : 0), 0).toFixed(2)
}
function handleAllocationBillChange(index: number, billId: string) {
const allocation = allocations[index]
if (!allocation) return
allocation.billId = billId
const bill = receiptBills.value.find((item) => item.id === billId)
allocation.amount = bill ? bill.outstandingAmount.toFixed(2) : ''
syncReceiptAmount()
}
function handleLineOrderChange(line: BillLineDraft, orderId: string) {
const order = billingOrders.value.find((item) => String(item.orderId) === orderId)
line.orderId = orderId
line.inboundId = order?.inboundId == null ? '' : String(order.inboundId)
line.sourceFeeId = ''
line.sourceFeeVersion = 1
line.feeItemName = ''
line.amount = ''
if (line.inboundId) void loadFeeSources(line.inboundId)
}
function handleFeeSourceChange(line: BillLineDraft, sourceFeeId: string) {
const source = (feeSourcesByInbound[line.inboundId] || []).find((item) => String(item.sourceFeeId ?? '') === sourceFeeId)
line.sourceFeeId = sourceFeeId
line.sourceFeeVersion = Number(source?.sourceFeeVersion || 1)
line.feeItemName = source?.feeTypeNameSnapshot || ''
line.amount = source?.amount == null ? '' : String(source.amount)
}
function feeSourceOptions(line: BillLineDraft) {
return (feeSourcesByInbound[line.inboundId] || []).filter((item) => item.sourceFeeId != null && Number(item.sourceFeeVersion) > 0)
}
function feeSourceLabel(source: UnifiedOrderFeeDetailResource) {
return `${source.feeTypeNameSnapshot || '未命名费用'} · ${source.amount ?? 0} ${source.currency || 'CNY'}`
}
async function load() {
if (!canView.value) return
loading.value = true; loadError.value = null
try {
const customerId = String(filters.customerId || '').trim()
if (!customerId) { allRows.value = []; total.value = 0; return }
let source: TableRow[]
let responseTotal = 0
if (props.mode === 'bills') {
const result = await listFinanceStatements(customerId)
source = result.map(mapStatement)
responseTotal = result.length; serverPaged.value = false
} else if (props.mode === 'receipts') {
const result = await listFinanceReceivableSettlements(customerId); source = result.map(mapReceivable); responseTotal = result.length; serverPaged.value = false
} else {
const result = await listFinancePayableSettlements(customerId); source = result.map(mapPayable); responseTotal = result.length; serverPaged.value = false
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
const status = String(filters.status || '')
allRows.value = source.filter((row) => (!keyword || Object.values(row).some((value) => String(value ?? '').toLowerCase().includes(keyword))) && (!status || row.status === status))
total.value = keyword || status ? allRows.value.length : responseTotal
} catch (error) { allRows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
finally { loading.value = false }
}
function applyFilters(model: QueryModel) { Object.assign(filters, model); page.value = 1; void load() }
function changePageSize() { page.value = 1; void load() }
function openCreate() {
billingOrdersRequest += 1; receiptBillsRequest += 1; costOptionsRequest += 1
drawerMode.value = 'create'; selectedBill.value = null; selectedLedgerRow.value = null; billRevisions.value = []; revisionError.value = null; drawerError.value = null
Object.assign(form, { customerId: '', billDate: new Date().toISOString().slice(0, 10), amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
allocations.splice(0, allocations.length, { billId: '', amount: '' })
billLines.splice(0, billLines.length, emptyBillLine())
billingOrders.value = []; billingOrdersError.value = ''; receiptBills.value = []; receiptBillsError.value = ''; costOrders.value = []; costContainers.value = []; costOptionsError.value = ''
file.value = null; uploadedVoucher.value = null; voucherError.value = ''; drawerOpen.value = true
}
function selectFile(event: Event) {
const selected = (event.target as HTMLInputElement).files?.[0] || null
uploadedVoucher.value = null; voucherError.value = ''
if (selected && !selected.type.startsWith('image/')) { file.value = null; voucherError.value = '凭证仅支持图片文件'; return }
file.value = selected
}
function positiveNumber(value: string) { const amount = Number(value); return Number.isFinite(amount) && amount > 0 }
function addAllocation() { allocations.push({ billId: '', amount: '' }) }
function removeAllocation(index: number) { if (allocations.length > 1) { allocations.splice(index, 1); syncReceiptAmount() } }
function addBillLine() { billLines.push(emptyBillLine()) }
function removeBillLine(index: number) { if (billLines.length > 1) billLines.splice(index, 1) }
function billPayloadLines() { return billLines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, orderId: line.orderId || undefined, inboundId: line.inboundId || undefined, feeItemName: line.feeItemName.trim(), amount: Number(line.amount) })) }
function validateBusinessForm() {
if (!/^\d+$/.test(form.customerId)) return '请选择有效客户'
if (props.mode !== 'costs' && !form.billDate) return '请选择日期'
if (props.mode !== 'bills' && !positiveNumber(form.amount)) return '金额必须大于 0'
if (props.mode === 'bills' && billLines.some((line) => !/^\d+$/.test(line.sourceFeeId) || !line.orderId || !line.inboundId || !line.feeItemName.trim() || !positiveNumber(line.amount))) return '请为每条账单明细选择订单和已生成的费用来源'
if (drawerMode.value === 'adjust' && !form.remark.trim()) return '调整原因不能为空'
if (props.mode === 'costs' && (!form.referenceNo || !form.feeName.trim())) return `请选择${form.costType === 'SINGLE_ORDER' ? '订单' : '柜次'}并填写费用项目`
if (props.mode === 'receipts') {
if (allocations.some((item) => !receiptBills.value.some((bill) => bill.id === item.billId) || !positiveNumber(item.amount))) return '每条分配都必须选择未结清账单并填写大于 0 的金额'
if (new Set(allocations.map((item) => item.billId)).size !== allocations.length) return '同一账单不能重复分配'
const exceededBill = allocations.find((item) => Number(item.amount) > (receiptBills.value.find((bill) => bill.id === item.billId)?.outstandingAmount || 0) + 0.000001)
if (exceededBill) return '账单分配金额不能超过该账单的未收金额'
const allocated = allocations.reduce((sum, item) => sum + Number(item.amount), 0)
if (Math.abs(allocated - Number(form.amount)) > 0.000001) return `分配金额合计 ${allocated.toFixed(2)} 元,必须等于收款金额`
}
if (props.mode !== 'bills' && uploadedVoucher.value && uploadedVoucher.value.customerId !== form.customerId) return '客户ID已变化,请重新选择并上传对应客户的凭证'
if (props.mode !== 'bills' && !file.value && !uploadedVoucher.value) return '请先选择收款或成本凭证图片'
return ''
}
async function submit() {
const valid = await formRef.value?.validate().catch(() => false); if (!valid) return
const validationMessage = validateBusinessForm(); if (validationMessage) { ElMessage.warning(validationMessage); return }
saving.value = true
drawerError.value = null
try {
if (props.mode === 'bills') {
const lines = billPayloadLines()
if (drawerMode.value === 'edit' && selectedBill.value) await updateFinanceBill(selectedBill.value.id, { customerId: form.customerId, billDate: form.billDate, lines, expectedVersion: selectedBill.value.version })
else if (drawerMode.value === 'adjust' && selectedBill.value) await adjustFinanceBill(selectedBill.value.id, { expectedVersion: selectedBill.value.version, reason: form.remark.trim(), lines })
else await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, lines })
} else {
if (!uploadedVoucher.value && file.value) uploadedVoucher.value = await uploadFinanceVoucher(form.customerId, file.value)
const voucherFileIds = uploadedVoucher.value ? [uploadedVoucher.value.fileId] : []
if (!voucherFileIds.length) throw new Error('凭证上传未返回有效 fileId')
if (props.mode === 'receipts') {
await createFinanceReceipt({ customerId: form.customerId, receivedDate: form.billDate, amount: String(form.amount), paymentMethod: form.paymentMethod, remark: form.remark, allocations: allocations.map((item) => ({ billId: item.billId, amount: String(item.amount) })) }, voucherFileIds)
} else {
await createFinanceCost({ customerId: form.customerId, costType: form.costType, orderId: form.costType === 'SINGLE_ORDER' ? form.referenceNo : undefined, containerId: form.costType === 'CONTAINER_SHARED' ? form.referenceNo : undefined, expenseName: form.feeName.trim(), amount: String(form.amount), remark: form.remark }, voucherFileIds)
}
}
drawerOpen.value = false; ElMessage.success(`${title.value}已保存`); await load()
} catch (error) { const details = getApiErrorDetails(error, `${title.value}保存失败,请检查 Finance 接口后重试`); drawerError.value = details; ElMessage.error(details.message) }
finally { saving.value = false }
}
async function issueBill(row: TableRow) {
actionError.value = null
try {
await ElMessageBox.confirm('出账后账单将冻结,不能再按草稿方式修改。确认出账吗?', '确认出账', { type: 'warning', confirmButtonText: '确认出账', cancelButtonText: '取消' })
await issueFinanceBill(String(row.id), Number(row.version))
ElMessage.success('账单已出账并冻结'); await load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
const details = getApiErrorDetails(error, '账单出账失败,请重试'); actionError.value = details; ElMessage.error(details.message)
}
}
async function openBill(row: TableRow, mode: DrawerMode) {
drawerMode.value = mode; drawerOpen.value = true; drawerLoading.value = true; drawerError.value = null; revisionError.value = null; billRevisions.value = []; selectedBill.value = null
try {
const detail = await getFinanceBill(String(row.id)); selectedBill.value = detail
Object.assign(form, { customerId: detail.customerId, billDate: detail.billDate, remark: '' })
billLines.splice(0, billLines.length, ...detail.lines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, feeItemName: line.feeItemName, amount: String(line.amount), orderId: line.orderId || '', inboundId: line.inboundId || '' })))
if (!billLines.length) addBillLine()
await loadBillingOrders(detail.customerId)
await Promise.all([...new Set(billLines.map((line) => line.inboundId).filter(Boolean))].map(loadFeeSources))
if (mode === 'detail') {
revisionLoading.value = true
try { billRevisions.value = await listFinanceBillRevisions(detail.id, detail.customerId) }
catch (error) { revisionError.value = getApiErrorDetails(error, '账单修订记录加载失败,请重试') }
finally { revisionLoading.value = false }
}
} catch (error) { drawerError.value = getApiErrorDetails(error, '账单详情加载失败,请重试') }
finally { drawerLoading.value = false }
}
function openLedgerDetail(row: TableRow) {
drawerMode.value = 'detail'
selectedLedgerRow.value = row
drawerError.value = null
drawerOpen.value = true
}
async function voidBill(row: TableRow) {
actionError.value = null
try {
const result = await ElMessageBox.prompt('请输入作废原因。已有收款的账单不能作废。', '作废账单', { type: 'warning', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await voidFinanceBill(String(row.id), Number(row.version), result.value.trim())
ElMessage.success('账单已作废'); await load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
const details = getApiErrorDetails(error, '账单作废失败,请重试'); actionError.value = details; ElMessage.error(details.message)
}
}
function handleBillCommand(command: string, row: TableRow) {
if (command === 'issue') void issueBill(row)
else if (command === 'void') void voidBill(row)
}
onMounted(() => { void load(); void loadCustomerOptions() })
</script>
<template>
<AdminPage as="div" class="finance-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问财务模块" sub-title="请联系管理员开通平台财务权限" />
<ListPageLayout v-else aria-label="财务人工模块">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="queryFields" :preference-key="`finance-${props.mode}-query`" @search="applyFilters" /></template>
<template v-if="stateError" #state><LoadErrorState :title="customerOptionsError ? '客户选项加载失败' : `${title}加载失败`" :message="stateError.message" :loading="customerOptionsLoading || loading" @retry="customerOptionsError ? loadCustomerOptions() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<div v-if="actionError" class="surface-panel operation-error" role="alert"><el-alert type="error" :closable="true" :title="actionError.message" @close="actionError = null" /><ApiErrorMeta :details="actionError" /></div>
<section v-if="!loadError && !customerOptionsError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`finance-${props.mode}-table`" :aria-label="`${title}列表`" row-key="id" empty-text="暂无数据" @update:page="load" @update:page-size="changePageSize">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.mode === 'bills' ? '新建对账单' : props.mode === 'receipts' ? '登记收款' : '登记应付成本' }}</el-button></template>
<template #actions="{ row }">
<div v-if="props.mode === 'bills'" class="row-actions">
<el-button link type="primary" @click="openBill(row, 'detail')"><Eye :size="15" />查看</el-button>
<el-button v-if="row.status === 'DRAFT' && canUpdate" link type="primary" @click="openBill(row, 'edit')"><Pencil :size="15" />编辑</el-button>
<el-button v-if="['ISSUED', 'PARTIALLY_PAID', 'PAID'].includes(String(row.status)) && canAdjust" link type="primary" @click="openBill(row, 'adjust')"><Pencil :size="15" />调整</el-button>
<el-button v-if="row.status === 'ISSUED' && canVoid" link type="danger" @click="voidBill(row)"><Trash2 :size="15" />作废</el-button>
<el-dropdown v-if="row.status === 'DRAFT' && (canIssue || canVoid)" trigger="click" @command="handleBillCommand($event, row)">
<el-button link aria-label="更多账单操作"><MoreHorizontal :size="17" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canIssue" command="issue"><FileCheck2 :size="15" />出账</el-dropdown-item><el-dropdown-item v-if="canVoid" command="void" divided><Trash2 :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
<el-button v-else link type="primary" @click="openLedgerDetail(row)"><Eye :size="15" />查看</el-button>
</template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' || row.status === 'CONFIRMED' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : row.status === 'CONFIRMED' ? '已确认' : row.status === 'VOIDED' ? '作废' : row.status }}</el-tag></template>
<template #costType="{ row }">{{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</template>
<template #empty><el-empty description="暂无符合条件的财务记录" /></template>
<template #pagination-left>币种:CNY · 数据由平台财务人工维护</template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="drawerTitle" size="min(760px, 94vw)" :loading="drawerLoading" :confirm-loading="saving">
<div v-if="drawerError" class="drawer-error" role="alert"><el-alert type="error" :closable="false" :title="drawerError.message" /><ApiErrorMeta :details="drawerError" /></div>
<template v-if="props.mode === 'bills' && drawerMode === 'detail' && selectedBill">
<el-descriptions :column="2" border class="bill-summary">
<el-descriptions-item label="账单编号">{{ selectedBill.billNo }}</el-descriptions-item><el-descriptions-item label="当前版本">v{{ selectedBill.version }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedBill.status }}</el-descriptions-item>
<el-descriptions-item label="账单日期">{{ selectedBill.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedBill.dueDate }}</el-descriptions-item>
<el-descriptions-item label="账单金额">{{ selectedBill.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedBill.outstandingAmount }} CNY</el-descriptions-item>
</el-descriptions>
<FormSection title="费用明细" description="当前账单版本中的真实费用明细。">
<div v-for="(line, index) in selectedBill.lines" :key="`${line.sourceFeeId}-${index}`" class="detail-line"><strong>{{ line.feeItemName }}</strong><span>{{ line.inboundId ? `入库单 ${line.inboundId} · ` : '' }}{{ line.orderId ? `订单 ${line.orderId} · ` : '' }}来源 {{ line.sourceFeeId }}/v{{ line.sourceFeeVersion }}</span><b>{{ line.amount }} CNY</b></div>
<el-empty v-if="!selectedBill.lines.length" description="暂无费用明细" />
</FormSection>
<FormSection title="修订记录" description="按版本倒序展示后端保存的调整前账单快照。">
<el-skeleton v-if="revisionLoading" :rows="2" animated />
<div v-else-if="revisionError" class="revision-error" role="alert"><el-alert type="error" :closable="false" :title="revisionError.message" /><ApiErrorMeta :details="revisionError" /></div>
<el-empty v-else-if="!billRevisions.length" description="暂无历史修订记录" />
<div v-else class="revision-list">
<section v-for="revision in billRevisions" :key="revision.id" class="revision-item">
<div class="revision-heading"><strong>调整前版本 v{{ revision.version }}</strong><span>{{ revision.createdAt || '时间未知' }}</span></div>
<div class="revision-meta"><span>原因:{{ revision.reason || '未填写' }}</span><span>操作人ID:{{ revision.createdBy || '—' }}</span><span>状态:{{ revision.status || '—' }}</span><span>金额:{{ revision.totalAmount }} CNY</span></div>
<div v-for="(line, index) in revision.lines" :key="`${revision.id}-${line.sourceFeeId}-${index}`" class="revision-line"><span>{{ line.feeItemName || `费用来源 ${line.sourceFeeId}` }}</span><b>{{ line.amount }} CNY</b></div>
<p v-if="!revision.lines.length" class="revision-empty">该版本没有费用明细快照</p>
</section>
</div>
</FormSection>
</template>
<template v-else-if="props.mode !== 'bills' && drawerMode === 'detail' && selectedLedgerRow">
<el-descriptions :column="2" border class="bill-summary">
<el-descriptions-item label="结算单号">{{ selectedLedgerRow.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerNameById(selectedLedgerRow.customerId) }}</el-descriptions-item>
<template v-if="props.mode === 'receipts'"><el-descriptions-item label="账单日期">{{ selectedLedgerRow.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedLedgerRow.dueDate }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ selectedLedgerRow.status }}</el-descriptions-item></template>
<template v-else><el-descriptions-item label="费用项目">{{ selectedLedgerRow.feeName }}</el-descriptions-item><el-descriptions-item label="成本类型">{{ selectedLedgerRow.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</el-descriptions-item><el-descriptions-item label="关联来源">{{ selectedLedgerRow.referenceNo }}</el-descriptions-item><el-descriptions-item label="应付金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已付金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未付金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="登记时间">{{ selectedLedgerRow.createdAt }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedLedgerRow.status }}</el-descriptions-item></template>
</el-descriptions>
</template>
<el-form v-else-if="drawerMode !== 'detail'" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || drawerLoading">
<FormSection title="基本信息" description="首期只支持 CNY,由平台财务人工填写并留存凭证。">
<div class="form-grid two-cols">
<el-form-item label="客户" prop="customerId"><el-select v-model="form.customerId" filterable placeholder="请选择客户" :loading="customerOptionsLoading" :loading-text="'客户加载中'" :no-data-text="customerOptionsError?.message || '暂无可选客户'" :disabled="props.mode === 'bills' && drawerMode !== 'create'" style="width: 100%" @change="handleCustomerChange"><el-option v-for="customer in selectableFormCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item v-if="props.mode !== 'costs'" :label="props.mode === 'receipts' ? '收款日期' : '账单日期'" prop="billDate"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" :placeholder="props.mode === 'receipts' ? '请选择收款日期' : '请选择账单日期'" style="width: 100%" :disabled="props.mode === 'bills' && drawerMode === 'adjust'" /></el-form-item>
</div>
<div v-if="customerOptionsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="customerOptionsError.message" show-icon /><ApiErrorMeta :details="customerOptionsError" /><el-button link type="primary" :loading="customerOptionsLoading" @click="loadCustomerOptions">重新加载客户</el-button></div>
<template v-if="props.mode === 'bills'">
<div class="allocation-title">费用明细</div>
<div v-if="billingOrdersError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="billingOrdersError" show-icon /><el-button link type="primary" :loading="billingOrdersLoading" @click="loadBillingOrders(form.customerId)">重新加载客户订单</el-button></div>
<p v-else class="form-hint">先选择客户订单,再选择该入库单已生成的费用来源;系统会自动带出费用项目、版本和金额。</p>
<div v-for="(line, index) in billLines" :key="index" class="bill-line-row">
<el-select :model-value="line.orderId" filterable placeholder="选择订单 / 入库单" :loading="billingOrdersLoading" loading-text="订单加载中" :no-data-text="form.customerId ? '该客户暂无已入库订单' : '请先选择客户'" :disabled="!form.customerId" @update:model-value="handleLineOrderChange(line, String($event || ''))"><el-option v-for="order in billingOrders" :key="String(order.orderId)" :label="orderLabel(order)" :value="String(order.orderId)" /></el-select>
<el-select :model-value="line.sourceFeeId" filterable placeholder="选择费用来源" :loading="Boolean(feeSourcesLoading[line.inboundId])" loading-text="费用来源加载中" :no-data-text="line.inboundId ? '该入库单暂无可选费用' : '请先选择订单'" :disabled="!line.inboundId" @update:model-value="handleFeeSourceChange(line, String($event || ''))"><el-option v-for="source in feeSourceOptions(line)" :key="`${source.sourceFeeId}-${source.sourceFeeVersion}`" :label="feeSourceLabel(source)" :value="String(source.sourceFeeId)" /></el-select>
<el-input v-model="line.feeItemName" placeholder="费用项目(自动带出)" disabled />
<el-input v-model="line.amount" inputmode="decimal" placeholder="金额(CNY)" />
<el-button link type="danger" aria-label="删除费用明细" @click="removeBillLine(index)"><Trash2 :size="16" /></el-button>
</div>
<el-button link type="primary" @click="addBillLine"><Plus :size="15" />添加费用明细</el-button>
<el-form-item v-if="drawerMode === 'adjust'" label="调整原因" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请说明本次调整原因" /></el-form-item>
</template>
<template v-else>
<div class="form-grid two-cols">
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" placeholder="请选择成本类型" style="width: 100%" @change="handleCostTypeChange"><el-option label="单票成本" value="SINGLE_ORDER" /><el-option label="整柜公共成本" value="CONTAINER_SHARED" /></el-select></el-form-item>
<el-form-item label="金额(CNY)" prop="amount"><el-input v-model="form.amount" inputmode="decimal" :readonly="props.mode === 'receipts'" :placeholder="props.mode === 'receipts' ? '由账单分配金额自动汇总' : '请输入成本金额'" /></el-form-item>
</div>
<div v-if="props.mode === 'costs' && costOptionsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="costOptionsError" show-icon /><el-button link type="primary" :loading="costOptionsLoading" @click="loadCostOptions(form.customerId)">重新加载业务对象</el-button></div>
<el-form-item v-if="props.mode === 'costs' && form.costType === 'SINGLE_ORDER'" label="关联订单" prop="referenceNo"><el-select v-model="form.referenceNo" filterable placeholder="请选择客户订单" :loading="costOptionsLoading" loading-text="订单加载中" :no-data-text="form.customerId ? '该客户暂无可选订单' : '请先选择客户'" :disabled="!form.customerId" style="width: 100%"><el-option v-for="order in costOrders" :key="String(order.orderId)" :label="orderOptionLabel(order)" :value="String(order.orderId)" /></el-select></el-form-item>
<el-form-item v-if="props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" label="关联柜次" prop="referenceNo"><el-select v-model="form.referenceNo" filterable placeholder="请选择客户订单关联的柜次" :loading="costOptionsLoading" loading-text="柜次加载中" :no-data-text="form.customerId ? '该客户订单暂无关联柜次' : '请先选择客户'" :disabled="!form.customerId" style="width: 100%"><el-option v-for="container in costContainers" :key="container.id" :label="containerOptionLabel(container)" :value="container.id" /></el-select></el-form-item>
<template v-if="props.mode === 'receipts'">
<el-form-item label="收款方式" prop="paymentMethod"><el-select v-model="form.paymentMethod" placeholder="请选择收款方式" style="width: 100%"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item>
<div class="allocation-title">账单分配</div>
<div v-if="receiptBillsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="receiptBillsError" show-icon /><el-button link type="primary" :loading="receiptBillsLoading" @click="loadReceiptBills(form.customerId)">重新加载未结清账单</el-button></div>
<p v-else class="form-hint">选择未结清对账单后自动带出未收金额,可按本次实际到账金额调整分配。</p>
<div v-for="(allocation, index) in allocations" :key="index" class="allocation-row"><el-select :model-value="allocation.billId" filterable placeholder="请选择未结清账单" :loading="receiptBillsLoading" loading-text="账单加载中" :no-data-text="form.customerId ? '该客户暂无未结清账单' : '请先选择客户'" :disabled="!form.customerId" @update:model-value="handleAllocationBillChange(index, String($event || ''))"><el-option v-for="bill in receiptBills" :key="bill.id" :label="receiptBillLabel(bill)" :value="bill.id" :disabled="allocationBillDisabled(bill.id, index)" /></el-select><el-input v-model="allocation.amount" inputmode="decimal" placeholder="本次分配金额(CNY)" @input="syncReceiptAmount" /><el-button link type="danger" aria-label="删除分配" @click="removeAllocation(index)"><Trash2 :size="16" /></el-button></div>
<el-button link type="primary" @click="addAllocation"><Plus :size="15" />添加账单分配</el-button>
</template>
<el-form-item v-if="props.mode === 'costs'" label="费用项目" prop="feeName"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" /></el-form-item>
<el-alert v-if="props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" type="info" :closable="false" show-icon title="整柜成本保存时由后端从 Firstleg 获取真实柜内订单体积快照并完成分摊。" />
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(可选)" /></el-form-item>
</template>
</FormSection>
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。">
<label class="file-picker" :class="{ 'is-disabled': saving }"><FileUp :size="16" />{{ uploadedVoucher ? `已上传:${uploadedVoucher.fileName}` : file?.name || '选择凭证图片' }}<input type="file" accept="image/*" :disabled="saving" @change="selectFile" /></label>
<p v-if="voucherError" class="voucher-error" role="alert">{{ voucherError }}</p>
<p v-else-if="uploadedVoucher" class="voucher-success">凭证 fileId:{{ uploadedVoucher.fileId }},创建失败时可直接重试。</p>
</FormSection>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : saving ? '上传并保存中' : '上传并保存' }}</el-button></div></template>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-page { min-width: 0; }
.operation-error, .drawer-error { display: grid; gap: 8px; padding: 12px; }
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
.row-actions .el-button { margin-left: 0; }
.file-picker { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 10px 14px; border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; color: var(--cc-text); overflow-wrap: anywhere; }
.file-picker.is-disabled { cursor: not-allowed; color: var(--cc-muted); background: var(--el-fill-color-light); }
.file-picker input { display: none; }
.voucher-error { margin: 8px 0 0; color: var(--el-color-danger); }
.voucher-success { margin: 8px 0 0; color: var(--el-color-success); overflow-wrap: anywhere; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
.allocation-title { margin: 4px 0 8px; font-weight: 600; }
.option-error { display: grid; justify-items: start; gap: 4px; margin-bottom: 10px; }
.option-error .el-alert { width: 100%; }
.allocation-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
.bill-line-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(180px, 1.2fr) minmax(140px, 1fr) minmax(110px, .7fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
.form-hint { margin: 0 0 10px; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.bill-summary { margin-bottom: 16px; }
.revision-error { display: grid; gap: 8px; }
.revision-list { display: grid; gap: 12px; }
.revision-item { padding-bottom: 12px; border-bottom: 1px solid var(--el-border-color-lighter); }
.revision-heading, .revision-meta, .revision-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.revision-heading span, .revision-meta, .revision-empty { color: var(--cc-muted); }
.revision-meta { flex-wrap: wrap; justify-content: flex-start; margin: 6px 0; font-size: 13px; }
.revision-line { padding: 5px 0; }
.revision-line b { white-space: nowrap; }
.revision-empty { margin: 6px 0 0; }
.detail-line { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .8fr) auto; gap: 12px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
.detail-line span { color: var(--cc-muted); overflow-wrap: anywhere; }
.detail-line b { white-space: nowrap; }
@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } .bill-line-row { grid-template-columns: 1fr 1fr; } .drawer-footer .el-button { min-height: 44px; } :deep(.bill-summary .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { .drawer-footer { flex-wrap: wrap; } .drawer-footer .el-button { flex: 1 1 120px; } .allocation-row, .bill-line-row, .detail-line { grid-template-columns: 1fr; } .allocation-row .el-button, .bill-line-row .el-button { justify-self: end; } .revision-heading, .revision-line { align-items: flex-start; } :deep(.bill-summary .el-descriptions__cell) { display: block; width: 100%; } }
</style>