feat:优化了门店部门财务功能,新增开票功能

This commit is contained in:
李琦
2026-07-24 09:54:44 +08:00
parent b1df6fb4e8
commit 14414bf9b0
12 changed files with 1312 additions and 3 deletions

View File

@@ -0,0 +1,42 @@
import { requestClient } from '#/api/request';
const prefix = 'invoice-center/';
/** 开票申请列表 */
export async function getInvoiceCenterListApi(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/** 开票详情 */
export async function getInvoiceCenterDetailApi(data: any) {
return requestClient.get<any>(`${prefix}detail`, { params: data });
}
/** 受理 */
export async function acceptInvoiceApi(data: { id: number }) {
return requestClient.post<any>(`${prefix}accept`, data);
}
/** 拒绝 */
export async function rejectInvoiceApi(data: { id: number; reject_reason: string }) {
return requestClient.post<any>(`${prefix}reject`, data);
}
/** 完成开票(上传文件并按需发邮) */
export async function completeInvoiceApi(data: {
id: number;
invoice_code: string;
invoice_files: string[];
}) {
return requestClient.post<any>(`${prefix}complete`, data);
}
/** 重发邮件 */
export async function resendInvoiceEmailApi(data: { id: number }) {
return requestClient.post<any>(`${prefix}resend-email`, data);
}
/** 各状态数量(Tab 徽标) */
export async function getInvoiceStatusStatsApi() {
return requestClient.get<Record<string, number>>(`${prefix}status-stats`);
}

View File

@@ -0,0 +1,96 @@
<script lang="ts" setup>
/**
* 完成开票:填写票据代码 + 拖拽/粘贴上传发票文件(图片/PDF)
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { completeInvoiceApi } from '../api';
const invoiceCode = ref('');
const fileUrls = ref<string[]>([]);
const rowId = ref(0);
const receiveType = ref(1);
const email = ref('');
const gridApi = ref<any>();
const emailHint = computed(() => {
if (email.value) {
return `填写了邮箱则会发送至:${email.value};同时可在小程序查收`;
}
return '未填写邮箱则仅小程序可查收;完成后将通知患者';
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const code = invoiceCode.value.trim();
if (!code) {
message.warning('请填写票据代码');
return;
}
if (!fileUrls.value.length) {
message.warning('请上传发票文件');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
const res: any = await completeInvoiceApi({
id: rowId.value,
invoice_code: code,
invoice_files: fileUrls.value,
});
if (res?.subscribe_warn) {
message.warning(String(res.subscribe_warn));
} else {
message.success('开票完成');
}
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
rowId.value = Number(data.id || 0);
receiveType.value = Number(data.receive_type || 1);
email.value = String(data.email || '');
gridApi.value = data.gridApi;
invoiceCode.value = '';
fileUrls.value = [];
},
});
</script>
<template>
<Modal title="完成开票" class="w-[560px]">
<div class="space-y-4 py-2">
<div class="text-sm text-gray-500">{{ emailHint }}</div>
<div>
<div class="mb-1 font-medium">票据代码</div>
<Input v-model:value="invoiceCode" placeholder="请输入发票代码/号码" allow-clear />
</div>
<div>
<div class="mb-1 font-medium">发票文件(图片或 PDF)</div>
<UploadDraggerPaste
v-model="fileUrls"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
tip="点击或拖拽发票文件到此处(建议上传图片,小程序内可直接预览;PDF 将通过微信打开)"
:max-count="9"
:max-size-mb="20"
/>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
/**
* 拒绝开票申请弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import { rejectInvoiceApi } from '../api';
const reason = ref('');
const rowId = ref(0);
const gridApi = ref<any>();
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const text = reason.value.trim();
if (!text) {
message.warning('请填写拒绝原因');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
await rejectInvoiceApi({ id: rowId.value, reject_reason: text });
message.success('已拒绝');
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
rowId.value = Number(data.id || 0);
gridApi.value = data.gridApi;
reason.value = '';
},
});
</script>
<template>
<Modal title="拒绝开票申请" class="w-[480px]">
<div class="py-2">
<div class="mb-2 text-sm text-gray-500">拒绝后患者可重新申请开票</div>
<Input.TextArea v-model:value="reason" :rows="4" placeholder="请填写拒绝原因" />
</div>
</Modal>
</template>

View File

@@ -0,0 +1,47 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 开票中心列表搜索表单
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'Input',
componentProps: {
placeholder: '订单号 / 抬头 / 邮箱',
allowClear: true,
},
fieldName: 'keyword',
label: '关键词',
},
{
component: 'Input',
componentProps: {
placeholder: '订单号',
allowClear: true,
},
fieldName: 'order_no',
label: '订单号',
},
{
component: 'Select',
componentProps: {
placeholder: '接收方式',
allowClear: true,
options: [
{ label: '全部', value: '' },
{ label: '小程序下载', value: 1 },
{ label: '邮箱接收', value: 2 },
],
},
defaultValue: '',
fieldName: 'receive_type',
label: '接收方式',
},
],
showCollapseButton: false,
submitButtonOptions: { content: '查询' },
submitOnChange: false,
submitOnEnter: true,
};

View File

@@ -0,0 +1,69 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getInvoiceCenterListApi } from '../api';
interface RowType {
id: number;
order_no: string;
status: number;
}
/**
* 开票中心表格配置;statusFilter 由页面注入当前 Tab 状态
*/
export function createGridOptions(getStatus: () => string | number): VxeGridProps<RowType> {
return {
checkboxConfig: {
highlight: true,
labelField: '',
},
columns: [
{ field: 'id', title: 'ID', width: 70 },
{ field: 'order_no', title: '订单号', minWidth: 160 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'title_type_text', title: '抬头类型', width: 90 },
{ field: 'title_name', title: '抬头名称', minWidth: 120 },
{ field: 'amount', title: '金额', width: 100 },
{ field: 'receive_type_text', title: '接收方式', width: 100 },
{ field: 'email', title: '邮箱', minWidth: 160 },
{ field: 'status_text', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 260,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const status = getStatus();
return await getInvoiceCenterListApi({
page: page.currentPage,
pageSize: page.pageSize,
status: status === '' || status === 'all' ? '' : status,
...formValues,
});
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
// @ts-ignore
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-buttons' },
},
showOverflow: false,
};
}

View File

@@ -0,0 +1,316 @@
<script lang="ts" setup>
/**
* 财务开票中心:待受理 / 已受理 / 已完成 / 已拒绝 / 全部
* Tab 记忆 localStorage;各状态徽标数量(0 不显示)
* 发票文件:图片用 Image 预览,PDF 用 Modal + blob iframe
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Badge, Button, Image, Modal, Space, Tabs, Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
acceptInvoiceApi,
getInvoiceStatusStatsApi,
resendInvoiceEmailApi,
} from './api';
import CompleteModal from './components/CompleteModal.vue';
import RejectModal from './components/RejectModal.vue';
import { formOptions } from './config/search';
import { createGridOptions } from './config/table';
defineOptions({ name: 'FinanceInvoiceCenter' });
/** Tab 记忆键,刷新后恢复上次选中状态 */
const TAB_STORAGE_KEY = 'finance-invoice-center-tab';
const TAB_KEYS = ['0', '1', '2', '9', 'all'] as const;
/**
* 从 localStorage 读取合法 Tab,非法则默认待受理
*/
function readStoredTab(): string {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
if (stored && (TAB_KEYS as readonly string[]).includes(stored)) {
return stored;
}
return '0';
}
const activeTab = ref<string>(readStoredTab());
const statusFilter = computed(() =>
activeTab.value === 'all' ? '' : activeTab.value,
);
/** 各状态数量,供徽标展示 */
const statusStats = ref<Record<string, number>>({
'0': 0,
'1': 0,
'2': 0,
'9': 0,
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions: createGridOptions(() => statusFilter.value),
});
/**
* 列表 + 徽标一并刷新(弹窗回调与操作后复用)
*/
function reloadAll() {
gridApi.reload();
loadStatusStats();
}
/** 传给弹窗的伪 gridApi,reload 时同步刷新徽标 */
const gridApiWithStats = {
reload: reloadAll,
};
const [RejectModalComp, rejectModalApi] = useVbenModal({
connectedComponent: RejectModal,
});
const [CompleteModalComp, completeModalApi] = useVbenModal({
connectedComponent: CompleteModal,
});
/** 文件预览弹窗 */
const previewVisible = ref(false);
const previewTitle = ref('');
const previewIsImage = ref(false);
const previewImageUrl = ref('');
const previewPdfUrl = ref('');
const previewLoading = ref(false);
/**
* 拉取各状态数量;失败静默,避免打断列表
*/
async function loadStatusStats() {
try {
const res: any = await getInvoiceStatusStatsApi();
const data = res?.data ?? res ?? {};
statusStats.value = {
'0': Number(data['0'] ?? data[0] ?? 0),
'1': Number(data['1'] ?? data[1] ?? 0),
'2': Number(data['2'] ?? data[2] ?? 0),
'9': Number(data['9'] ?? data[9] ?? 0),
};
} catch {
// 徽标非关键路径,忽略错误
}
}
function handleTabChange(key: string | number) {
const next = String(key);
activeTab.value = next;
localStorage.setItem(TAB_STORAGE_KEY, next);
reloadAll();
}
/** 受理 */
async function handleAccept(row: any) {
Modal.confirm({
title: '确认受理该开票申请?',
content: '受理后将写入站内通知(订阅消息在开票完成时发送)',
async onOk() {
await acceptInvoiceApi({ id: row.id });
message.success('受理成功');
reloadAll();
},
});
}
function openReject(row: any) {
rejectModalApi.setData({ id: row.id, gridApi: gridApiWithStats });
rejectModalApi.open();
}
function openComplete(row: any) {
completeModalApi.setData({
id: row.id,
receive_type: row.receive_type,
email: row.email,
gridApi: gridApiWithStats,
});
completeModalApi.open();
}
/** 重发邮件(有邮箱即可,不限 receive_type) */
async function handleResend(row: any) {
Modal.confirm({
title: '确认重发邮件?',
content: `将再次发送至 ${row.email || ''}`,
async onOk() {
await resendInvoiceEmailApi({ id: row.id });
message.success('邮件已重发');
reloadAll();
},
});
}
function statusColor(status: number) {
if (status === 0) return 'processing';
if (status === 1) return 'warning';
if (status === 2) return 'success';
if (status === 9) return 'error';
return 'default';
}
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
}
function revokePdfBlob() {
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewPdfUrl.value);
}
previewPdfUrl.value = '';
}
/**
* 预览发票文件:图片走 Image;PDF fetch 成 blob 后 iframe
*/
async function previewFiles(files: string[]) {
if (!files?.length) {
message.info('暂无文件');
return;
}
const first = files[0];
revokePdfBlob();
previewTitle.value = first.split('/').pop() || '发票文件';
if (isImageUrl(first)) {
previewIsImage.value = true;
previewImageUrl.value = first;
previewVisible.value = true;
return;
}
previewIsImage.value = false;
previewLoading.value = true;
previewVisible.value = true;
try {
const resp = await fetch(first);
if (!resp.ok) throw new Error('文件加载失败');
const blob = await resp.blob();
const pdfBlob =
blob.type && blob.type !== 'application/octet-stream'
? blob
: new Blob([blob], { type: 'application/pdf' });
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
} catch (e: any) {
message.error(e?.message || '预览失败,可尝试重新上传为图片');
previewVisible.value = false;
} finally {
previewLoading.value = false;
}
}
function onPreviewClose() {
previewVisible.value = false;
revokePdfBlob();
previewImageUrl.value = '';
}
onMounted(() => {
loadStatusStats();
});
onBeforeUnmount(() => {
revokePdfBlob();
});
</script>
<template>
<Page auto-content-height title="开票中心">
<RejectModalComp />
<CompleteModalComp />
<Tabs :active-key="activeTab" class="mb-2" @change="handleTabChange">
<Tabs.TabPane key="0">
<template #tab>
<Badge :count="statusStats['0']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>待受理</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="1">
<template #tab>
<Badge :count="statusStats['1']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已受理</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="2">
<template #tab>
<Badge :count="statusStats['2']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已完成</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="9">
<template #tab>
<Badge :count="statusStats['9']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已拒绝</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="all" tab="全部" />
</Tabs>
<Grid>
<template #toolbar-buttons />
<template #status="{ row }">
<Tag :color="statusColor(row.status)">{{ row.status_text }}</Tag>
</template>
<template #action="{ row }">
<Space>
<Button v-if="row.status === 0" type="link" size="small" @click="handleAccept(row)">
受理
</Button>
<Button v-if="row.status === 0" type="link" size="small" danger @click="openReject(row)">
拒绝
</Button>
<Button v-if="row.status === 1" type="link" size="small" @click="openComplete(row)">
完成开票
</Button>
<Button
v-if="row.status === 2 && row.invoice_files?.length"
type="link"
size="small"
@click="previewFiles(row.invoice_files)"
>
查看文件
</Button>
<Button
v-if="row.status === 2 && row.email"
type="link"
size="small"
@click="handleResend(row)"
>
重发邮件
</Button>
</Space>
</template>
</Grid>
<Modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
width="80%"
destroy-on-close
@cancel="onPreviewClose"
>
<div v-if="previewLoading" class="py-16 text-center text-gray-500">加载中…</div>
<div v-else-if="previewIsImage" class="flex justify-center">
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="h-[70vh] w-full border-0"
title="发票预览"
/>
</Modal>
</Page>
</template>

View File

@@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, Input, InputNumber, message, Radio, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import { Button, Card, Checkbox, Input, InputNumber, message, Radio, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
@@ -37,6 +37,8 @@ const logisticsShowWhNameUser = ref(false);
const logisticsShowWhNameClinic = ref(true);
/** 业务员是否允许编辑/改价/配置所属诊所(默认否) */
const salespersonStoreEditEnabled = ref(false);
/** 开票通知渠道:subscribe / sms,默认仅订阅消息 */
const invoiceNoticeChannels = ref<string[]>(['subscribe']);
function readStoredTab() {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
@@ -45,7 +47,8 @@ function readStoredTab() {
stored === 'input_audit' ||
stored === 'miniprogram' ||
stored === 'logistics' ||
stored === 'salesperson'
stored === 'salesperson' ||
stored === 'invoice_notice'
) {
activeKey.value = stored;
}
@@ -110,6 +113,22 @@ async function load() {
if (row.config_key === 'salesperson_store_edit_enabled') {
salespersonStoreEditEnabled.value = parseBoolConfig(row.config_value, false);
}
if (row.config_key === 'invoice_notice_channels') {
let channels: unknown = row.config_value;
if (typeof channels === 'string') {
try {
channels = JSON.parse(channels);
} catch {
channels = ['subscribe'];
}
}
if (Array.isArray(channels) && channels.length) {
invoiceNoticeChannels.value = channels.map(String).filter((c) => c === 'subscribe' || c === 'sms');
}
if (!invoiceNoticeChannels.value.length) {
invoiceNoticeChannels.value = ['subscribe'];
}
}
}
} finally {
loading.value = false;
@@ -167,6 +186,16 @@ async function handleSave() {
description: '允许业务员编辑/改价/配置所属诊所',
sort: 300,
},
{
config_key: 'invoice_notice_channels',
config_value: JSON.stringify(
invoiceNoticeChannels.value.length ? invoiceNoticeChannels.value : ['subscribe'],
),
value_type: 'json',
config_group: 'invoice',
description: '开票通知渠道:subscribe订阅消息 / sms短信',
sort: 200,
},
]);
message.success('保存成功');
} finally {
@@ -271,6 +300,19 @@ onMounted(() => {
/>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="invoice_notice" tab="开票通知">
<div class="py-4">
<div class="mb-2 font-medium">患者开票通知渠道</div>
<div class="mb-2 text-sm text-gray-500">
受理与开票完成时按所选渠道发送;可多选(全选)。默认仅小程序订阅消息。
</div>
<Checkbox.Group v-model:value="invoiceNoticeChannels">
<Checkbox value="subscribe">小程序订阅消息</Checkbox>
<Checkbox value="sms">短信</Checkbox>
</Checkbox.Group>
</div>
</Tabs.TabPane>
</Tabs>
<div class="mt-4">