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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 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>
|
||||
import { computed, ref } from 'vue';
|
||||
/**
|
||||
* 部门财务报表详情抽屉(C 端风格)
|
||||
* 英雄头 + 指标卡 + 诊所卡片 + 药品内嵌面板 + ExcelJS 导出
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Button, Table } from 'ant-design-vue';
|
||||
|
||||
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
|
||||
import {
|
||||
exportDepartmentFinanceReportApi,
|
||||
Button,
|
||||
Empty,
|
||||
Pagination,
|
||||
Spin,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getDepartmentFinanceReportDetailApi,
|
||||
getDepartmentFinanceStoreDrugDetailApi,
|
||||
} 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 exportLoading = ref(false);
|
||||
const reportId = ref(0);
|
||||
const detail = ref<any>(null);
|
||||
const drugLoading = ref(false);
|
||||
const drugItems = ref<any[]>([]);
|
||||
const drugStoreName = ref('');
|
||||
const selectedStoreId = ref<number | null>(null);
|
||||
const storePage = ref(1);
|
||||
const storePageSize = 8;
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
onOpenChange(isOpen) {
|
||||
@@ -26,46 +51,47 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
} else {
|
||||
detail.value = null;
|
||||
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;
|
||||
if (!s) return null;
|
||||
return [
|
||||
{ title: '开票合计', value: s.invoice_total, icon: 'mdi:cash-multiple', color: 'text-blue-600', unit: '¥' },
|
||||
{ title: '中药供货价', value: s.herbal_supply_price, icon: 'mdi:leaf', color: 'text-green-600', unit: '¥' },
|
||||
{ title: '颗粒药供货价', value: s.granule_supply_price, icon: 'mdi:pill', color: 'text-teal-600', unit: '¥' },
|
||||
{ title: '西药供货价', value: s.medicine_supply_price, icon: 'mdi:pharmacy', color: 'text-purple-600', unit: '¥' },
|
||||
{ title: '中药代煎费', value: s.herbal_decoct_fee, icon: 'mdi:fire', color: 'text-orange-600', unit: '¥' },
|
||||
{ title: '中药运费', value: s.herbal_express_fee, icon: 'skill-icons:expressjs-dark', color: 'text-orange-500', unit: '¥' },
|
||||
{ title: '西药运费', value: s.medicine_express_fee, icon: 'mdi:truck', color: 'text-indigo-600', unit: '¥' },
|
||||
{ title: '颗粒药运费', value: s.granule_express_fee, icon: 'mdi:truck-outline', color: 'text-cyan-600', unit: '¥' },
|
||||
];
|
||||
if (!s) return [];
|
||||
return STORE_METRIC_FIELDS.map((f) => ({
|
||||
label: f.label,
|
||||
value: s[f.key],
|
||||
}));
|
||||
});
|
||||
|
||||
const storeColumns = [
|
||||
{ title: '诊所名称', dataIndex: 'store_name', key: 'store_name', width: 160, ellipsis: true },
|
||||
{ title: '中药供货价', dataIndex: 'herbal_supply_price', key: 'herbal_supply_price', width: 100 },
|
||||
{ title: '颗粒药供货价', dataIndex: 'granule_supply_price', key: 'granule_supply_price', width: 110 },
|
||||
{ title: '西药供货价', dataIndex: 'medicine_supply_price', key: 'medicine_supply_price', width: 100 },
|
||||
{ title: '中药代煎费', dataIndex: 'herbal_decoct_fee', key: 'herbal_decoct_fee', width: 100 },
|
||||
{ title: '中药运费', dataIndex: 'herbal_express_fee', key: 'herbal_express_fee', width: 90 },
|
||||
{ title: '西药运费', dataIndex: 'medicine_express_fee', key: 'medicine_express_fee', width: 90 },
|
||||
{ title: '颗粒药运费', dataIndex: 'granule_express_fee', key: 'granule_express_fee', width: 100 },
|
||||
{ title: '开票合计', dataIndex: 'invoice_total', key: 'invoice_total', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
|
||||
];
|
||||
const allStores = computed(() => detail.value?.stores ?? []);
|
||||
|
||||
const drugColumns = [
|
||||
{ title: '药名', dataIndex: 'drug_name', key: 'drug_name', width: 160, ellipsis: true },
|
||||
{ title: '药品编号', dataIndex: 'drug_number', key: 'drug_number', width: 120 },
|
||||
{ title: '类型', dataIndex: 'type_txt', key: 'type_txt', width: 100 },
|
||||
{ title: '数量', dataIndex: 'number', key: 'number', width: 80 },
|
||||
{ title: '供货单价', dataIndex: 'market_price', key: 'market_price', width: 100 },
|
||||
{ title: '供货总额', dataIndex: 'total_supply_price', key: 'total_supply_price', width: 110 },
|
||||
];
|
||||
const storeTotal = computed(() => allStores.value.length);
|
||||
|
||||
/** 诊所前端分页切片 */
|
||||
const pagedStores = computed(() => {
|
||||
const start = (storePage.value - 1) * storePageSize;
|
||||
return allStores.value.slice(start, start + storePageSize);
|
||||
});
|
||||
|
||||
watch(storeTotal, () => {
|
||||
const maxPage = Math.max(1, Math.ceil(storeTotal.value / storePageSize) || 1);
|
||||
if (storePage.value > maxPage) storePage.value = maxPage;
|
||||
});
|
||||
|
||||
/** 加载报表详情 */
|
||||
async function loadDetail() {
|
||||
@@ -73,6 +99,10 @@ async function loadDetail() {
|
||||
if (!data?.id) return;
|
||||
reportId.value = data.id;
|
||||
loading.value = true;
|
||||
selectedStoreId.value = null;
|
||||
drugItems.value = [];
|
||||
drugStoreName.value = '';
|
||||
storePage.value = 1;
|
||||
try {
|
||||
detail.value = await getDepartmentFinanceReportDetailApi(data.id);
|
||||
} finally {
|
||||
@@ -80,8 +110,15 @@ async function loadDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 查看诊所药品供货明细 */
|
||||
/**
|
||||
* 查看诊所药品供货明细(再次点击同一诊所则收起)
|
||||
*/
|
||||
async function showStoreDrugs(storeId: number, name: string) {
|
||||
if (selectedStoreId.value === storeId) {
|
||||
closeDrugPanel();
|
||||
return;
|
||||
}
|
||||
selectedStoreId.value = storeId;
|
||||
drugStoreName.value = name;
|
||||
drugLoading.value = true;
|
||||
try {
|
||||
@@ -90,15 +127,40 @@ async function showStoreDrugs(storeId: number, name: string) {
|
||||
report_id: reportId.value,
|
||||
});
|
||||
drugItems.value = res.items ?? [];
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载药品明细失败');
|
||||
drugItems.value = [];
|
||||
} finally {
|
||||
drugLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出当前报表 */
|
||||
/** 收起药品明细面板 */
|
||||
function closeDrugPanel() {
|
||||
selectedStoreId.value = null;
|
||||
drugStoreName.value = '';
|
||||
drugItems.value = [];
|
||||
}
|
||||
|
||||
/** 诊所分页切换 */
|
||||
function onStorePageChange(page: number) {
|
||||
storePage.value = page;
|
||||
}
|
||||
|
||||
/** 导出当前报表(ExcelJS 美化) */
|
||||
async function handleExport() {
|
||||
if (!reportId.value) return;
|
||||
await exportDepartmentFinanceReportApi({ id: reportId.value, include_drugs: 1 });
|
||||
if (!reportId.value || exportLoading.value) return;
|
||||
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({
|
||||
@@ -112,49 +174,454 @@ defineExpose({
|
||||
<template>
|
||||
<Drawer
|
||||
:title="detail ? `报表详情 - ${detail.report_no}` : '报表详情'"
|
||||
class="w-[1100px]"
|
||||
class="w-[80%]"
|
||||
>
|
||||
<div v-if="detail" class="mb-3 text-sm text-muted-foreground">
|
||||
统计区间:{{ detail.period_start }} ~ {{ detail.period_end }}
|
||||
<span class="ml-3">生成时间:{{ detail.generated_at }}</span>
|
||||
</div>
|
||||
<StatisticsReconciliation
|
||||
v-if="statistics"
|
||||
:statistics="statistics"
|
||||
/>
|
||||
<div class="mb-3 mt-4 flex justify-end">
|
||||
<Button type="primary" @click="handleExport">导出 Excel</Button>
|
||||
</div>
|
||||
<Table
|
||||
:columns="storeColumns"
|
||||
:data-source="detail?.stores ?? []"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
bordered
|
||||
:scroll="{ x: 1200 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="showStoreDrugs(record.store_id, record.store_name)">
|
||||
药品明细
|
||||
</Button>
|
||||
</template>
|
||||
<Spin :spinning="loading">
|
||||
<template v-if="detail">
|
||||
<!-- 英雄头:编号 + 区间 + 开票合计 + 导出 -->
|
||||
<div class="df-detail-hero">
|
||||
<div class="df-detail-hero__left">
|
||||
<div class="df-detail-hero__title">{{ detail.report_no }}</div>
|
||||
<div class="df-detail-hero__meta">
|
||||
<span>统计区间</span>
|
||||
<span>{{ detail.period_start }} ~ {{ detail.period_end }}</span>
|
||||
</div>
|
||||
<div class="df-detail-hero__meta">
|
||||
<span>生成时间</span>
|
||||
<span>{{ detail.generated_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="df-detail-hero__right">
|
||||
<div class="df-detail-hero__amount-label">开票合计</div>
|
||||
<div class="df-detail-hero__amount-value">
|
||||
¥{{ formatMoney(detail.summary?.invoice_total ?? detail.invoice_total) }}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
class="mt-3"
|
||||
:loading="exportLoading"
|
||||
@click="handleExport"
|
||||
>
|
||||
导出 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>
|
||||
</Table>
|
||||
<div v-if="drugStoreName" class="mt-4">
|
||||
<div class="mb-2 font-medium">{{ drugStoreName }} - 药品供货明细</div>
|
||||
<Table
|
||||
:columns="drugColumns"
|
||||
:data-source="drugItems"
|
||||
:loading="drugLoading"
|
||||
:pagination="{ pageSize: 8 }"
|
||||
row-key="drug_id"
|
||||
size="small"
|
||||
bordered
|
||||
:scroll="{ x: 700 }"
|
||||
/>
|
||||
</div>
|
||||
<Empty v-else-if="!loading" description="暂无报表详情" />
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</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>
|
||||
/**
|
||||
* 部门财务列表:C 端卡片网格 + ExcelJS 前端导出
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
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 {
|
||||
exportDepartmentFinanceReportApi,
|
||||
getDepartmentFinanceReportListApi,
|
||||
} from '#/views/finance/department-finance/api';
|
||||
import { getDepartmentFinanceReportListApi } from '#/views/finance/department-finance/api';
|
||||
import { buildAndDownloadDepartmentFinanceExcel } from '#/views/finance/department-finance/utils/exportDepartmentFinanceExcel';
|
||||
|
||||
import ConfigModal from './components/ConfigModal.vue';
|
||||
import GenerateReportModal from './components/GenerateReportModal.vue';
|
||||
@@ -17,20 +17,23 @@ import ReportDetailDrawer from './components/ReportDetailDrawer.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
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 configModalRef = ref<InstanceType<typeof ConfigModal>>();
|
||||
const generateReportModalRef = ref<InstanceType<typeof GenerateReportModal>>();
|
||||
|
||||
const columns = [
|
||||
{ title: '报表编号', dataIndex: 'report_no', key: 'report_no', width: 160 },
|
||||
{ title: '统计开始', dataIndex: 'period_start', key: 'period_start', width: 170 },
|
||||
{ title: '统计结束', dataIndex: 'period_end', key: 'period_end', width: 170 },
|
||||
{ title: '生成时间', dataIndex: 'generated_at', key: 'generated_at', width: 170 },
|
||||
{ title: '开票合计', dataIndex: 'invoice_total', key: 'invoice_total', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const },
|
||||
];
|
||||
/** 金额展示:千分位 + 两位小数 */
|
||||
function formatMoney(value: unknown): string {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** 拉取报表列表 */
|
||||
async function fetchList(page = pagination.value.current) {
|
||||
@@ -67,15 +70,34 @@ function openDetail(id: number) {
|
||||
reportDetailDrawerRef.value?.open(id);
|
||||
}
|
||||
|
||||
/** 导出报表 */
|
||||
/**
|
||||
* 导出报表:拉详情 + 药品明细 → ExcelJS 美化生成 → 下载
|
||||
*/
|
||||
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() {
|
||||
configModalRef.value?.open();
|
||||
}
|
||||
|
||||
/** 分页切换 */
|
||||
function onPageChange(page: number, pageSize: number) {
|
||||
pagination.value.pageSize = pageSize;
|
||||
fetchList(page);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
@@ -85,54 +107,160 @@ onMounted(() => {
|
||||
<Page auto-content-height title="部门财务">
|
||||
<ReportDetailDrawer ref="reportDetailDrawerRef" />
|
||||
<ConfigModal ref="configModalRef" />
|
||||
<GenerateReportModal ref="generateReportModalRef" @success="onGenerateSuccess" />
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<Button type="primary" @click="handleGenerate">
|
||||
手动生成报表
|
||||
</Button>
|
||||
<GenerateReportModal
|
||||
ref="generateReportModalRef"
|
||||
@success="onGenerateSuccess"
|
||||
/>
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<Button type="primary" @click="handleGenerate">手动生成报表</Button>
|
||||
<Button @click="openConfig">生成配置</Button>
|
||||
<Button @click="fetchList()">刷新</Button>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p: number, ps: number) => {
|
||||
pagination.pageSize = ps;
|
||||
fetchList(p);
|
||||
},
|
||||
}"
|
||||
row-key="id"
|
||||
bordered
|
||||
size="small"
|
||||
:scroll="{ x: 1100 }"
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="list.length" class="df-card-grid">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="df-card"
|
||||
>
|
||||
<div class="df-card__main">
|
||||
<div class="df-card__title">{{ item.report_no }}</div>
|
||||
<div class="df-card__meta">
|
||||
<span>统计区间</span>
|
||||
<span>{{ item.period_start }} ~ {{ item.period_end }}</span>
|
||||
</div>
|
||||
<div class="df-card__meta">
|
||||
<span>生成时间</span>
|
||||
<span>{{ item.generated_at }}</span>
|
||||
</div>
|
||||
<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 }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: () => openDetail(record.id),
|
||||
},
|
||||
{
|
||||
label: '导出',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: () => handleExport(record.id),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[]"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<Pagination
|
||||
:current="pagination.current"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-size-options="['12', '24', '48']"
|
||||
show-size-changer
|
||||
show-quick-jumper
|
||||
@change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
</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