Files
admin-web/src/modules/overseas-business/components/OverseasFeeEditor.vue
T
2026-10-26 13:43:08 +08:00

151 lines
13 KiB
Vue

<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { actOnOverseasFee, createOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFee, type SaveOverseasFee } from '../api/overseasFees'
import { businessLabels, feePermissions, sameProjectFees, sameProjectFeeSummary } from '../overseasFeePresentation'
export interface FeeItemOption { feeCode: string; feeTypeName: string; defaultPrice: number | null }
const props = defineProps<{ fee: OverseasFee | null; items: FeeItemOption[]; business?: BusinessFeeOption }>()
const access = useAccessPreviewStore()
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const saving = ref(false), optionsLoading = ref(false), options = ref<BusinessFeeOption[]>([]), formRef = ref<FormInstance>()
const error = ref<ApiErrorDetails | null>(null)
const today = () => new Date().toLocaleDateString('sv-SE')
const form = reactive({ businessType: 'WAREHOUSE_TRANSFER', businessId: '', feeItemCode: '', feeRemark: '', settlementMode: '' as '' | 'BILL' | 'WALLET', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: today() })
const existingFees = ref<OverseasFee[]>([]), existingLoading = ref(false), existingError = ref('')
const duplicates = computed(() => sameProjectFees(existingFees.value, form.feeItemCode, props.fee?.direction || 'RECEIVABLE', props.fee?.sourceFeeId))
const title = computed(() => props.fee ? '调整费用' : '新增费用')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-fee-editor', label: title, isOpen: open, model: form, requiredPermission: computed(() => feePermissions.edit) })
const rules: FormRules<typeof form> = {
businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
businessId: [{ required: true, message: '请选择业务订单', trigger: 'change' }],
feeItemCode: [{ required: true, message: '请选择费用项目', trigger: 'change' }],
amount: [{ validator: (_rule, value: number, callback) => callback(Number.isFinite(value) && value > 0 ? undefined : new Error('金额须大于0')), trigger: 'blur' }],
}
let optionSequence = 0
let savedFingerprint = '', requestKey = ''
let openedBusinessKey = ''
const businessKey = () => props.business ? `${props.business.businessType}:${props.business.businessId}:${props.business.customerId}` : ''
const automaticRentCode = 'FEE_TYPE_077'
const manualItems = computed(() => props.items.filter(item => item.feeCode !== automaticRentCode))
const hasExistingItem = computed(() => !!props.fee && manualItems.value.some(item => item.feeCode === props.fee?.feeItemCode))
async function loadOptions(keyword = '') {
if (props.business) return
const sequence = ++optionSequence
optionsLoading.value = true
try {
const result = await listBusinessFeeOptions({ businessType: form.businessType, keyword })
if (sequence === optionSequence) {
const current = selected.value
options.value = current && !result.some(row => row.businessId === current.businessId) ? [current, ...result] : result
}
} catch (failure) { if (sequence === optionSequence) error.value = getApiErrorDetails(failure, '业务订单加载失败') }
finally { if (sequence === optionSequence) optionsLoading.value = false }
}
function changeBusinessType() { form.businessId = ''; options.value = []; void loadOptions() }
const selected = computed(() => props.business || options.value.find(row => row.businessId === form.businessId))
const selectedItem = computed(() => manualItems.value.find(item => item.feeCode === form.feeItemCode))
let existingSequence = 0
async function loadExistingFees() {
const sequence = ++existingSequence
existingFees.value = []; existingError.value = ''; existingLoading.value = false
if (!open.value || !selected.value) return
existingLoading.value = true
try {
const result = await listBusinessOverseasFees(selected.value, { page: 1, size: Number.MAX_SAFE_INTEGER }, props.fee?.direction || 'RECEIVABLE')
if (sequence === existingSequence) existingFees.value = result.records
} catch (failure) { if (sequence === existingSequence) existingError.value = getApiErrorDetails(failure, '已有费用加载失败').message }
finally { if (sequence === existingSequence) existingLoading.value = false }
}
watch([open, selected], loadExistingFees)
watch(open, async value => {
if (!value) { optionSequence++; optionsLoading.value = false; return }
savedFingerprint = ''; requestKey = ''
openedBusinessKey = businessKey()
error.value = null
const fee = props.fee
Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() })
options.value = fee ? [{ businessId: fee.businessId || '', businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, orderId: fee.orderId, warehouseId: fee.warehouseId }] : []
captureBaseline()
formRef.value?.clearValidate()
if (!fee && !props.business) await loadOptions()
})
const sourceTypes: Record<string, string> = {
LASTLEG_ORDER: 'OVERSEAS_ORDER_FEE', WAREHOUSE_TRANSFER: 'OVERSEAS_WAREHOUSE_FEE', RETURN_ORDER: 'OVERSEAS_WAREHOUSE_FEE',
DISCARD_ORDER: 'OVERSEAS_WAREHOUSE_FEE', RELABEL_ORDER: 'OVERSEAS_RELABEL_FEE', US_TRUCK: 'OVERSEAS_TRUCK_FEE',
LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE',
OVERSEAS_INBOUND: 'OVERSEAS_INBOUND_FEE', DELIVERY_ORDER: 'OVERSEAS_DELIVERY_FEE',
}
const selectableTypes = Object.keys(sourceTypes)
async function save() {
if (!access.can(feePermissions.edit) || saving.value || optionsLoading.value || !await formRef.value?.validate().catch(() => false)) return
if (form.feeItemCode === automaticRentCode) { ElMessage.error('仓租自动计算,请勿手动添加'); return }
const order = selected.value
if (!order) { ElMessage.error('请重新选择业务订单'); return }
if (existingLoading.value || existingError.value) { ElMessage.warning(existingError.value || '正在核对已有费用'); return }
if (duplicates.value.length && !form.feeRemark.trim()) { ElMessage.warning('此单已有相同费用项目,请填写费用说明后继续添加'); return }
const wrongFee = props.business && props.fee && (props.fee.businessId !== props.business.businessId || props.fee.businessType !== props.business.businessType || props.fee.customerId !== props.business.customerId)
if (openedBusinessKey !== businessKey() || wrongFee || (props.business && (form.businessId !== props.business.businessId || form.businessType !== props.business.businessType))) {
ElMessage.error('单据已变化,请关闭后重新添加费用'); return
}
if (!props.fee && !sourceTypes[order.businessType]) { ElMessage.error('请通过本业务的费用流程添加'); return }
const item = selectedItem.value
if (!item && !props.fee) { ElMessage.error('请重新选择费用项目'); return }
saving.value = true; error.value = null
try {
const payload: SaveOverseasFee = {
...order, sourceType: props.fee?.sourceType || sourceTypes[order.businessType]!,
feeItemCode: form.feeItemCode, feeItemName: item?.feeTypeName || props.fee!.feeItemName, direction: props.fee?.direction || 'RECEIVABLE',
businessDate: form.businessDate, currency: form.currency, amount: form.amount, feeRemark: form.feeRemark.trim() || undefined,
...(form.settlementMode ? { settlementMode: form.settlementMode } : {}),
...(props.fee?.direction === 'PAYABLE' ? {
supplierId: props.fee.supplierId, supplierName: props.fee.supplierName, supplierCategory: props.fee.supplierCategory,
settlementMode: props.fee.settlementMode, paidAt: props.fee.paidAt, paymentMethod: props.fee.paymentMethod,
paymentReferenceNo: props.fee.paymentReferenceNo, paymentVoucherFileIds: props.fee.paymentVoucherFileIds,
} : {}),
}
const fingerprint = JSON.stringify(payload)
if (fingerprint !== savedFingerprint || !requestKey) { savedFingerprint = fingerprint; requestKey = crypto.randomUUID() }
const idempotencyKey = requestKey
if (props.fee) await actOnOverseasFee(props.fee.sourceFeeId, 'ADJUST', { expectedVersion: props.fee.sourceVersion, fee: payload, idempotencyKey })
else await createOverseasFee(payload, idempotencyKey)
captureBaseline(); open.value = false; ElMessage.success(props.fee ? '费用已调整' : '费用已创建'); emit('saved')
} catch (failure) { error.value = getApiErrorDetails(failure, '费用保存失败') }
finally { saving.value = false }
}
</script>
<template>
<AppDialog append-to-body v-model="open" :title="title" width="680px" class="admin-density-dialog admin-workbench is-dialog" confirm-text="保存" :confirm-disabled="optionsLoading || !access.can(feePermissions.edit)" :confirm-loading="saving" fullscreenable @confirm="save">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="density-form" :disabled="saving">
<div class="fee-editor-grid">
<el-form-item v-if="!business" label="业务类型" prop="businessType"><el-select v-model="form.businessType" :disabled="!!fee" @change="changeBusinessType"><el-option v-for="type in selectableTypes" :key="type" :value="type" :label="businessLabels[type]" /></el-select></el-form-item>
<el-form-item v-if="!business" label="业务订单" prop="businessId"><el-select v-model="form.businessId" filterable remote :remote-method="loadOptions" :loading="optionsLoading" :disabled="!!fee" placeholder="搜索订单号"><el-option v-for="row in options" :key="row.businessId" :value="row.businessId" :label="`${row.businessNo} · ${row.customerName}`" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && fee.feeItemCode !== automaticRentCode && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :max="999999999.99" :precision="2" :controls="false" /></el-form-item>
<el-form-item v-if="(props.fee?.direction || 'RECEIVABLE') === 'RECEIVABLE'" label="结算方式"><el-select v-model="form.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item>
<el-form-item label="费用说明" class="fee-remark" :required="duplicates.length > 0"><el-input v-model="form.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item>
</div>
<div v-if="duplicates.length" class="fee-existing" role="status"><span>此单已有相同费用,确认本次为新增作业后可继续添加。</span><p v-for="row in duplicates" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</p></div>
<div v-if="existingError" class="fee-existing-error">{{ existingError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></div>
</el-form>
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</AppDialog>
</template>
<style scoped>
.fee-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.fee-editor-grid :deep(.el-select), .fee-editor-grid :deep(.el-date-editor), .fee-editor-grid :deep(.el-input-number) { width: 100%; }
.fee-remark { grid-column: 1 / -1; }
.fee-existing { color: var(--cc-text-secondary, #606266); font-size: 13px; margin-bottom: 12px; }
.fee-existing p { margin: 4px 0; }
.fee-existing-error { color: var(--cc-danger, #f56c6c); }
</style>