Files
finance-master/apps/finance/src/views/posting/reconciliate/modules/Manual.vue
chenghuan 8971243f23 feat(对账): 新增批量对账功能并优化对账流程
- 添加批量对账模式,支持按金额批量选择账单
- 优化rowKey处理逻辑
- 调整对账表单验证规则,增加金额校验
- 修改时间单位从月到天,提高精确度
- 优化选中账单展示,增加标记和计数功能
- 修复SelectDropdownRender组件选项更新问题
2026-01-16 14:21:14 +08:00

450 lines
13 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 lang="ts" setup>
import type { CreateOrderType, PaymentRowType } from '../types';
import { ref, watch } from 'vue';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { Button, Card, Input, message, Modal, notification } from 'ant-design-vue';
import dayjs from 'dayjs';
import { reconciliateBillsApi } from '#/api/posting/reconciliate';
import { useSysStore } from '#/store';
import Orders from '../components/Orders.vue';
import Selected from '../components/Selected.vue';
import TrainingClass from '../components/TrainingClass.vue';
import VipProduct from '../components/VipProduct.vue';
import WumenCourseProduct from '../components/WumenCourseProduct.vue';
import WumenCourseRecord from '../components/WumenCourseRecord.vue';
import WumenPhysicalProduct from '../components/WumenPhysicalProduct.vue';
import WumenRecharge from '../components/WumenRecharge.vue';
import YiluCourseProduct from '../components/YiluCourseProduct.vue';
import YiluCourseRecord from '../components/YiluCourseRecord.vue';
import YiluPhysicalProduct from '../components/YiluPhysicalProduct.vue';
import YiluRecharge from '../components/YiluRecharge.vue';
const props = withDefaults(
defineProps<{
data?: PaymentRowType[];
show?: boolean;
}>(),
{
show: false,
data: () => [],
},
);
const emit = defineEmits(['update:show']);
const needCreateRecord: Record<string, any> = {
A: WumenCourseRecord,
B: WumenCourseProduct,
C: WumenRecharge,
D: WumenPhysicalProduct,
E: YiluCourseRecord,
F: YiluCourseProduct,
G: YiluRecharge,
H: YiluPhysicalProduct,
I: VipProduct,
J: TrainingClass,
};
const sysStore = useSysStore();
// 批量模式
const isBatchMode = ref(false);
const visible = ref(props.show);
watch(visible, (val) => {
emit('update:show', val);
});
const multipleCurrentIndex = ref<number[]>([0]);
const multipleCurrentData = ref<PaymentRowType[]>([]);
const selectedAmount = ref();
const pendingData = ref<PaymentRowType[]>([]);
// 监听props.data变化更新本地pendingData
watch(
() => props.data,
(newData) => {
pendingData.value = newData.toSorted(
(a, b) => Number(b.orderFlag === 1) - Number(a.orderFlag === 1),
);
multipleCurrentIndex.value = [0];
},
{ immediate: true },
);
function changePendingData(index: number) {
// 单个模式
multipleCurrentIndex.value = [index];
selectedData.value = [];
selectedRef.value?.clearData();
selectedAmount.value = null;
isBatchMode.value = false;
}
function changeBatchSelected() {
selectedData.value = [];
selectedRef.value?.clearData();
if (selectedAmount.value || selectedAmount.value === '0') {
// 按金额批量选中
activeTabKey.value = 'B'; // 批量模式不能选订单和记录,默认到课程商品
isBatchMode.value = true;
multipleCurrentData.value = pendingData.value.filter(
(item) => item.fee === Number.parseFloat(selectedAmount.value),
);
multipleCurrentIndex.value = multipleCurrentData.value.map((item) =>
pendingData.value.indexOf(item),
);
} else {
// 取消批量选中
activeTabKey.value = '0'; // 切换到订单列表
isBatchMode.value = false;
changePendingData(0);
}
}
const tabList = [
{
key: '0',
tab: '订单列表',
},
{
key: 'A',
tab: '吴门医述开课记录',
},
{
key: 'B',
tab: '吴门医述课程商品',
},
{
key: 'C',
tab: '吴门医述充值记录',
},
{
key: 'D',
tab: '吴门医述实物商品',
},
{
key: 'E',
tab: '一路健康开课记录',
},
{
key: 'F',
tab: '一路健康课程商品',
},
{
key: 'G',
tab: '一路健康充值记录',
},
{
key: 'H',
tab: '一路健康实物商品',
},
{
key: 'I',
tab: '开通VIP',
},
{
key: 'J',
tab: '报名培训班',
},
];
const activeTabKey = ref('0');
function changeRecordTab(key: string) {
if (isBatchMode.value && ['0', 'A', 'C', 'E', 'G'].includes(key)) {
message.warning('批量模式下,不能切换到该选项');
return;
}
activeTabKey.value = key;
}
// 确认对账完成
function onCompleteCheck() {
notification.success({
message: '操作成功',
duration: 1,
});
// 删除当前已对账的数据
const currentIndex = multipleCurrentIndex.value[0] || 0;
multipleCurrentIndex.value.forEach((index) => {
pendingData.value.splice(index, 1);
});
// 如果没有待对账数据了,关闭弹框
if (pendingData.value.length === 0) {
visible.value = false;
return;
}
// 清空待处理账单的已选状态
multipleCurrentIndex.value = [];
multipleCurrentData.value = [];
// 如果当前索引超出了数据范围重置到第一条否则下一条删除已处理索引后currentIndex即是下一条
multipleCurrentIndex.value[0] = Math.min(currentIndex, pendingData.value.length - 1);
}
// 其他方式--确认对账 --------------
// 已选列表
const selectedRef = ref();
const errorData = ref<CreateOrderType[]>([]);
const selectedData = ref<CreateOrderType[]>([]);
const needCreateRecordRef = ref();
// 选中
function onSelectRecord(rows: CreateOrderType[]) {
const addData = rows.filter(
(item) => !selectedData.value.some((selected) => selected.id === item.id),
);
selectedData.value.push(...addData);
selectedRef.value?.handleAdd(addData);
}
// 取消选中
function onDeleteChecked(ids: number[]) {
ids.forEach((id) => {
const rowData = selectedData.value.find((item) => item.id === id);
const index = selectedData.value.findIndex((item) => item.id === id);
if (index !== -1) {
selectedData.value.splice(index, 1);
selectedRef.value?.deleteData(id);
}
if (rowData && needCreateRecordRef.value) {
needCreateRecordRef.value.cancelCheck(id);
}
});
}
function completeSubmitCheck(item: CreateOrderType) {
if (!item.realMoney) {
message.error(`${item.productName}》请填写实际金额`);
return true;
}
if (!item.orderMoney) {
message.error(`${item.productName}》请填写订单金额`);
return true;
}
// 单个模式必须填手机号
if (!isBatchMode.value && !item.tel) {
message.error(`${item.productName}》请填写手机号`);
return true;
}
// 单个模式课程、vip必须填开始时间和结束时间
if (!isBatchMode.value && (item.orderType === '1' || item.orderType === '2') && !item.startTime) {
message.error(`${item.productName}》请填写开始时间`);
return true;
}
if (!isBatchMode.value && (item.orderType === '1' || item.orderType === '2') && !item.endTime) {
message.error(`${item.productName}》请填写到期时间`);
return true;
}
// 批量模式课程、vip必须填到期时间
if (isBatchMode.value && (item.orderType === '1' || item.orderType === '2') && !item.endTime) {
message.error(`${item.productName}》请填写到期时间`);
return true;
}
return false;
}
// 确认对账
async function onCompleteCheckCreated() {
if (multipleCurrentIndex.value.length === 0) {
message.error('请选择账单');
return;
}
if (selectedData.value.length === 0) {
message.error('请选择对账数据');
}
const selected = selectedRef.value.getData();
// 检查已选数据中是否有未填写的表单项
const emptyItem = selected.find((item: CreateOrderType) => {
return completeSubmitCheck(item);
});
if (emptyItem) {
errorData.value = [emptyItem];
return;
} else {
errorData.value = [];
}
// 验证订单金额不能小于实际金额
const invalidItem = selected.find((item: CreateOrderType) => {
return Number.parseFloat(item.orderMoney) < Number.parseFloat(item.realMoney);
});
if (invalidItem) {
errorData.value = [invalidItem];
message.error(`${invalidItem.productName}》订单金额不能小于实际金额`);
return;
} else {
errorData.value = [];
}
// 判断已选的数据填写的实际金额总和是否与对账单金额一致
const totalRealMoney = selected.reduce(
(acc: number, item: CreateOrderType) => acc + Number.parseFloat(item.realMoney),
0,
);
if (totalRealMoney !== pendingData.value?.[multipleCurrentIndex.value[0] ?? 0]?.fee) {
message.error('已选数据填写的实际金额总和与对账单金额不一致');
return;
}
const data = selected.map((item: CreateOrderType) => {
const result: any = {
...item,
districtMoney: (
Number.parseFloat(item.orderMoney) - Number.parseFloat(item.realMoney)
).toString(),
};
// 单选模式且只有VIP和课程订单才处理开始时间和结束时间
if (!isBatchMode.value && (item.orderType === '1' || item.orderType === '2')) {
result.startTime = dayjs(item.startTime).format('YYYY-MM-DD'); // 格式化开始时间
result.endTime = dayjs(item.startTime)
.add(Number.parseFloat(item.endTime), 'day')
.format('YYYY-MM-DD'); // 处理结束时间根据传入的vb时间和课程时长计算
}
// 如果是多选模式,处理相应数据
if (isBatchMode.value) {
delete result.paymentId;
result.startTime = '';
}
return result;
});
const paymentIds = multipleCurrentData.value.map((item) => item.id);
// 调用接口
await (isBatchMode.value
? reconciliateBillsApi.manualCheckCreatedBatch({
paymentIds: paymentIds.join(','),
orders: data,
})
: reconciliateBillsApi.manualCheckCreated(data));
onCompleteCheck();
// 清空已选列表
selectedData.value = [];
selectedRef.value?.clearData();
}
</script>
<template>
<Modal
v-model:open="visible"
width="100%"
title="人工对账"
:footer="null"
centered
wrap-class-name="full-modal"
>
<div class="flex gap-2">
<Card title="待对账列表" class="w-1/4" size="small">
<div class="flex items-center justify-between p-1 pb-2">
<Input
v-model:value="selectedAmount"
addon-before="批量选中金额"
allow-clear
class="w-60"
@change="changeBatchSelected"
/>
<div class="ml-2">已选中{{ multipleCurrentIndex.length }}</div>
</div>
<div class="flex flex-col gap-2 overflow-y-auto px-1" style="height: calc(100vh - 170px)">
<div
v-for="(item, index) in pendingData"
:key="item.id"
:class="{ active: multipleCurrentIndex.includes(index) }"
class="pay-order-item relative"
@click="changePendingData(index)"
>
<div>关联sn号{{ item.relationSn }}</div>
<div>业务流水号{{ item.transactionSn }}</div>
<div>财务流水号{{ item.financeSn }}</div>
<div>金额{{ item.fee }}</div>
<div>支付方式{{ sysStore.getDictMap('payment', item.type) }}</div>
<div>时间{{ dayjs(item.ctime).format('YYYY-MM-DD HH:mm:ss') }}</div>
<VbenIcon
v-if="item.orderFlag === 1"
icon="ant-design:star-fill"
class="absolute right-1 top-1 h-5 w-5 text-red-500"
/>
</div>
</div>
</Card>
<div class="flex w-3/4 flex-1 flex-col gap-2">
<Card
:tab-list="tabList"
disabled
:active-tab-key="activeTabKey"
@tab-change="changeRecordTab"
class="order-card flex-1"
size="small"
>
<Orders
v-if="activeTabKey === '0'"
:payment-id="pendingData[multipleCurrentIndex[0] ?? 0]?.id"
@complete-check="onCompleteCheck"
/>
<component
:is="needCreateRecord[activeTabKey]"
v-if="activeTabKey !== '0'"
ref="needCreateRecordRef"
:tab-key="activeTabKey"
:payment="pendingData[multipleCurrentIndex[0] ?? 0]"
:selected-data="selectedData"
@complete-check="onSelectRecord"
@deleted-checked="onDeleteChecked"
/>
</Card>
<Card
v-if="activeTabKey !== '0'"
:title="`已选列表(${selectedData.length})`"
class="h-[270px] w-full"
size="small"
>
<template #extra>
<Button type="primary" size="small" @click="onCompleteCheckCreated()">确认对账</Button>
</template>
<Selected
ref="selectedRef"
:error-item="errorData"
:is-batch-mode="isBatchMode"
@deleted-checked="onDeleteChecked"
/>
</Card>
</div>
</div>
</Modal>
</template>
<style lang="scss" scoped>
.pay-order-item {
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 10px;
&.active {
padding: 9px;
border: 2px solid #1890ff;
background-color: rgba(24, 144, 255, 0.05);
}
}
:deep(.ant-card-body) {
padding: 5px !important;
padding-bottom: 10px !important;
}
.order-card {
width: 100%;
:deep(.ant-card-body) {
padding: 1px !important;
height: calc(100% - 41px); // 减去标签页头部高度
// background-color: #f1f3f6;
}
}
</style>