This commit is contained in:
2026-09-09 15:26:43 +08:00
parent a2515119b7
commit a869467efb
13 changed files with 420 additions and 85 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { computed, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getFinancePaymentVoucher, downloadFinancePaymentVoucher } from '@/modules/finance'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -17,6 +18,42 @@ const suppliers = ref<ContainerFeeSupplierOption[]>([])
const loading = ref(false)
const actionLoading = ref(false)
const voucherUploading = ref(false)
const voucherInput = ref<HTMLInputElement | null>(null)
const voucherNames = reactive<Record<string, string>>({})
let uploadSequence = 0
let previewSequence = 0
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewUrl = ref('')
const previewName = ref('付款凭证')
const previewIsImage = ref(false)
function clearPreview() {
previewSequence++
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
async function previewVoucher(customerId: string, fileId: string) {
clearPreview()
const sequence = previewSequence
previewOpen.value = true; previewLoading.value = true; previewError.value = null
previewName.value = voucherNames[fileId] || '付款凭证'
try {
const metadata = await getFinancePaymentVoucher(customerId, fileId)
const blob = await downloadFinancePaymentVoucher(customerId, fileId)
if (sequence !== previewSequence) return
previewName.value = metadata.fileName || '付款凭证'
voucherNames[fileId] = previewName.value
previewIsImage.value = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'].includes(metadata.contentType)
previewUrl.value = URL.createObjectURL(new Blob([blob], { type: previewIsImage.value ? metadata.contentType : 'application/octet-stream' }))
} catch (cause) {
if (sequence === previewSequence) previewError.value = getApiErrorDetails(cause, '付款凭证读取失败')
} finally {
if (sequence === previewSequence) previewLoading.value = false
}
}
watch(previewOpen, (open) => { if (!open) clearPreview() })
onBeforeUnmount(() => { uploadSequence++; clearPreview() })
const error = ref<ApiErrorDetails | null>(null)
const dialogOpen = ref(false)
const editing = ref<OverseasContainerFeeSource | null>(null)
@@ -32,6 +69,8 @@ const feeItemLabel = (value: string) => feeItems.find((item) => item.value === v
const reviewLabels: Record<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' }
const form = reactive<{ customerId: string; feeItemCode: OverseasContainerFeeItem; direction: OverseasContainerFeeDirection; businessDate: string; amount: number | null; supplierId: string; settlementMode: ContainerFeeSettlementMode; paidAt: string; paymentMethod: ContainerFeePaymentMethod; paymentReferenceNo: string; paymentVoucherFileIds: string[] }>({ customerId: '', feeItemCode: 'PORT_FEE', direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), amount: null, supplierId: '', settlementMode: 'ON_ACCOUNT', paidAt: '', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: '', paymentVoucherFileIds: [] })
watch(() => form.customerId, () => { uploadSequence++; voucherUploading.value = false; form.paymentVoucherFileIds = [] }, { flush: 'sync' })
watch(dialogOpen, (open) => { if (!open) { uploadSequence++; voucherUploading.value = false } }, { flush: 'sync' })
function key(scope: string) { return `${scope}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` }
function customerName(customerId: string) { return customers.value.find((item) => item.customerId === customerId)?.customerName || null }
function selectedSupplier() { return suppliers.value.find((item) => item.id === form.supplierId) || null }
@@ -63,7 +102,7 @@ async function load() {
function openCreate() {
editing.value = null; form.customerId = customers.value[0]?.customerId || ''; form.feeItemCode = 'PORT_FEE'; form.direction = 'PAYABLE'; form.businessDate = new Date().toISOString().slice(0, 10); form.amount = null; form.supplierId = ''; form.settlementMode = 'ON_ACCOUNT'; form.paidAt = ''; form.paymentMethod = 'BANK_TRANSFER'; form.paymentReferenceNo = ''; form.paymentVoucherFileIds = []; dialogOpen.value = true
}
function openAdjust(row: any) {
function openAdjust(row: OverseasContainerFeeSource) {
editing.value = row; form.customerId = row.customerId; form.feeItemCode = row.feeItemCode; form.direction = row.direction; form.businessDate = row.businessDate; form.amount = row.amount; form.supplierId = row.supplierId || ''; form.settlementMode = row.settlementMode === 'DIRECT_PAID' ? 'DIRECT_PAID' : 'ON_ACCOUNT'; form.paidAt = row.paidAt || ''; form.paymentMethod = (row.paymentMethod as ContainerFeePaymentMethod) || 'BANK_TRANSFER'; form.paymentReferenceNo = row.paymentReferenceNo || ''; form.paymentVoucherFileIds = [...row.paymentVoucherFileIds]; dialogOpen.value = true
}
async function mutate(task: () => Promise<unknown>, success: string) {
@@ -73,6 +112,7 @@ async function mutate(task: () => Promise<unknown>, success: string) {
finally { actionLoading.value = false }
}
async function save() {
if (voucherUploading.value || actionLoading.value) return
if (!form.customerId || !form.businessDate || !form.amount || form.amount <= 0) return ElMessage.warning('请完整填写客户、业务日期和大于 0 的金额')
if (!props.containerNo) return ElMessage.warning('请先补录真实集装箱号后再维护尾程费用')
if (form.direction === 'PAYABLE' && !selectedSupplier()) return ElMessage.warning('应付费用必须选择供应商')
@@ -81,24 +121,38 @@ async function save() {
if (editing.value) await mutate(() => adjustOverseasContainerFee(editing.value!.sourceFeeId, editing.value!.sourceVersion, payload, key('container-fee-adjust')), '费用调整已提交审核')
else await mutate(() => createOverseasContainerFee(payload, key('container-fee-create')), '费用草稿已创建')
}
async function uploadVoucher(file: UploadFile) {
if (!form.customerId || !file.raw) { ElMessage.warning('请先选择费用所属客户'); return }
async function uploadVoucher(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || voucherUploading.value) return
if (!form.customerId) { ElMessage.warning('请先选择费用所属客户'); return }
const customerId = form.customerId
const sequence = ++uploadSequence
voucherUploading.value = true
try { const uploaded = await uploadContainerFeePaymentVoucher(form.customerId, file.raw); form.paymentVoucherFileIds = [uploaded.fileId]; ElMessage.success('付款凭证已上传') }
catch (cause) { ElMessage.error(getApiErrorMessage(cause, '付款凭证上传失败')) }
finally { voucherUploading.value = false }
try {
const uploaded = await uploadContainerFeePaymentVoucher(customerId, file)
if (sequence !== uploadSequence) return
form.paymentVoucherFileIds = [uploaded.fileId]
voucherNames[uploaded.fileId] = uploaded.fileName || file.name
ElMessage.success('付款凭证已上传')
} catch (cause) {
if (sequence === uploadSequence) ElMessage.error(getApiErrorMessage(cause, '付款凭证上传失败'))
} finally {
if (sequence === uploadSequence) voucherUploading.value = false
}
}
async function submit(row: any) {
async function submit(row: OverseasContainerFeeSource) {
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { type: 'warning' })
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
}
async function review(row: any, decision: 'APPROVE' | 'REJECT') {
async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'REJECT') {
let reason: string | null = null
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { type: 'warning' })
await mutate(() => reviewOverseasContainerFee(row.sourceFeeId, decision, reason, key('container-fee-review')), decision === 'APPROVE' ? '费用已审核通过' : '费用已驳回')
}
async function voidFee(row: any) {
async function voidFee(row: OverseasContainerFeeSource) {
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '费用已作废')
}
@@ -113,18 +167,24 @@ onMounted(load)
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
<LoadErrorState v-else-if="error" title="尾程计费加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-table v-else v-loading="loading" :data="rows" empty-text="当前货柜暂无尾程费用">
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions"><el-button v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" link type="primary" @click="submit(row)">提交</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" link type="success" @click="review(row, 'APPROVE')">通过</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" link type="danger" @click="review(row, 'REJECT')">驳回</el-button><el-button v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" link type="primary" @click="openAdjust(row)">调整</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" link type="danger" @click="voidFee(row)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions"><el-button v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" link type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" link type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" link type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" link type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" link type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column>
<el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column>
<el-table-column prop="supplierName" label="供应商" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.supplierName || '—' }}</template></el-table-column>
<el-table-column prop="amount" label="金额" min-width="130" align="right"><template #default="{ row }">¥{{ row.amount.toFixed(2) }}</template></el-table-column>
<el-table-column label="付款凭证" min-width="180"><template #default="{ row }"><div v-if="row.paymentVoucherFileIds.length" class="voucher-list"><el-button v-for="(fileId, index) in (row.paymentVoucherFileIds as string[])" :key="fileId" link type="primary" @click="previewVoucher(row.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button></div><span v-else>—</span></template></el-table-column>
<el-table-column prop="businessDate" label="业务日期" width="120" />
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table>
<AppDialog v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="save">
<el-form label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing)" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="uploadVoucher"><el-button :loading="voucherUploading">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button></el-upload></el-form-item></template></template></div></el-form>
<AppDialog v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<el-form label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing) || voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></template></template></div></el-form>
</AppDialog>
<AppDialog v-model="previewOpen" :title="previewName" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
<p v-if="previewLoading">正在读取付款凭证…</p>
<div v-else-if="previewError"><el-alert type="error" :closable="false" :title="previewError.message" /><ApiErrorMeta :details="previewError" /></div>
<div v-else-if="previewUrl" class="voucher-preview"><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
</AppDialog>
</div>
</template>
@@ -134,6 +194,11 @@ onMounted(load)
.last-leg-fees__header { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.last-leg-fees__header h2 { margin: 0; color: #253553; font-size: 20px; }
.last-leg-fees__header p { margin: 6px 0 0; color: #77839b; }
.voucher-list { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
.voucher-item { display: flex; gap: 8px; max-width: 100%; }
.voucher-list :deep(.el-button) { white-space: normal; overflow-wrap: anywhere; margin-left: 0; }
.voucher-preview { display: grid; gap: 16px; }
.voucher-preview img { max-width: 100%; max-height: 65vh; object-fit: contain; margin: auto; }
.row-actions { display: flex; align-items: center; white-space: nowrap; }
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.fee-form-grid :deep(.el-select), .fee-form-grid :deep(.el-date-editor), .fee-form-grid :deep(.el-input-number) { width: 100%; }