2026-09-08 22:03:03 +08:00
|
|
|
|
<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'
|
2026-09-09 00:49:13 +08:00
|
|
|
|
import { listContainerFeeSupplierOptions, uploadContainerFeePaymentVoucher, type ContainerFeePaymentMethod, type ContainerFeeSettlementMode, type ContainerFeeSupplierOption } from '@/modules/loading-lists'
|
2026-09-08 22:03:03 +08:00
|
|
|
|
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerCustomer, type OverseasContainerFeeDirection, type OverseasContainerFeeItem, type OverseasContainerFeeSaveRequest, type OverseasContainerFeeSource } from '../api/overseasContainerFees'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{ containerBookingId: string; containerNo: string | null }>()
|
|
|
|
|
|
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(() => access.can('platform:finance:fee-source:edit'))
|
|
|
|
|
|
const canReview = computed(() => 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), 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>尾程计费</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="!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">
|
2026-09-09 00:49:13 +08:00
|
|
|
|
<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>
|
2026-09-08 22:03:03 +08:00
|
|
|
|
</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>
|