Files
one-pipe-system/src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue
2026-09-12 11:27:50 +08:00

575 lines
18 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.
<template>
<ElDialog
:model-value="modelValue"
:title="dialogTitle"
width="760px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@closed="handleClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="140px">
<ElFormItem label="收款方式" prop="payment_method_id">
<ElSelect
v-model="form.payment_method_id"
placeholder="请选择收款方式"
style="width: 100%"
:loading="paymentMethodsLoading"
>
<ElOption
v-for="item in enabledPaymentMethods"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
<div v-if="!paymentMethodsLoading && !enabledPaymentMethodCount" class="form-tip">
暂无可用的收款方式请联系管理员
</div>
</ElFormItem>
<ElFormItem label="核销账单" required>
<div class="bill-selector">
<div v-if="billsLoading" class="bill-selector__empty">加载中...</div>
<ElEmpty
v-else-if="!candidateBills.length"
description="暂无可核销账单"
:image-size="60"
/>
<div v-else class="bill-selector__list">
<div v-for="bill in candidateBills" :key="bill.id" class="bill-row">
<ElCheckbox
:model-value="selectedBillIds.includes(bill.id)"
@change="toggleBill(bill)"
/>
<div class="bill-row__main">
<div class="bill-row__title">账单 #{{ bill.id }}</div>
<div class="bill-row__meta">
单号{{ bill.source_no || '-' }} · 未核销
{{ formatCollectionCurrency(bill.remaining_amount) }}
</div>
</div>
<ElInputNumber
v-if="selectedBillIds.includes(bill.id)"
v-model="amountMap[bill.id]"
:min="0"
:max="fenToYuan(bill.remaining_amount)"
:precision="2"
:step="1"
size="small"
style="width: 160px"
/>
</div>
</div>
</div>
<div class="bill-selector__total">
已选 {{ selectedBillIds.length }} 张账单核销合计
{{ formatCollectionCurrency(totalAmountFen) }}
</div>
</ElFormItem>
<ElFormItem label="付款金额" prop="paid_amount">
<ElInputNumber
v-model="form.paid_amount"
:min="0"
:precision="2"
:step="1"
style="width: 220px"
/>
<span class="amount-tip">本次线下收款金额不得小于核销合计</span>
</ElFormItem>
<ElFormItem label="付款方名称" prop="payer_name">
<ElInput
v-model="form.payer_name"
maxlength="100"
show-word-limit
placeholder="请输入付款方名称"
/>
</ElFormItem>
<ElFormItem label="付款时间" prop="paid_at">
<ElDatePicker
v-model="form.paid_at"
type="datetime"
placeholder="请选择付款时间"
value-format="YYYY-MM-DDTHH:mm:ssZ"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="外部交易流水号" prop="external_transaction_no">
<ElInput
v-model="form.external_transaction_no"
maxlength="128"
show-word-limit
placeholder="请输入经人工核对的外部交易流水号"
/>
</ElFormItem>
<ElFormItem label="付款凭证" prop="payment_voucher_keys">
<VoucherUpload
ref="uploadRef"
v-model="form.payment_voucher_keys"
voucher-name="付款凭证"
:max-count="5"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('payment_voucher_keys')"
/>
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElInput
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填"
/>
</ElFormItem>
<ElFormItem v-if="isActing" label="代办原因" prop="acting_reason">
<ElInput
v-model="form.acting_reason"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="超级管理员代办必须填写原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
<ElButton
type="primary"
:loading="submitting || voucherUploading"
:disabled="voucherUploading || !enabledPaymentMethodCount"
@click="handleSubmit"
>
{{ voucherUploading ? '凭证上传中...' : '提交' }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { EmployeeCollectionService } from '@/api/modules'
import type {
EmployeeCollectionAllocation,
EmployeeCollectionAllocationRequest,
EmployeeCollectionApplication,
EmployeeCollectionApplicationRequest,
EmployeeCollectionBill,
EmployeeCollectionPaymentMethod
} from '@/types/api'
import { fenToYuan, yuanToFen } from '@/utils/business/format'
import { getErrorMessage, toVoucherKeyList } from '@/utils/business'
import { useUserStore } from '@/store/modules/user'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import {
canCreateApplication,
formatCollectionCurrency,
normalizeCollectionList
} from '../../employeeCollectionDisplay'
interface Props {
modelValue: boolean
application?: EmployeeCollectionApplication | null
presetBill?: EmployeeCollectionBill | null
}
interface BillOption {
id: number
source_no?: string | null
remaining_amount: number
}
const props = withDefaults(defineProps<Props>(), {
application: null,
presetBill: null
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const paymentMethodsLoading = ref(false)
const billsLoading = ref(false)
const submitting = ref(false)
const voucherUploading = ref(false)
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
const candidateBills = ref<BillOption[]>([])
const selectedBillIds = ref<number[]>([])
const amountMap = reactive<Record<number, number>>({})
const form = reactive({
payment_method_id: undefined as number | undefined,
paid_amount: 0,
payer_name: '',
paid_at: '',
external_transaction_no: '',
payment_voucher_keys: [] as string[],
remark: '',
acting_reason: ''
})
const isActing = computed(() => userStore.isSuperAdmin)
const isResubmit = computed(() => !!props.application)
const dialogTitle = computed(() => (isResubmit.value ? '修改并重新提交核销申请' : '创建核销申请'))
const enabledPaymentMethods = computed(() => paymentMethods.value.filter((item) => item.enabled))
const enabledPaymentMethodCount = computed(() => enabledPaymentMethods.value.length)
const paidAmountFen = computed(() => yuanToFen(form.paid_amount) || 0)
const totalAmountFen = computed(() =>
selectedBillIds.value.reduce((total, billId) => total + (yuanToFen(amountMap[billId]) || 0), 0)
)
const rules = computed<FormRules>(() => {
const base: FormRules = {
payment_method_id: [{ required: true, message: '请选择收款方式', trigger: 'change' }],
paid_amount: [
{ required: true, message: '请输入付款金额', trigger: 'blur' },
{
validator: (_rule, _value, callback) => {
if (paidAmountFen.value <= 0) {
callback(new Error('付款金额必须大于 0'))
return
}
if (paidAmountFen.value < totalAmountFen.value) {
callback(new Error('付款金额不能小于核销合计'))
return
}
callback()
},
trigger: 'change'
}
],
payer_name: [{ required: true, message: '请输入付款方名称', trigger: 'blur' }],
paid_at: [{ required: true, message: '请选择付款时间', trigger: 'change' }],
external_transaction_no: [
{ required: true, message: '请输入外部交易流水号', trigger: 'blur' }
],
payment_voucher_keys: [
{
required: true,
validator: (_rule, value, callback) => {
if (Array.isArray(value) && value.length) callback()
else callback(new Error('请上传付款凭证1-5 个)'))
},
trigger: 'change'
}
]
}
if (isActing.value) {
base.acting_reason = [{ required: true, message: '请填写代办原因', trigger: 'blur' }]
}
return base
})
watch(totalAmountFen, (total) => {
if (paidAmountFen.value < total) {
form.paid_amount = fenToYuan(total)
}
})
const loadPaymentMethods = async () => {
paymentMethodsLoading.value = true
try {
const res = await EmployeeCollectionService.getPaymentMethods({ page: 1, page_size: 100 })
if (res.code === 0) {
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
} finally {
paymentMethodsLoading.value = false
}
}
const toBillOption = (bill: {
id: number
source_no?: string | null
remaining_amount?: number | null
}): BillOption => ({
id: bill.id,
source_no: bill.source_no,
remaining_amount: bill.remaining_amount ?? 0
})
const loadCandidateBills = async () => {
billsLoading.value = true
try {
const res = await EmployeeCollectionService.getBills({ page: 1, page_size: 100 })
const list: BillOption[] =
res.code === 0
? normalizeCollectionList<EmployeeCollectionBill>(res.data)
.filter((bill) => canCreateApplication(bill))
.map((bill) => toBillOption(bill))
: []
if (props.presetBill && !list.some((bill) => bill.id === props.presetBill?.id)) {
list.unshift(toBillOption(props.presetBill))
}
candidateBills.value = list
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取可核销账单失败'))
} finally {
billsLoading.value = false
}
}
const normalizePaidAtForPicker = (value?: string | null): string => {
if (!value) return ''
const match = value
.trim()
.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?\s*(Z|[+-]\d{2}:?\d{2})?$/)
if (!match) return value
const base = `${match[1]}T${match[2]}`
const zone = match[3]
if (!zone) return base
if (zone === 'Z') return `${base}+00:00`
return `${base}${zone.includes(':') ? zone : `${zone.slice(0, 3)}:${zone.slice(3)}`}`
}
const fetchApplicationAllocations = async (
applicationId: number
): Promise<EmployeeCollectionAllocation[]> => {
const res = await EmployeeCollectionService.getApplicationById(applicationId)
if (res.code === 0 && res.data) {
return res.data.allocations || []
}
return []
}
const toggleBill = (bill: BillOption) => {
const index = selectedBillIds.value.indexOf(bill.id)
if (index >= 0) {
selectedBillIds.value.splice(index, 1)
delete amountMap[bill.id]
} else {
selectedBillIds.value.push(bill.id)
amountMap[bill.id] = fenToYuan(bill.remaining_amount)
}
}
const resetState = () => {
form.payment_method_id = undefined
form.paid_amount = 0
form.payer_name = ''
form.paid_at = ''
form.external_transaction_no = ''
form.payment_voucher_keys = []
form.remark = ''
form.acting_reason = ''
selectedBillIds.value = []
Object.keys(amountMap).forEach((key) => delete amountMap[Number(key)])
uploadRef.value?.clearFiles()
}
const initialize = async (): Promise<void> => {
resetState()
await Promise.all([loadPaymentMethods(), loadCandidateBills()])
if (props.application) {
const application = props.application
form.payment_method_id = application.payment_method_id ?? undefined
form.paid_amount = fenToYuan(application.paid_amount)
form.payer_name = application.payer_name || ''
form.paid_at = normalizePaidAtForPicker(application.paid_at)
form.external_transaction_no = application.external_transaction_no || ''
form.payment_voucher_keys = toVoucherKeyList(application.payment_voucher_keys ?? undefined)
form.remark = application.remark || ''
form.acting_reason = application.acting_reason || ''
const allocations = await fetchApplicationAllocations(application.id)
allocations.forEach((allocation) => {
if (!candidateBills.value.some((item) => item.id === allocation.bill_id)) {
candidateBills.value.push({
id: allocation.bill_id,
source_no: allocation.bill_source_no,
remaining_amount: Math.max(
allocation.amount,
(allocation.bill_receivable_amount ?? 0) -
(allocation.bill_received_amount ?? 0) -
(allocation.bill_reserved_amount ?? 0),
0
)
})
}
if (!selectedBillIds.value.includes(allocation.bill_id)) {
selectedBillIds.value.push(allocation.bill_id)
}
amountMap[allocation.bill_id] = fenToYuan(allocation.amount)
})
if (paidAmountFen.value < totalAmountFen.value) {
form.paid_amount = fenToYuan(totalAmountFen.value)
}
} else if (props.presetBill) {
const bill = candidateBills.value.find((item) => item.id === props.presetBill?.id)
selectedBillIds.value = [props.presetBill.id]
amountMap[props.presetBill.id] = fenToYuan(
bill?.remaining_amount ?? props.presetBill.remaining_amount ?? 0
)
}
}
watch(
() => props.modelValue,
(visible) => {
if (visible) void initialize()
}
)
const handleClosed = () => {
resetState()
formRef.value?.clearValidate()
}
const buildAllocations = (): EmployeeCollectionAllocationRequest[] | null => {
const payload: EmployeeCollectionAllocationRequest[] = []
for (const billId of selectedBillIds.value) {
const amount = yuanToFen(amountMap[billId]) || 0
const option = candidateBills.value.find((bill) => bill.id === billId)
if (amount <= 0) {
ElMessage.warning('请填写每张账单的核销金额')
return null
}
if (option && amount > option.remaining_amount) {
ElMessage.warning('核销金额不能超过账单未核销金额')
return null
}
payload.push({ bill_id: billId, amount })
}
return payload
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
if (!selectedBillIds.value.length) {
ElMessage.warning('请至少选择一张待核销账单')
return
}
const allocations = buildAllocations()
if (!allocations) return
const payload: EmployeeCollectionApplicationRequest = {
payment_method_id: form.payment_method_id as number,
paid_amount: paidAmountFen.value,
paid_at: form.paid_at,
payer_name: form.payer_name.trim(),
external_transaction_no: form.external_transaction_no.trim(),
payment_voucher_keys: toVoucherKeyList(form.payment_voucher_keys),
remark: form.remark.trim() || undefined,
allocations,
acting_reason: isActing.value ? form.acting_reason.trim() : undefined
}
submitting.value = true
try {
const res = props.application
? await EmployeeCollectionService.updateApplication(props.application.id, payload)
: await EmployeeCollectionService.createApplication(payload)
if (res.code !== 0) return
ElMessage.success(isResubmit.value ? '重新提交成功' : '核销申请已提交')
emit('update:modelValue', false)
emit('success')
} catch (error) {
ElMessage.error(getErrorMessage(error, '提交核销申请失败'))
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.form-tip {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.amount-tip {
margin-left: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.bill-selector {
width: 100%;
&__empty {
padding: 16px 0;
color: var(--el-text-color-secondary);
text-align: center;
}
&__list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
padding-right: 4px;
overflow-y: auto;
}
&__total {
margin-top: 10px;
font-size: 13px;
color: var(--el-text-color-regular);
}
}
.bill-row {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
&__main {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
&__meta {
margin-top: 2px;
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
}
.dialog-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
</style>