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
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:
@@ -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 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user