Files
admin-web/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue
T
2026-09-09 10:18:12 +08:00

142 lines
17 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 UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { listContainerFeeSupplierOptions, uploadContainerFeePaymentVoucher, type ContainerFeePaymentMethod, type ContainerFeeSettlementMode, type ContainerFeeSupplierOption } from '@/modules/loading-lists'
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerCustomer, type OverseasContainerFeeDirection, type OverseasContainerFeeItem, type OverseasContainerFeeSaveRequest, type OverseasContainerFeeSource } from '../api/overseasContainerFees'
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
const access = useAccessPreviewStore()
const rows = ref<OverseasContainerFeeSource[]>([])
const customers = ref<OverseasContainerCustomer[]>([])
const suppliers = ref<ContainerFeeSupplierOption[]>([])
const loading = ref(false)
const actionLoading = ref(false)
const voucherUploading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const dialogOpen = ref(false)
const editing = ref<OverseasContainerFeeSource | null>(null)
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
const canEdit = computed(() => !props.readonly && access.can('platform:finance:fee-source:edit'))
const canReview = computed(() => !props.readonly && access.can('platform:finance:fee-source:review'))
const feeItems: Array<{ value: OverseasContainerFeeItem; label: string }> = [
{ value: 'PORT_FEE', label: '港口费' }, { value: 'STORAGE_FEE', label: '堆存费' },
{ value: 'CONTAINER_REPAIR_FEE', label: '修箱费' }, { value: 'CRANE_FEE', label: '吊机费' },
{ value: 'TERMINAL_DRAYAGE_FEE', label: '码头拖车费' }, { value: 'OTHER', label: '其他' },
]
const feeItemLabel = (value: string) => feeItems.find((item) => item.value === value)?.label || value
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: [] })
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 }
function saveRequest(row?: OverseasContainerFeeSource): OverseasContainerFeeSaveRequest {
const item = feeItems.find((option) => option.value === form.feeItemCode)
return {
customerId: form.customerId, customerName: customerName(form.customerId), sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER',
businessId: props.containerBookingId, businessNo: props.containerNo || '', orderId: null, inboundId: null, inboundCode: null, containerId: null,
containerBookingId: props.containerBookingId, containerNo: props.containerNo || '', warehouseId: null,
feeItemCode: form.feeItemCode, feeItemName: item?.label || row?.feeItemName || form.feeItemCode, direction: form.direction,
supplierId: form.direction === 'PAYABLE' ? selectedSupplier()?.id || null : null,
supplierName: form.direction === 'PAYABLE' ? selectedSupplier()?.supplierName || null : null,
supplierCategory: form.direction === 'PAYABLE' ? selectedSupplier()?.category || null : null,
settlementMode: form.direction === 'PAYABLE' ? form.settlementMode : null,
paidAt: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paidAt || null : null,
paymentMethod: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paymentMethod : null,
paymentReferenceNo: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paymentReferenceNo.trim() || null : null,
paymentVoucherFileIds: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' && form.paymentVoucherFileIds.length ? form.paymentVoucherFileIds : null,
businessDate: form.businessDate, currency: 'CNY', amount: Number(form.amount),
}
}
async function load() {
if (!canView.value) return
loading.value = true; error.value = null
try { [rows.value, customers.value, suppliers.value] = await Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), props.readonly ? Promise.resolve([]) : listContainerFeeSupplierOptions(null)]) }
catch (cause) { error.value = getApiErrorDetails(cause, '尾程计费加载失败') }
finally { loading.value = false }
}
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) {
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) {
actionLoading.value = true
try { await task(); ElMessage.success(success); dialogOpen.value = false; await load() }
catch (cause) { ElMessage.error(getApiErrorMessage(cause, '尾程计费操作失败')) }
finally { actionLoading.value = false }
}
async function save() {
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('应付费用必须选择供应商')
if (form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' && (!form.paidAt || !form.paymentMethod || (!form.paymentReferenceNo.trim() && form.paymentVoucherFileIds.length === 0))) return ElMessage.warning('直接已付必须填写付款时间、方式,并提供流水号或付款凭证')
const payload = saveRequest(editing.value || undefined)
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 }
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 }
}
async function submit(row: any) {
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { type: 'warning' })
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
}
async function review(row: any, 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) {
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '费用已作废')
}
onMounted(load)
</script>
<template>
<div class="last-leg-fees">
<header class="last-leg-fees__header"><div><h2>{{ title }}</h2><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
<el-alert v-if="readonly" type="info" :closable="false" title="当前路由仅查看海外段费用,写操作请从清关提柜详情进入。" />
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
<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 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 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>
</div>
</template>
<style scoped>
.last-leg-fees { display: grid; gap: 16px; min-width: 0; }
.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; }
.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%; }
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-form-grid { grid-template-columns: 1fr; } }
</style>