616 lines
51 KiB
Vue
616 lines
51 KiB
Vue
<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>
|