feat:优化了门店部门财务
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
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
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
李琦
2026-07-23 16:55:26 +08:00
parent 5fcb65aa0d
commit b1df6fb4e8
4 changed files with 1252 additions and 155 deletions

View File

@@ -52,13 +52,3 @@ export async function saveDepartmentFinanceConfigApi(data: {
}) { }) {
return requestClient.post<any>(`${prefix}save-config`, data); 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

@@ -1,23 +1,48 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; /**
* 部门财务报表详情抽屉C 端风格)
* 英雄头 + 指标卡 + 诊所卡片 + 药品内嵌面板 + ExcelJS 导出
*/
import { computed, ref, watch } from 'vue';
import { useVbenDrawer } from '@vben/common-ui'; import { useVbenDrawer } from '@vben/common-ui';
import { Button, Table } from 'ant-design-vue';
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
import { import {
exportDepartmentFinanceReportApi, Button,
Empty,
Pagination,
Spin,
Tag,
message,
} from 'ant-design-vue';
import {
getDepartmentFinanceReportDetailApi, getDepartmentFinanceReportDetailApi,
getDepartmentFinanceStoreDrugDetailApi, getDepartmentFinanceStoreDrugDetailApi,
} from '#/views/finance/department-finance/api'; } from '#/views/finance/department-finance/api';
import { buildAndDownloadDepartmentFinanceExcel } from '#/views/finance/department-finance/utils/exportDepartmentFinanceExcel';
/** 诊所卡内展示的金额字段(不含开票合计,单独强调) */
const STORE_METRIC_FIELDS = [
{ key: 'herbal_supply_price', label: '中药供货价' },
{ key: 'granule_supply_price', label: '颗粒药供货价' },
{ key: 'medicine_supply_price', label: '西药供货价' },
{ key: 'herbal_decoct_fee', label: '中药代煎费' },
{ key: 'herbal_express_fee', label: '中药运费' },
{ key: 'medicine_express_fee', label: '西药运费' },
{ key: 'granule_express_fee', label: '颗粒药运费' },
] as const;
const loading = ref(false); const loading = ref(false);
const exportLoading = ref(false);
const reportId = ref(0); const reportId = ref(0);
const detail = ref<any>(null); const detail = ref<any>(null);
const drugLoading = ref(false); const drugLoading = ref(false);
const drugItems = ref<any[]>([]); const drugItems = ref<any[]>([]);
const drugStoreName = ref(''); const drugStoreName = ref('');
const selectedStoreId = ref<number | null>(null);
const storePage = ref(1);
const storePageSize = 8;
const [Drawer, drawerApi] = useVbenDrawer({ const [Drawer, drawerApi] = useVbenDrawer({
onOpenChange(isOpen) { onOpenChange(isOpen) {
@@ -26,46 +51,47 @@ const [Drawer, drawerApi] = useVbenDrawer({
} else { } else {
detail.value = null; detail.value = null;
drugItems.value = []; drugItems.value = [];
drugStoreName.value = '';
selectedStoreId.value = null;
storePage.value = 1;
} }
}, },
}); });
const statistics = computed(() => { /** 金额千分位展示 */
function formatMoney(value: unknown): string {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return n.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/** 英雄头下方 7 项指标(开票合计已在头区突出,不再重复) */
const metricItems = computed(() => {
const s = detail.value?.summary; const s = detail.value?.summary;
if (!s) return null; if (!s) return [];
return [ return STORE_METRIC_FIELDS.map((f) => ({
{ title: '开票合计', value: s.invoice_total, icon: 'mdi:cash-multiple', color: 'text-blue-600', unit: '¥' }, label: f.label,
{ title: '中药供货价', value: s.herbal_supply_price, icon: 'mdi:leaf', color: 'text-green-600', unit: '¥' }, value: s[f.key],
{ 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 = [ const allStores = computed(() => detail.value?.stores ?? []);
{ 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 = [ const storeTotal = computed(() => allStores.value.length);
{ 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 }, const pagedStores = computed(() => {
{ title: '数量', dataIndex: 'number', key: 'number', width: 80 }, const start = (storePage.value - 1) * storePageSize;
{ title: '供货单价', dataIndex: 'market_price', key: 'market_price', width: 100 }, return allStores.value.slice(start, start + storePageSize);
{ title: '供货总额', dataIndex: 'total_supply_price', key: 'total_supply_price', width: 110 }, });
];
watch(storeTotal, () => {
const maxPage = Math.max(1, Math.ceil(storeTotal.value / storePageSize) || 1);
if (storePage.value > maxPage) storePage.value = maxPage;
});
/** 加载报表详情 */ /** 加载报表详情 */
async function loadDetail() { async function loadDetail() {
@@ -73,6 +99,10 @@ async function loadDetail() {
if (!data?.id) return; if (!data?.id) return;
reportId.value = data.id; reportId.value = data.id;
loading.value = true; loading.value = true;
selectedStoreId.value = null;
drugItems.value = [];
drugStoreName.value = '';
storePage.value = 1;
try { try {
detail.value = await getDepartmentFinanceReportDetailApi(data.id); detail.value = await getDepartmentFinanceReportDetailApi(data.id);
} finally { } finally {
@@ -80,8 +110,15 @@ async function loadDetail() {
} }
} }
/** 查看诊所药品供货明细 */ /**
* 查看诊所药品供货明细(再次点击同一诊所则收起)
*/
async function showStoreDrugs(storeId: number, name: string) { async function showStoreDrugs(storeId: number, name: string) {
if (selectedStoreId.value === storeId) {
closeDrugPanel();
return;
}
selectedStoreId.value = storeId;
drugStoreName.value = name; drugStoreName.value = name;
drugLoading.value = true; drugLoading.value = true;
try { try {
@@ -90,15 +127,40 @@ async function showStoreDrugs(storeId: number, name: string) {
report_id: reportId.value, report_id: reportId.value,
}); });
drugItems.value = res.items ?? []; drugItems.value = res.items ?? [];
} catch (e: any) {
message.error(e?.message || '加载药品明细失败');
drugItems.value = [];
} finally { } finally {
drugLoading.value = false; drugLoading.value = false;
} }
} }
/** 导出当前报表 */ /** 收起药品明细面板 */
function closeDrugPanel() {
selectedStoreId.value = null;
drugStoreName.value = '';
drugItems.value = [];
}
/** 诊所分页切换 */
function onStorePageChange(page: number) {
storePage.value = page;
}
/** 导出当前报表ExcelJS 美化) */
async function handleExport() { async function handleExport() {
if (!reportId.value) return; if (!reportId.value || exportLoading.value) return;
await exportDepartmentFinanceReportApi({ id: reportId.value, include_drugs: 1 }); exportLoading.value = true;
const hide = message.loading('正在生成 Excel…', 0);
try {
await buildAndDownloadDepartmentFinanceExcel(reportId.value);
message.success('导出成功');
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally {
hide();
exportLoading.value = false;
}
} }
defineExpose({ defineExpose({
@@ -112,49 +174,454 @@ defineExpose({
<template> <template>
<Drawer <Drawer
:title="detail ? `报表详情 - ${detail.report_no}` : '报表详情'" :title="detail ? `报表详情 - ${detail.report_no}` : '报表详情'"
class="w-[1100px]" class="w-[80%]"
> >
<div v-if="detail" class="mb-3 text-sm text-muted-foreground"> <Spin :spinning="loading">
统计区间{{ detail.period_start }} ~ {{ detail.period_end }} <template v-if="detail">
<span class="ml-3">生成时间{{ detail.generated_at }}</span> <!-- 英雄头编号 + 区间 + 开票合计 + 导出 -->
</div> <div class="df-detail-hero">
<StatisticsReconciliation <div class="df-detail-hero__left">
v-if="statistics" <div class="df-detail-hero__title">{{ detail.report_no }}</div>
:statistics="statistics" <div class="df-detail-hero__meta">
/> <span>统计区间</span>
<div class="mb-3 mt-4 flex justify-end"> <span>{{ detail.period_start }} ~ {{ detail.period_end }}</span>
<Button type="primary" @click="handleExport">导出 Excel</Button> </div>
</div> <div class="df-detail-hero__meta">
<Table <span>生成时间</span>
:columns="storeColumns" <span>{{ detail.generated_at }}</span>
:data-source="detail?.stores ?? []" </div>
:loading="loading" </div>
:pagination="{ pageSize: 10 }" <div class="df-detail-hero__right">
row-key="id" <div class="df-detail-hero__amount-label">开票合计</div>
size="small" <div class="df-detail-hero__amount-value">
bordered ¥{{ formatMoney(detail.summary?.invoice_total ?? detail.invoice_total) }}
:scroll="{ x: 1200 }" </div>
> <Button
<template #bodyCell="{ column, record }"> type="primary"
<template v-if="column.key === 'action'"> class="mt-3"
<Button type="link" size="small" @click="showStoreDrugs(record.store_id, record.store_name)"> :loading="exportLoading"
药品明细 @click="handleExport"
</Button> >
</template> 导出 Excel
</Button>
</div>
</div>
<!-- 指标卡7 项费用 -->
<div v-if="metricItems.length" class="df-detail-metrics">
<div
v-for="item in metricItems"
:key="item.label"
class="df-detail-metric"
>
<div class="df-detail-metric__label">{{ item.label }}</div>
<div class="df-detail-metric__value">
¥{{ formatMoney(item.value) }}
</div>
</div>
</div>
<!-- 诊所卡片列表 -->
<div class="df-detail-section-title">诊所明细</div>
<div v-if="pagedStores.length" class="df-detail-store-grid">
<div
v-for="store in pagedStores"
:key="store.id ?? store.store_id"
class="df-detail-store"
:class="{
'df-detail-store--active': selectedStoreId === store.store_id,
}"
>
<div class="df-detail-store__main">
<div class="df-detail-store__name">{{ store.store_name }}</div>
<div class="df-detail-store__metrics">
<div
v-for="field in STORE_METRIC_FIELDS"
:key="field.key"
class="df-detail-store__metric"
>
<span>{{ field.label }}</span>
<span>¥{{ formatMoney(store[field.key]) }}</span>
</div>
</div>
<div class="df-detail-store__total">
<span>开票合计</span>
<span>¥{{ formatMoney(store.invoice_total) }}</span>
</div>
</div>
<div class="df-detail-store__footer">
<Button
size="small"
:type="selectedStoreId === store.store_id ? 'primary' : 'default'"
@click="showStoreDrugs(store.store_id, store.store_name)"
>
{{
selectedStoreId === store.store_id
? '收起药品明细'
: '查看药品明细'
}}
</Button>
</div>
</div>
</div>
<Empty v-else-if="!loading" description="暂无诊所明细" />
<div
v-if="storeTotal > storePageSize"
class="mt-3 flex justify-end"
>
<Pagination
:current="storePage"
:page-size="storePageSize"
:total="storeTotal"
size="small"
@change="onStorePageChange"
/>
</div>
<!-- 药品明细内嵌面板 -->
<div v-if="drugStoreName" class="df-detail-drug-panel">
<div class="df-detail-drug-panel__head">
<div class="df-detail-drug-panel__title">
{{ drugStoreName }} · 药品供货明细
</div>
<Button size="small" @click="closeDrugPanel">收起</Button>
</div>
<Spin :spinning="drugLoading">
<div v-if="drugItems.length" class="df-detail-drug-list">
<div
v-for="drug in drugItems"
:key="drug.drug_id ?? drug.drug_number"
class="df-detail-drug-row"
>
<div class="df-detail-drug-row__main">
<div class="df-detail-drug-row__name">
{{ drug.drug_name }}
</div>
<div class="df-detail-drug-row__sub">
<Tag v-if="drug.type_txt" color="blue">
{{ drug.type_txt }}
</Tag>
<span v-if="drug.drug_number" class="text-slate-400">
{{ drug.drug_number }}
</span>
<span>数量 {{ drug.number ?? '—' }}</span>
</div>
</div>
<div class="df-detail-drug-row__money">
<div class="df-detail-drug-row__price">
单价 ¥{{ formatMoney(drug.market_price) }}
</div>
<div class="df-detail-drug-row__total">
¥{{ formatMoney(drug.total_supply_price) }}
</div>
</div>
</div>
</div>
<Empty
v-else-if="!drugLoading"
description="该诊所暂无药品供货明细"
/>
</Spin>
</div>
</template> </template>
</Table> <Empty v-else-if="!loading" description="暂无报表详情" />
<div v-if="drugStoreName" class="mt-4"> </Spin>
<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> </Drawer>
</template> </template>
<style scoped>
.df-detail-hero {
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: flex-end;
justify-content: space-between;
padding: 18px 20px;
margin-bottom: 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
}
.dark .df-detail-hero {
background: #0f172a;
border-color: #334155;
}
.df-detail-hero__title {
margin-bottom: 10px;
font-size: 20px;
font-weight: 700;
color: #1f4e79;
letter-spacing: 0.02em;
}
.dark .df-detail-hero__title {
color: #93c5fd;
}
.df-detail-hero__meta {
display: flex;
gap: 10px;
justify-content: space-between;
min-width: 260px;
margin-bottom: 4px;
font-size: 13px;
color: #64748b;
}
.df-detail-hero__meta > span:last-child {
color: #334155;
text-align: right;
}
.dark .df-detail-hero__meta > span:last-child {
color: #cbd5e1;
}
.df-detail-hero__right {
text-align: right;
}
.df-detail-hero__amount-label {
font-size: 13px;
color: #64748b;
}
.df-detail-hero__amount-value {
margin-top: 4px;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
color: #2563eb;
}
.dark .df-detail-hero__amount-value {
color: #60a5fa;
}
.df-detail-metrics {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.df-detail-metric {
padding: 14px 14px 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.dark .df-detail-metric {
background: #0f172a;
border-color: #334155;
}
.df-detail-metric:hover {
border-color: #93c5fd;
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
}
.df-detail-metric__label {
margin-bottom: 6px;
font-size: 12px;
color: #64748b;
}
.df-detail-metric__value {
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.dark .df-detail-metric__value {
color: #e2e8f0;
}
.df-detail-section-title {
margin-bottom: 12px;
font-size: 15px;
font-weight: 600;
color: #334155;
}
.dark .df-detail-section-title {
color: #cbd5e1;
}
.df-detail-store-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
.df-detail-store {
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.dark .df-detail-store {
background: #0f172a;
border-color: #334155;
}
.df-detail-store:hover {
border-color: #93c5fd;
box-shadow: 0 6px 16px rgb(15 23 42 / 8%);
}
.df-detail-store--active {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.df-detail-store__main {
flex: 1;
padding: 14px 14px 10px;
}
.df-detail-store__name {
margin-bottom: 10px;
font-size: 15px;
font-weight: 600;
color: #1f4e79;
}
.dark .df-detail-store__name {
color: #93c5fd;
}
.df-detail-store__metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
}
.df-detail-store__metric {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
color: #64748b;
}
.df-detail-store__metric > span:last-child {
font-size: 13px;
font-weight: 600;
color: #334155;
}
.dark .df-detail-store__metric > span:last-child {
color: #cbd5e1;
}
.df-detail-store__total {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-top: 12px;
padding-top: 10px;
font-size: 13px;
color: #64748b;
border-top: 1px dashed #e2e8f0;
}
.dark .df-detail-store__total {
border-top-color: #1e293b;
}
.df-detail-store__total > span:last-child {
font-size: 18px;
font-weight: 700;
color: #2563eb;
}
.dark .df-detail-store__total > span:last-child {
color: #60a5fa;
}
.df-detail-store__footer {
display: flex;
justify-content: flex-end;
padding: 10px 14px 12px;
background: #fafafa;
border-top: 1px solid #f1f5f9;
}
.dark .df-detail-store__footer {
background: #0b1220;
border-top-color: #1e293b;
}
.df-detail-drug-panel {
margin-top: 20px;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.dark .df-detail-drug-panel {
background: #0f172a;
border-color: #334155;
}
.df-detail-drug-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #f1f5f9;
}
.dark .df-detail-drug-panel__head {
background: #0b1220;
border-bottom-color: #1e293b;
}
.df-detail-drug-panel__title {
font-size: 14px;
font-weight: 600;
color: #1f4e79;
}
.dark .df-detail-drug-panel__title {
color: #93c5fd;
}
.df-detail-drug-list {
display: flex;
flex-direction: column;
max-height: 420px;
overflow: auto;
}
.df-detail-drug-row {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
.dark .df-detail-drug-row {
border-bottom-color: #1e293b;
}
.df-detail-drug-row:last-child {
border-bottom: none;
}
.df-detail-drug-row:nth-child(even) {
background: #fafafa;
}
.dark .df-detail-drug-row:nth-child(even) {
background: #0b1220;
}
.df-detail-drug-row__name {
margin-bottom: 4px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.dark .df-detail-drug-row__name {
color: #e2e8f0;
}
.df-detail-drug-row__sub {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 12px;
color: #64748b;
}
.df-detail-drug-row__money {
flex-shrink: 0;
text-align: right;
}
.df-detail-drug-row__price {
margin-bottom: 2px;
font-size: 12px;
color: #94a3b8;
}
.df-detail-drug-row__total {
font-size: 16px;
font-weight: 700;
color: #2563eb;
}
.dark .df-detail-drug-row__total {
color: #60a5fa;
}
</style>

View File

@@ -1,15 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
/**
* 部门财务列表C 端卡片网格 + ExcelJS 前端导出
*/
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { Button, Table } from 'ant-design-vue'; import { Button, Empty, Pagination, Spin, message } from 'ant-design-vue';
import { TableAction } from '#/components/table-action'; import { getDepartmentFinanceReportListApi } from '#/views/finance/department-finance/api';
import { import { buildAndDownloadDepartmentFinanceExcel } from '#/views/finance/department-finance/utils/exportDepartmentFinanceExcel';
exportDepartmentFinanceReportApi,
getDepartmentFinanceReportListApi,
} from '#/views/finance/department-finance/api';
import ConfigModal from './components/ConfigModal.vue'; import ConfigModal from './components/ConfigModal.vue';
import GenerateReportModal from './components/GenerateReportModal.vue'; import GenerateReportModal from './components/GenerateReportModal.vue';
@@ -17,20 +17,23 @@ import ReportDetailDrawer from './components/ReportDetailDrawer.vue';
const loading = ref(false); const loading = ref(false);
const list = ref<any[]>([]); const list = ref<any[]>([]);
const pagination = ref({ current: 1, pageSize: 20, total: 0 }); const pagination = ref({ current: 1, pageSize: 12, total: 0 });
/** 当前正在导出的报表 id用于卡片按钮 loading */
const exportingId = ref<number | null>(null);
const reportDetailDrawerRef = ref<InstanceType<typeof ReportDetailDrawer>>(); const reportDetailDrawerRef = ref<InstanceType<typeof ReportDetailDrawer>>();
const configModalRef = ref<InstanceType<typeof ConfigModal>>(); const configModalRef = ref<InstanceType<typeof ConfigModal>>();
const generateReportModalRef = ref<InstanceType<typeof GenerateReportModal>>(); const generateReportModalRef = ref<InstanceType<typeof GenerateReportModal>>();
const columns = [ /** 金额展示:千分位 + 两位小数 */
{ title: '报表编号', dataIndex: 'report_no', key: 'report_no', width: 160 }, function formatMoney(value: unknown): string {
{ title: '统计开始', dataIndex: 'period_start', key: 'period_start', width: 170 }, const n = Number(value);
{ title: '统计结束', dataIndex: 'period_end', key: 'period_end', width: 170 }, if (!Number.isFinite(n)) return '—';
{ title: '生成时间', dataIndex: 'generated_at', key: 'generated_at', width: 170 }, return n.toLocaleString(undefined, {
{ title: '开票合计', dataIndex: 'invoice_total', key: 'invoice_total', width: 120 }, minimumFractionDigits: 2,
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const }, maximumFractionDigits: 2,
]; });
}
/** 拉取报表列表 */ /** 拉取报表列表 */
async function fetchList(page = pagination.value.current) { async function fetchList(page = pagination.value.current) {
@@ -67,15 +70,34 @@ function openDetail(id: number) {
reportDetailDrawerRef.value?.open(id); reportDetailDrawerRef.value?.open(id);
} }
/** 导出报表 */ /**
* 导出报表:拉详情 + 药品明细 → ExcelJS 美化生成 → 下载
*/
async function handleExport(id: number) { async function handleExport(id: number) {
await exportDepartmentFinanceReportApi({ id, include_drugs: 1 }); if (exportingId.value != null) return;
exportingId.value = id;
const hide = message.loading('正在生成 Excel…', 0);
try {
await buildAndDownloadDepartmentFinanceExcel(id);
message.success('导出成功');
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally {
hide();
exportingId.value = null;
}
} }
function openConfig() { function openConfig() {
configModalRef.value?.open(); configModalRef.value?.open();
} }
/** 分页切换 */
function onPageChange(page: number, pageSize: number) {
pagination.value.pageSize = pageSize;
fetchList(page);
}
onMounted(() => { onMounted(() => {
fetchList(); fetchList();
}); });
@@ -85,54 +107,160 @@ onMounted(() => {
<Page auto-content-height title="部门财务"> <Page auto-content-height title="部门财务">
<ReportDetailDrawer ref="reportDetailDrawerRef" /> <ReportDetailDrawer ref="reportDetailDrawerRef" />
<ConfigModal ref="configModalRef" /> <ConfigModal ref="configModalRef" />
<GenerateReportModal ref="generateReportModalRef" @success="onGenerateSuccess" /> <GenerateReportModal
<div class="mb-3 flex flex-wrap gap-2"> ref="generateReportModalRef"
<Button type="primary" @click="handleGenerate"> @success="onGenerateSuccess"
手动生成报表 />
</Button> <div class="mb-4 flex flex-wrap gap-2">
<Button type="primary" @click="handleGenerate">手动生成报表</Button>
<Button @click="openConfig">生成配置</Button> <Button @click="openConfig">生成配置</Button>
<Button @click="fetchList()">刷新</Button> <Button @click="fetchList()">刷新</Button>
</div> </div>
<Table <Spin :spinning="loading">
:columns="columns" <div v-if="list.length" class="df-card-grid">
:data-source="list" <div
:loading="loading" v-for="item in list"
:pagination="{ :key="item.id"
current: pagination.current, class="df-card"
pageSize: pagination.pageSize, >
total: pagination.total, <div class="df-card__main">
showSizeChanger: true, <div class="df-card__title">{{ item.report_no }}</div>
onChange: (p: number, ps: number) => { <div class="df-card__meta">
pagination.pageSize = ps; <span>统计区间</span>
fetchList(p); <span>{{ item.period_start }} ~ {{ item.period_end }}</span>
}, </div>
}" <div class="df-card__meta">
row-key="id" <span>生成时间</span>
bordered <span>{{ item.generated_at }}</span>
size="small" </div>
:scroll="{ x: 1100 }" <div class="df-card__amount">
<span class="df-card__amount-label">开票合计</span>
<span class="df-card__amount-value">
¥{{ formatMoney(item.invoice_total) }}
</span>
</div>
</div>
<div class="df-card__footer">
<Button size="small" @click="openDetail(item.id)">详情</Button>
<Button
size="small"
type="primary"
:loading="exportingId === item.id"
@click="handleExport(item.id)"
>
导出
</Button>
</div>
</div>
</div>
<Empty v-else-if="!loading" description="暂无部门财务报表" />
</Spin>
<div
v-if="pagination.total > 0"
class="mt-4 flex justify-end"
> >
<template #bodyCell="{ column, record }"> <Pagination
<template v-if="column.key === 'action'"> :current="pagination.current"
<TableAction :page-size="pagination.pageSize"
:actions="[ :total="pagination.total"
{ :page-size-options="['12', '24', '48']"
label: '详情', show-size-changer
type: 'link', show-quick-jumper
size: 'small', @change="onPageChange"
onClick: () => openDetail(record.id), />
}, </div>
{
label: '导出',
type: 'link',
size: 'small',
onClick: () => handleExport(record.id),
},
]"
:drop-down-actions="[]"
/>
</template>
</template>
</Table>
</Page> </Page>
</template> </template>
<style scoped>
.df-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.df-card {
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.dark .df-card {
background: #0f172a;
border-color: #334155;
}
.df-card:hover {
border-color: #93c5fd;
box-shadow: 0 6px 16px rgb(15 23 42 / 8%);
}
.df-card__main {
flex: 1;
padding: 16px 16px 12px;
}
.df-card__title {
margin-bottom: 12px;
font-size: 16px;
font-weight: 600;
color: #1f4e79;
letter-spacing: 0.02em;
}
.dark .df-card__title {
color: #93c5fd;
}
.df-card__meta {
display: flex;
gap: 8px;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
color: #64748b;
}
.df-card__meta > span:last-child {
color: #334155;
text-align: right;
}
.dark .df-card__meta > span:last-child {
color: #cbd5e1;
}
.df-card__amount {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
.dark .df-card__amount {
border-top-color: #1e293b;
}
.df-card__amount-label {
font-size: 13px;
color: #64748b;
}
.df-card__amount-value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
color: #2563eb;
}
.dark .df-card__amount-value {
color: #60a5fa;
}
.df-card__footer {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 10px 16px 12px;
background: #fafafa;
border-top: 1px solid #f1f5f9;
}
.dark .df-card__footer {
background: #0b1220;
border-top-color: #1e293b;
}
</style>

View File

@@ -0,0 +1,512 @@
/**
* 部门财务报表 ExcelJS 导出
* 面向 C 端用户:标题区 / 信息区 / 表头 / 斑马纹 / 合计行 / 冻结窗格
*/
import ExcelJS from 'exceljs';
import { downloadByData } from '#/util/tool';
import {
getDepartmentFinanceReportDetailApi,
getDepartmentFinanceStoreDrugDetailApi,
} from '#/views/finance/department-finance/api';
const XLSX_MIME =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } },
};
const DOUBLE_TOP_BORDER: Partial<ExcelJS.Borders> = {
...THIN_BORDER,
top: { style: 'double', color: { argb: 'FF000000' } },
};
/** 诊所明细金额列(不含诊所名称) */
const STORE_MONEY_KEYS = [
'herbal_supply_price',
'granule_supply_price',
'medicine_supply_price',
'herbal_decoct_fee',
'herbal_express_fee',
'medicine_express_fee',
'granule_express_fee',
'invoice_total',
] as const;
const STORE_HEADERS = [
'诊所名称',
'中药供货价',
'颗粒药供货价',
'西药供货价',
'中药代煎费',
'中药运费',
'西药运费',
'颗粒药运费',
'开票合计',
];
const DRUG_HEADERS = [
'诊所名称',
'药品编码',
'药品名称',
'药品类型',
'数量',
'供货单价',
'供货总额',
];
/** 汇总键值表:指标名 -> summary 字段 */
const SUMMARY_METRICS: { label: string; key: string; highlight?: boolean }[] = [
{ label: '中药供货价', key: 'herbal_supply_price' },
{ label: '颗粒药供货价', key: 'granule_supply_price' },
{ label: '西药供货价', key: 'medicine_supply_price' },
{ label: '中药代煎费', key: 'herbal_decoct_fee' },
{ label: '中药运费', key: 'herbal_express_fee' },
{ label: '西药运费', key: 'medicine_express_fee' },
{ label: '颗粒药运费', key: 'granule_express_fee' },
{ label: '开票合计', key: 'invoice_total', highlight: true },
];
export interface DepartmentFinanceDrugRow {
store_name: string;
drug_number?: string;
drug_name?: string;
type_txt?: string;
number?: number | string;
market_price?: number | string;
total_supply_price?: number | string;
}
function formatDateTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** 金额转数值,无效则返回 0 */
function toMoney(raw: unknown): number {
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
}
function applyMoneyCell(cell: ExcelJS.Cell, value: unknown) {
cell.value = toMoney(value);
cell.numFmt = '#,##0.00';
cell.alignment = { horizontal: 'right', vertical: 'middle' };
cell.border = THIN_BORDER;
}
function applyTextCell(cell: ExcelJS.Cell, value: unknown, align: 'left' | 'center' = 'left') {
cell.value = value == null || value === '' ? '' : String(value);
cell.alignment = { horizontal: align, vertical: 'middle', wrapText: true };
cell.border = THIN_BORDER;
}
function applyIntCell(cell: ExcelJS.Cell, value: unknown) {
const n = Number(value);
cell.value = Number.isFinite(n) ? n : value == null ? '' : String(value);
cell.numFmt = '0';
cell.alignment = { horizontal: 'right', vertical: 'middle' };
cell.border = THIN_BORDER;
}
function styleInfoLabel(cell: ExcelJS.Cell, label: string) {
cell.value = label;
cell.font = { bold: true, size: 10 };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F0FE' },
};
cell.alignment = { horizontal: 'right', vertical: 'middle' };
cell.border = THIN_BORDER;
}
function styleInfoValue(cell: ExcelJS.Cell, value: string) {
cell.value = value;
cell.font = { size: 10 };
cell.alignment = { horizontal: 'left', vertical: 'middle' };
cell.border = THIN_BORDER;
}
/**
* 写入标题行(合并 layoutCols 列)
* 为什么C 端打开文件时先看到品牌标题,降低「裸表」感
*/
function writeTitleRow(
sheet: ExcelJS.Worksheet,
layoutCols: number,
title: string,
) {
sheet.mergeCells(1, 1, 1, layoutCols);
const titleCell = sheet.getCell(1, 1);
titleCell.value = title;
titleCell.font = { bold: true, size: 16, color: { argb: 'FF1F4E79' } };
titleCell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF5F7FA' },
};
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
titleCell.border = THIN_BORDER;
sheet.getRow(1).height = 36;
}
/**
* 写入报表信息区2 列一组:标签|值),返回下一可用行号
*/
function writeReportInfoBlock(
sheet: ExcelJS.Worksheet,
detail: Record<string, any>,
layoutCols: number,
exportedAt: Date,
): number {
const pairs: [string, string][] = [
['报表编号', String(detail.report_no ?? '')],
['统计区间', `${detail.period_start ?? ''} ~ ${detail.period_end ?? ''}`],
['生成时间', String(detail.generated_at ?? '')],
['导出时间', formatDateTime(exportedAt)],
];
let row = 2;
for (const [label, value] of pairs) {
styleInfoLabel(sheet.getCell(row, 1), label);
if (layoutCols >= 2) {
sheet.mergeCells(row, 2, row, layoutCols);
}
styleInfoValue(sheet.getCell(row, 2), value);
sheet.getRow(row).height = 22;
row += 1;
}
// 空一行分隔信息区与表头
sheet.getRow(row).height = 8;
return row + 1;
}
/** 表头行样式 */
function writeHeaderRow(
sheet: ExcelJS.Worksheet,
rowNum: number,
headers: string[],
) {
headers.forEach((label, i) => {
const cell = sheet.getCell(rowNum, i + 1);
cell.value = label;
cell.font = { bold: true, color: { argb: 'FF000000' } };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFFF00' },
};
cell.alignment = { horizontal: 'center', vertical: 'middle' };
cell.border = THIN_BORDER;
});
sheet.getRow(rowNum).height = 30;
}
/** 按内容估算列宽 */
function autoFitColumns(
sheet: ExcelJS.Worksheet,
colCount: number,
startRow: number,
endRow: number,
minWidth = 10,
maxWidth = 40,
) {
for (let c = 1; c <= colCount; c++) {
let maxLen = minWidth;
for (let r = startRow; r <= endRow; r++) {
const v = sheet.getCell(r, c).value;
const s =
v == null
? ''
: typeof v === 'object' && 'richText' in (v as object)
? ''
: String(v);
maxLen = Math.max(maxLen, s.length);
}
sheet.getColumn(c).width = Math.min(Math.max(maxLen * 1.2 + 2, minWidth), maxWidth);
}
}
function freezeAt(sheet: ExcelJS.Worksheet, headerRow: number) {
sheet.views = [
{
state: 'frozen',
xSplit: 0,
ySplit: headerRow,
topLeftCell: `A${headerRow + 1}`,
activeCell: `A${headerRow + 1}`,
},
];
}
/**
* Sheet1报表汇总 —— 键值表,开票合计高亮
*/
function buildSummarySheet(
workbook: ExcelJS.Workbook,
detail: Record<string, any>,
exportedAt: Date,
) {
const sheet = workbook.addWorksheet('报表汇总', {
views: [{ showGridLines: true }],
});
const layoutCols = 2;
writeTitleRow(sheet, layoutCols, '萧康云医 · 部门财务报表');
const headerRow = writeReportInfoBlock(sheet, detail, layoutCols, exportedAt);
// 键值表表头
writeHeaderRow(sheet, headerRow, ['指标', '金额(元)']);
const summary = detail.summary ?? detail;
let dataRow = headerRow + 1;
SUMMARY_METRICS.forEach((metric, idx) => {
const labelCell = sheet.getCell(dataRow, 1);
const moneyCell = sheet.getCell(dataRow, 2);
applyTextCell(labelCell, metric.label);
applyMoneyCell(moneyCell, summary[metric.key]);
moneyCell.font = { bold: true, size: metric.highlight ? 12 : 11 };
if (metric.highlight) {
labelCell.font = { bold: true, color: { argb: 'FF1F4E79' } };
moneyCell.font = { bold: true, size: 13, color: { argb: 'FF1F4E79' } };
const fill = {
type: 'pattern' as const,
pattern: 'solid' as const,
fgColor: { argb: 'FFE8F0FE' },
};
labelCell.fill = fill;
moneyCell.fill = fill;
} else if (idx % 2 === 1) {
const zebra = {
type: 'pattern' as const,
pattern: 'solid' as const,
fgColor: { argb: 'FFF2F2F2' },
};
labelCell.fill = zebra;
moneyCell.fill = zebra;
}
sheet.getRow(dataRow).height = metric.highlight ? 28 : 24;
dataRow += 1;
});
autoFitColumns(sheet, layoutCols, 1, dataRow - 1, 14, 28);
freezeAt(sheet, headerRow);
}
/**
* Sheet2诊所明细 + 底部合计行
*/
function buildStoreSheet(
workbook: ExcelJS.Workbook,
detail: Record<string, any>,
exportedAt: Date,
) {
const sheet = workbook.addWorksheet('诊所明细', {
views: [{ showGridLines: true }],
});
const colCount = STORE_HEADERS.length;
writeTitleRow(sheet, colCount, '萧康云医 · 部门财务 · 诊所明细');
const headerRow = writeReportInfoBlock(sheet, detail, colCount, exportedAt);
writeHeaderRow(sheet, headerRow, STORE_HEADERS);
const stores: Record<string, any>[] = detail.stores ?? [];
const dataStart = headerRow + 1;
stores.forEach((store, rowIdx) => {
const r = dataStart + rowIdx;
applyTextCell(sheet.getCell(r, 1), store.store_name);
STORE_MONEY_KEYS.forEach((key, i) => {
applyMoneyCell(sheet.getCell(r, i + 2), store[key]);
});
if (rowIdx % 2 === 1) {
for (let c = 1; c <= colCount; c++) {
sheet.getCell(r, c).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF2F2F2' },
};
}
}
sheet.getRow(r).height = 22;
});
const dataEnd = dataStart + Math.max(stores.length, 1) - 1;
const summaryRow = (stores.length > 0 ? dataEnd : headerRow) + 2;
// 合计行
const sumFill = {
type: 'pattern' as const,
pattern: 'solid' as const,
fgColor: { argb: 'FFFFF9E6' },
};
const titleCell = sheet.getCell(summaryRow, 1);
titleCell.value = '合计';
titleCell.font = { bold: true, size: 11 };
titleCell.fill = sumFill;
titleCell.border = DOUBLE_TOP_BORDER;
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
STORE_MONEY_KEYS.forEach((key, i) => {
const cell = sheet.getCell(summaryRow, i + 2);
const sum = stores.reduce((acc, s) => acc + toMoney(s[key]), 0);
cell.value = sum;
cell.numFmt = '#,##0.00';
cell.font = { bold: true };
cell.fill = sumFill;
cell.border = DOUBLE_TOP_BORDER;
cell.alignment = { horizontal: 'right', vertical: 'middle' };
});
sheet.getRow(summaryRow).height = 24;
autoFitColumns(sheet, colCount, headerRow, summaryRow, 10, 22);
freezeAt(sheet, headerRow);
}
/**
* Sheet3药品供货明细按诊所分组标题行
*/
function buildDrugSheet(
workbook: ExcelJS.Workbook,
detail: Record<string, any>,
drugRows: DepartmentFinanceDrugRow[],
exportedAt: Date,
) {
const sheet = workbook.addWorksheet('药品供货明细', {
views: [{ showGridLines: true }],
});
const colCount = DRUG_HEADERS.length;
writeTitleRow(sheet, colCount, '萧康云医 · 部门财务 · 药品供货明细');
const headerRow = writeReportInfoBlock(sheet, detail, colCount, exportedAt);
writeHeaderRow(sheet, headerRow, DRUG_HEADERS);
// 按诊所分组,保持接口返回顺序
const groups = new Map<string, DepartmentFinanceDrugRow[]>();
for (const row of drugRows) {
const name = row.store_name || '未知诊所';
if (!groups.has(name)) groups.set(name, []);
groups.get(name)!.push(row);
}
let r = headerRow + 1;
let zebra = 0;
for (const [storeName, rows] of groups) {
// 分组标题行
sheet.mergeCells(r, 1, r, colCount);
const groupCell = sheet.getCell(r, 1);
groupCell.value = `诊所:${storeName}${rows.length} 条)`;
groupCell.font = { bold: true, size: 11, color: { argb: 'FF1F4E79' } };
groupCell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F0FE' },
};
groupCell.alignment = { horizontal: 'left', vertical: 'middle' };
groupCell.border = THIN_BORDER;
for (let c = 2; c <= colCount; c++) {
sheet.getCell(r, c).border = THIN_BORDER;
sheet.getCell(r, c).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F0FE' },
};
}
sheet.getRow(r).height = 24;
r += 1;
for (const drug of rows) {
applyTextCell(sheet.getCell(r, 1), drug.store_name);
applyTextCell(sheet.getCell(r, 2), drug.drug_number);
applyTextCell(sheet.getCell(r, 3), drug.drug_name);
applyTextCell(sheet.getCell(r, 4), drug.type_txt, 'center');
applyIntCell(sheet.getCell(r, 5), drug.number);
applyMoneyCell(sheet.getCell(r, 6), drug.market_price);
applyMoneyCell(sheet.getCell(r, 7), drug.total_supply_price);
if (zebra % 2 === 1) {
for (let c = 1; c <= colCount; c++) {
sheet.getCell(r, c).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF2F2F2' },
};
}
}
sheet.getRow(r).height = 22;
r += 1;
zebra += 1;
}
}
if (drugRows.length === 0) {
sheet.mergeCells(r, 1, r, colCount);
applyTextCell(sheet.getCell(r, 1), '暂无药品供货明细', 'center');
r += 1;
}
autoFitColumns(sheet, colCount, headerRow, Math.max(r - 1, headerRow), 10, 36);
freezeAt(sheet, headerRow);
}
/**
* 根据详情 + 药品行生成美化后的 xlsx buffer
*/
export async function exportDepartmentFinanceExcel(
detail: Record<string, any>,
drugRows: DepartmentFinanceDrugRow[],
exportedAt = new Date(),
): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = '萧康云医';
workbook.created = exportedAt;
buildSummarySheet(workbook, detail, exportedAt);
buildStoreSheet(workbook, detail, exportedAt);
buildDrugSheet(workbook, detail, drugRows, exportedAt);
return workbook.xlsx.writeBuffer() as Promise<ArrayBuffer>;
}
/** 安全文件名 */
export function exportDepartmentFinanceFilename(reportNo: string): string {
const safe = String(reportNo || 'report').replace(/[\\/:*?"<>|]+/g, '_');
return `部门财务报表_${safe}.xlsx`;
}
/**
* 拉取详情与各诊所药品明细,生成并下载 Excel
* 为什么串行拉药品:与后台导出口径一致,避免并发打爆接口
*/
export async function buildAndDownloadDepartmentFinanceExcel(
reportId: number,
): Promise<void> {
const detail = await getDepartmentFinanceReportDetailApi(reportId);
if (!detail) {
throw new Error('报表不存在');
}
const drugRows: DepartmentFinanceDrugRow[] = [];
const stores: Record<string, any>[] = detail.stores ?? [];
for (const store of stores) {
const res = await getDepartmentFinanceStoreDrugDetailApi({
store_id: store.store_id,
report_id: reportId,
});
for (const drug of res?.items ?? []) {
drugRows.push({
store_name: store.store_name,
drug_number: drug.drug_number,
drug_name: drug.drug_name,
type_txt: drug.type_txt,
number: drug.number,
market_price: drug.market_price,
total_supply_price: drug.total_supply_price,
});
}
}
const buffer = await exportDepartmentFinanceExcel(detail, drugRows);
downloadByData(
buffer,
exportDepartmentFinanceFilename(detail.report_no),
XLSX_MIME,
);
}