1. 计算部门开票价格
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

This commit is contained in:
李琦
2026-07-11 15:53:44 +08:00
parent e0cb5c660c
commit 80b612a913
8 changed files with 687 additions and 1 deletions

View File

@@ -0,0 +1,64 @@
import { requestClient } from '#/api/request';
const prefix = 'department-finance-report/';
/**
* 报表批次列表
*/
export async function getDepartmentFinanceReportListApi(params: Record<string, unknown>) {
return requestClient.get<any>(`${prefix}list`, { params });
}
/**
* 报表详情
*/
export async function getDepartmentFinanceReportDetailApi(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 诊所药品供货明细
*/
export async function getDepartmentFinanceStoreDrugDetailApi(params: {
store_id: number;
report_id?: number;
search_time?: [string, string];
}) {
return requestClient.get<any>(`${prefix}store-drug-detail`, { params });
}
/**
* 手动生成报表(须传统计区间)
*/
export async function generateDepartmentFinanceReportApi(data: {
search_time: [string, string];
}) {
return requestClient.post<any>(`${prefix}generate`, data);
}
/**
* 读取生成配置
*/
export async function getDepartmentFinanceConfigApi() {
return requestClient.get<any>(`${prefix}config`);
}
/**
* 保存生成配置
*/
export async function saveDepartmentFinanceConfigApi(data: {
generate_day: number;
generate_hour: number;
}) {
return requestClient.post<any>(`${prefix}save-config`, data);
}
/**
* 导出 Excel
*/
export async function exportDepartmentFinanceReportApi(params: {
id: number;
include_drugs?: 0 | 1;
}) {
return requestClient.download(`${prefix}export-excel`, { params });
}

View File

@@ -0,0 +1,67 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, InputNumber, message } from 'ant-design-vue';
import {
getDepartmentFinanceConfigApi,
saveDepartmentFinanceConfigApi,
} from '#/views/finance/department-finance/api';
const emit = defineEmits<{ saved: [] }>();
const generateDay = ref(14);
const generateHour = ref(2);
const [Modal, modalApi] = useVbenModal({
title: '部门财务报表生成配置',
draggable: true,
onConfirm: async () => {
modalApi.setState({ confirmLoading: true });
try {
await saveDepartmentFinanceConfigApi({
generate_day: generateDay.value,
generate_hour: generateHour.value,
});
message.success('配置已保存');
emit('saved');
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen) {
if (isOpen) {
loadConfig();
}
},
});
/** 读取当前生成配置 */
async function loadConfig() {
const res = await getDepartmentFinanceConfigApi();
generateDay.value = Number(res.generate_day ?? 14);
generateHour.value = Number(res.generate_hour ?? 2);
}
defineExpose({
open() {
modalApi.open();
},
});
</script>
<template>
<Modal>
<Form layout="vertical">
<Form.Item label="每月生成日1-28">
<InputNumber v-model:value="generateDay" :min="1" :max="28" class="w-full" />
</Form.Item>
<Form.Item label="生成小时0-23">
<InputNumber v-model:value="generateHour" :min="0" :max="23" class="w-full" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,79 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DatePicker, Form, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { generateDepartmentFinanceReportApi } from '#/views/finance/department-finance/api';
const emit = defineEmits<{ success: [] }>();
/** 默认统计区间当月1日 ~ 今天(与对账单一致) */
const searchTime = ref<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs()]);
const [Modal, modalApi] = useVbenModal({
title: '手动生成部门财务报表',
draggable: true,
onConfirm: async () => {
if (!searchTime.value?.[0] || !searchTime.value?.[1]) {
message.warning('请选择统计开始和结束时间');
return;
}
if (searchTime.value[0].isAfter(searchTime.value[1], 'day')) {
message.warning('开始时间不能晚于结束时间');
return;
}
modalApi.setState({ confirmLoading: true });
try {
const res = await generateDepartmentFinanceReportApi({
search_time: [
searchTime.value[0].format('YYYY-MM-DD'),
searchTime.value[1].format('YYYY-MM-DD'),
],
});
message.success(`生成成功:${res.report_no ?? ''}`);
emit('success');
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen) {
if (isOpen) {
searchTime.value = [dayjs().startOf('month'), dayjs()];
}
},
});
function popupContainerBody() {
return document.body;
}
defineExpose({
open() {
modalApi.open();
},
});
</script>
<template>
<Modal>
<Form layout="vertical">
<Form.Item label="统计时间范围" required>
<DatePicker.RangePicker
v-model:value="searchTime"
format="YYYY-MM-DD"
class="w-full"
:get-popup-container="popupContainerBody"
/>
</Form.Item>
<p class="text-xs text-muted-foreground">
将按所选区间汇总各诊所开票相关金额供货价代煎费运费等结束日期不能晚于今天
</p>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,160 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Button, Table } from 'ant-design-vue';
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
import {
exportDepartmentFinanceReportApi,
getDepartmentFinanceReportDetailApi,
getDepartmentFinanceStoreDrugDetailApi,
} from '#/views/finance/department-finance/api';
const loading = ref(false);
const reportId = ref(0);
const detail = ref<any>(null);
const drugLoading = ref(false);
const drugItems = ref<any[]>([]);
const drugStoreName = ref('');
const [Drawer, drawerApi] = useVbenDrawer({
onOpenChange(isOpen) {
if (isOpen) {
loadDetail();
} else {
detail.value = null;
drugItems.value = [];
}
},
});
const statistics = computed(() => {
const s = detail.value?.summary;
if (!s) return null;
return [
{ title: '开票合计', value: s.invoice_total, icon: 'mdi:cash-multiple', color: 'text-blue-600', unit: '¥' },
{ title: '中药供货价', value: s.herbal_supply_price, icon: 'mdi:leaf', color: 'text-green-600', unit: '¥' },
{ title: '颗粒药供货价', value: s.granule_supply_price, icon: 'mdi:pill', color: 'text-teal-600', unit: '¥' },
{ title: '西药供货价', value: s.medicine_supply_price, icon: 'mdi:pharmacy', color: 'text-purple-600', unit: '¥' },
{ title: '中药代煎费', value: s.herbal_decoct_fee, icon: 'mdi:fire', color: 'text-orange-600', unit: '¥' },
{ title: '中药运费', value: s.herbal_express_fee, icon: 'skill-icons:expressjs-dark', color: 'text-orange-500', unit: '¥' },
{ title: '西药运费', value: s.medicine_express_fee, icon: 'mdi:truck', color: 'text-indigo-600', unit: '¥' },
{ title: '颗粒药运费', value: s.granule_express_fee, icon: 'mdi:truck-outline', color: 'text-cyan-600', unit: '¥' },
];
});
const storeColumns = [
{ title: '诊所名称', dataIndex: 'store_name', key: 'store_name', width: 160, ellipsis: true },
{ title: '中药供货价', dataIndex: 'herbal_supply_price', key: 'herbal_supply_price', width: 100 },
{ title: '颗粒药供货价', dataIndex: 'granule_supply_price', key: 'granule_supply_price', width: 110 },
{ title: '西药供货价', dataIndex: 'medicine_supply_price', key: 'medicine_supply_price', width: 100 },
{ title: '中药代煎费', dataIndex: 'herbal_decoct_fee', key: 'herbal_decoct_fee', width: 100 },
{ title: '中药运费', dataIndex: 'herbal_express_fee', key: 'herbal_express_fee', width: 90 },
{ title: '西药运费', dataIndex: 'medicine_express_fee', key: 'medicine_express_fee', width: 90 },
{ title: '颗粒药运费', dataIndex: 'granule_express_fee', key: 'granule_express_fee', width: 100 },
{ title: '开票合计', dataIndex: 'invoice_total', key: 'invoice_total', width: 100 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
];
const drugColumns = [
{ title: '药名', dataIndex: 'drug_name', key: 'drug_name', width: 160, ellipsis: true },
{ title: '药品编号', dataIndex: 'drug_number', key: 'drug_number', width: 120 },
{ title: '类型', dataIndex: 'type_txt', key: 'type_txt', width: 100 },
{ title: '数量', dataIndex: 'number', key: 'number', width: 80 },
{ title: '供货单价', dataIndex: 'market_price', key: 'market_price', width: 100 },
{ title: '供货总额', dataIndex: 'total_supply_price', key: 'total_supply_price', width: 110 },
];
/** 加载报表详情 */
async function loadDetail() {
const data = drawerApi.getData<{ id: number }>();
if (!data?.id) return;
reportId.value = data.id;
loading.value = true;
try {
detail.value = await getDepartmentFinanceReportDetailApi(data.id);
} finally {
loading.value = false;
}
}
/** 查看诊所药品供货明细 */
async function showStoreDrugs(storeId: number, name: string) {
drugStoreName.value = name;
drugLoading.value = true;
try {
const res = await getDepartmentFinanceStoreDrugDetailApi({
store_id: storeId,
report_id: reportId.value,
});
drugItems.value = res.items ?? [];
} finally {
drugLoading.value = false;
}
}
/** 导出当前报表 */
async function handleExport() {
if (!reportId.value) return;
await exportDepartmentFinanceReportApi({ id: reportId.value, include_drugs: 1 });
}
defineExpose({
open(id: number) {
drawerApi.setData({ id });
drawerApi.open();
},
});
</script>
<template>
<Drawer
:title="detail ? `报表详情 - ${detail.report_no}` : '报表详情'"
class="w-[1100px]"
>
<div v-if="detail" class="mb-3 text-sm text-muted-foreground">
统计区间{{ detail.period_start }} ~ {{ detail.period_end }}
<span class="ml-3">生成时间{{ detail.generated_at }}</span>
</div>
<StatisticsReconciliation
v-if="statistics"
:statistics="statistics"
/>
<div class="mb-3 mt-4 flex justify-end">
<Button type="primary" @click="handleExport">导出 Excel</Button>
</div>
<Table
:columns="storeColumns"
:data-source="detail?.stores ?? []"
:loading="loading"
:pagination="{ pageSize: 10 }"
row-key="id"
size="small"
bordered
:scroll="{ x: 1200 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Button type="link" size="small" @click="showStoreDrugs(record.store_id, record.store_name)">
药品明细
</Button>
</template>
</template>
</Table>
<div v-if="drugStoreName" class="mt-4">
<div class="mb-2 font-medium">{{ drugStoreName }} - 药品供货明细</div>
<Table
:columns="drugColumns"
:data-source="drugItems"
:loading="drugLoading"
:pagination="{ pageSize: 8 }"
row-key="drug_id"
size="small"
bordered
:scroll="{ x: 700 }"
/>
</div>
</Drawer>
</template>

View File

@@ -0,0 +1,138 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Table } from 'ant-design-vue';
import { TableAction } from '#/components/table-action';
import {
exportDepartmentFinanceReportApi,
getDepartmentFinanceReportListApi,
} from '#/views/finance/department-finance/api';
import ConfigModal from './components/ConfigModal.vue';
import GenerateReportModal from './components/GenerateReportModal.vue';
import ReportDetailDrawer from './components/ReportDetailDrawer.vue';
const loading = ref(false);
const list = ref<any[]>([]);
const pagination = ref({ current: 1, pageSize: 20, total: 0 });
const reportDetailDrawerRef = ref<InstanceType<typeof ReportDetailDrawer>>();
const configModalRef = ref<InstanceType<typeof ConfigModal>>();
const generateReportModalRef = ref<InstanceType<typeof GenerateReportModal>>();
const columns = [
{ title: '报表编号', dataIndex: 'report_no', key: 'report_no', width: 160 },
{ title: '统计开始', dataIndex: 'period_start', key: 'period_start', width: 170 },
{ title: '统计结束', dataIndex: 'period_end', key: 'period_end', width: 170 },
{ title: '生成时间', dataIndex: 'generated_at', key: 'generated_at', width: 170 },
{ title: '开票合计', dataIndex: 'invoice_total', key: 'invoice_total', width: 120 },
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const },
];
/** 拉取报表列表 */
async function fetchList(page = pagination.value.current) {
loading.value = true;
try {
const res = await getDepartmentFinanceReportListApi({
page,
pageSize: pagination.value.pageSize,
});
const data = res.list ?? res;
list.value = data?.items ?? [];
pagination.value = {
current: data?.page ?? page,
pageSize: data?.size ?? pagination.value.pageSize,
total: data?.total ?? 0,
};
} finally {
loading.value = false;
}
}
/** 打开手动生成弹窗 */
function handleGenerate() {
generateReportModalRef.value?.open();
}
/** 生成成功后刷新列表 */
function onGenerateSuccess() {
fetchList(1);
}
/** 查看报表详情 */
function openDetail(id: number) {
reportDetailDrawerRef.value?.open(id);
}
/** 导出报表 */
async function handleExport(id: number) {
await exportDepartmentFinanceReportApi({ id, include_drugs: 1 });
}
function openConfig() {
configModalRef.value?.open();
}
onMounted(() => {
fetchList();
});
</script>
<template>
<Page auto-content-height title="部门财务">
<ReportDetailDrawer ref="reportDetailDrawerRef" />
<ConfigModal ref="configModalRef" />
<GenerateReportModal ref="generateReportModalRef" @success="onGenerateSuccess" />
<div class="mb-3 flex flex-wrap gap-2">
<Button type="primary" @click="handleGenerate">
手动生成报表
</Button>
<Button @click="openConfig">生成配置</Button>
<Button @click="fetchList()">刷新</Button>
</div>
<Table
:columns="columns"
:data-source="list"
:loading="loading"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
onChange: (p: number, ps: number) => {
pagination.pageSize = ps;
fetchList(p);
},
}"
row-key="id"
bordered
size="small"
:scroll="{ x: 1100 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
size: 'small',
onClick: () => openDetail(record.id),
},
{
label: '导出',
type: 'link',
size: 'small',
onClick: () => handleExport(record.id),
},
]"
:drop-down-actions="[]"
/>
</template>
</template>
</Table>
</Page>
</template>

View File

@@ -0,0 +1,39 @@
import { requestClient } from '#/api/request';
import dayjs from 'dayjs';
const prefix = 'reconciliation/';
function formatSearchTimeParam(st: unknown): [string, string] | undefined {
if (!Array.isArray(st) || st.length < 2) return undefined;
const fmt = (v: unknown) => {
if (v && typeof v === 'object' && 'format' in v && typeof (v as { format: (s: string) => string }).format === 'function') {
return (v as dayjs.Dayjs).format('YYYY-MM-DD');
}
const d = dayjs(v as string | number);
return d.isValid() ? d.format('YYYY-MM-DD') : '';
};
const a = fmt(st[0]);
const b = fmt(st[1]);
if (!a || !b) return undefined;
return [a, b];
}
/**
* 诊所开票价格(部门财务口径)
*/
export async function getInvoicePriceApi(params: {
store_id: number;
search_time: unknown;
}) {
const search_time = formatSearchTimeParam(params.search_time);
if (!search_time) {
return Promise.reject(new Error('时间范围无效'));
}
return requestClient.get<any>(`${prefix}invoice-price`, {
params: {
store_id: params.store_id,
search_time,
},
});
}

View File

@@ -0,0 +1,112 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Table, Tag } from 'ant-design-vue';
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
import { getInvoicePriceApi } from '#/views/finance/reconciliation/api/invoice-price';
const loading = ref(false);
const storeId = ref(0);
const storeName = ref('');
const searchTime = ref<[string, string]>(['', '']);
const metrics = ref<Record<string, number> | null>(null);
const drugItems = ref<any[]>([]);
const [Drawer, drawerApi] = useVbenDrawer({
onOpenChange(isOpen) {
if (isOpen) {
fetchData();
} else {
metrics.value = null;
drugItems.value = [];
}
},
});
const statistics = computed(() => {
if (!metrics.value) return null;
const m = metrics.value;
return [
{ title: '开票合计', value: m.invoice_total, icon: 'mdi:cash-multiple', color: 'text-blue-600', unit: '¥' },
{ title: '中药供货价', value: m.herbal_supply_price, icon: 'mdi:leaf', color: 'text-green-600', unit: '¥' },
{ title: '颗粒药供货价', value: m.granule_supply_price, icon: 'mdi:pill', color: 'text-teal-600', unit: '¥' },
{ title: '西药供货价', value: m.medicine_supply_price, icon: 'mdi:pharmacy', color: 'text-purple-600', unit: '¥' },
{ title: '中药代煎费', value: m.herbal_decoct_fee, icon: 'mdi:fire', color: 'text-orange-600', unit: '¥' },
{ title: '中药运费', value: m.herbal_express_fee, icon: 'skill-icons:expressjs-dark', color: 'text-orange-500', unit: '¥' },
{ title: '西药运费', value: m.medicine_express_fee, icon: 'mdi:truck', color: 'text-indigo-600', unit: '¥' },
{ title: '颗粒药运费', value: m.granule_express_fee, icon: 'mdi:truck-outline', color: 'text-cyan-600', unit: '¥' },
];
});
const drugColumns = [
{ title: '药名', dataIndex: 'drug_name', key: 'drug_name', width: 160, ellipsis: true },
{ title: '药品编号', dataIndex: 'drug_number', key: 'drug_number', width: 120 },
{ title: '类型', dataIndex: 'type_txt', key: 'type_txt', width: 100 },
{ title: '数量', dataIndex: 'number', key: 'number', width: 80 },
{ title: '供货单价', dataIndex: 'market_price', key: 'market_price', width: 100 },
{ title: '供货总额', dataIndex: 'total_supply_price', key: 'total_supply_price', width: 110 },
];
/** 拉取开票价格与药品供货明细 */
async function fetchData() {
const data = drawerApi.getData<{
store_id: number;
store_name: string;
search_time: [string, string];
}>();
if (!data?.store_id || !data.search_time?.[0]) {
return;
}
storeId.value = data.store_id;
storeName.value = data.store_name;
searchTime.value = data.search_time;
loading.value = true;
try {
const res = await getInvoicePriceApi({
store_id: data.store_id,
search_time: data.search_time,
});
metrics.value = res.metrics ?? null;
drugItems.value = res.drug_items ?? [];
} finally {
loading.value = false;
}
}
defineExpose({
open(payload: { store_id: number; store_name: string; search_time: [string, string] }) {
drawerApi.setData(payload);
drawerApi.open();
},
});
</script>
<template>
<Drawer
:title="`开票价格 - ${storeName}`"
class="w-[900px]"
>
<div class="mb-2 text-sm text-muted-foreground">
统计区间{{ searchTime[0] }} ~ {{ searchTime[1] }}
<Tag color="blue" class="ml-2">诊所 ID: {{ storeId }}</Tag>
</div>
<StatisticsReconciliation
v-if="statistics"
:statistics="statistics"
/>
<div class="mt-4 mb-2 font-medium">药品供货明细</div>
<Table
:columns="drugColumns"
:data-source="drugItems"
:loading="loading"
:pagination="{ pageSize: 10 }"
row-key="drug_id"
size="small"
bordered
:scroll="{ x: 700 }"
/>
</Drawer>
</template>

View File

@@ -33,6 +33,7 @@ import {
} from '#/views/finance/reconciliation/api';
import ReconciliationExportModal from './components/ReconciliationExportModal.vue';
import InvoicePriceDrawer from './components/InvoicePriceDrawer.vue';
import StatisticsReconciliation from './components/statistics.vue';
import {
loadExcludeZeroSales,
@@ -77,6 +78,13 @@ const [ExportModal, exportModalApi] = useVbenModal({
connectedComponent: ReconciliationExportModal,
});
const invoicePriceDrawerRef = ref<InstanceType<typeof InvoicePriceDrawer>>();
/** 平台财务等可查看开票价格 */
const canViewInvoicePrice = computed(
() => Number(userInfo.value?.roles?.user_type) === 2,
);
const activeTab = ref('store');
/** 整页共用时间 */
@@ -268,6 +276,15 @@ function goDrug(storeId: number, name: string) {
activeTab.value = 'drug';
}
/** 打开诊所开票价格抽屉 */
function openInvoicePrice(storeId: number, name: string) {
invoicePriceDrawerRef.value?.open({
store_id: storeId,
store_name: name,
search_time: searchTimeParam(),
});
}
function clearDrugStoreFilter() {
currentStoreId.value = null;
currentStoreName.value = '';
@@ -306,7 +323,7 @@ const storeColumns = [
{ title: '总销售总价', key: 'total_sales_price', width: 120 },
{ title: '总供货总价', key: 'total_supply_price', width: 120 },
{ title: '挂号费用', key: 'registration_price', width: 100 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
const drugColumnsAll = [
@@ -355,6 +372,7 @@ watch(byOrder, () => {
title="对账单"
>
<ExportModal />
<InvoicePriceDrawer ref="invoicePriceDrawerRef" />
<StatisticsReconciliation
v-if="statistics"
:statistics="statistics"
@@ -460,6 +478,15 @@ watch(byOrder, () => {
size: 'small',
onClick: () => goDrug(record.id, record.name),
},
...(canViewInvoicePrice
? [{
label: '开票价格',
type: 'link',
icon: 'mdi:receipt-text',
size: 'small',
onClick: () => openInvoicePrice(record.id, record.name),
}]
: []),
]"
:drop-down-actions="[]"
/>