575 lines
18 KiB
Vue
575 lines
18 KiB
Vue
<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>
|