1. 对账单、订单的改良
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
李琦
2026-05-14 11:00:47 +08:00
parent 01f0a6b0f9
commit cc81e45a5c
26 changed files with 1646 additions and 831 deletions

View File

@@ -17,6 +17,10 @@ import { debounce } from 'lodash-es';
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
/** 中药无图时与仓库列表一致的占位图 */
const TCM_PLACEHOLDER_IMAGE =
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
// ==================== Props 定义 ====================
interface Props {
@@ -88,6 +92,12 @@ const containerRef = ref<HTMLElement | null>(null);
*/
const highlightIndex = ref(-1);
function resolveDropdownImage(item: any): string {
if (item._image) return item._image;
if (props.type === 1) return TCM_PLACEHOLDER_IMAGE;
return '';
}
// ==================== 方法定义 ====================
/**
@@ -296,8 +306,8 @@ watch(
<!-- 药品图片 -->
<div class="drug-item__image">
<img
v-if="item._image"
:src="item._image"
v-if="resolveDropdownImage(item)"
:src="resolveDropdownImage(item)"
alt=""
class="drug-item__img"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
/**
* 超级仓库 Admin选品字段与常用方「药品搜索」一致DrugSearchSelect
*/
import { computed, ref, watch } from 'vue';
import { useUserStore } from '@vben/stores';
import { Button } from 'ant-design-vue';
import { DrugSearchSelect } from '#/components/drug-search-select';
const props = withDefaults(
defineProps<{
/** vben-form药品 ID */
value?: number;
/** 列表筛选类型1 中药2 西药 */
productType?: number;
}>(),
{
productType: 1,
},
);
const emit = defineEmits<{
'update:value': [value: number | undefined];
}>();
const userStore = useUserStore();
const selectedLabel = ref('');
const searchType = computed(() => (props.productType === 2 ? 2 : 1));
const storeId = computed(() => userStore.userInfo?.store_id ?? 2);
const placeholder = computed(() => {
const name = props.productType === 2 ? '西药' : '中药';
return `输入${name}名称搜索(显示图片、供应商、规格、价格)`;
});
watch(
() => props.value,
(v) => {
if (v == null || v === undefined) {
selectedLabel.value = '';
}
},
{ immediate: true },
);
function onSelect(drug: any) {
const id = drug._drugId ?? drug.drug?.id ?? drug.drug_id;
selectedLabel.value =
drug._drugName || drug.drug?.drug_name || drug.drug_name || '';
emit('update:value', id);
}
function clearSelection() {
selectedLabel.value = '';
emit('update:value', undefined);
}
</script>
<template>
<div class="w-full space-y-2">
<DrugSearchSelect
:type="searchType"
:store-id="storeId"
:placeholder="placeholder"
@select="onSelect"
/>
<div
v-if="value != null"
class="flex items-center gap-2 text-sm text-muted-foreground"
>
<span>已选{{ selectedLabel || `ID ${value}` }}</span>
<Button type="link" size="small" class="!p-0" @click="clearSelection">
清除
</Button>
</div>
</div>
</template>

View File

@@ -4,4 +4,5 @@ export type CustomComponentType =
| 'ApiRadioGroup'
| 'ApiSelect'
| 'ApiTreeSelect'
| 'IconPicker';
| 'IconPicker'
| 'WarehouseAdminDrugSearch';

View File

@@ -188,16 +188,28 @@ const orderTypeMap = {
<div class="mt-4">
<h3>订单商品</h3>
<div v-if="data.prescription_type === 1">
<ul class="custom-list pl-6">
<li
<span class="mb-3 block font-medium">药品明细</span>
<div class="flex flex-wrap gap-3">
<div
v-for="(item, index) in data.product_order_items"
:key="index"
class="custom-list-item"
class="tcm-detail-chip w-[220px] max-w-full flex-shrink-0 rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-600 dark:bg-gray-900"
>
{{ item.drug.drug_number }} {{ item.drug_name }} *
{{ item.number * (item.dosage > 0 ? item.dosage : 1) }}
</li>
</ul>
<div class="font-medium">
{{ item.drug?.drug_number }}{{ item.drug_name }}
</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">
数量*{{ item.number * (item.dosage > 0 ? item.dosage : 1) }}
</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">
供货价{{
item.buy_price != null && item.buy_price !== ''
? `${item.buy_price}`
: '—'
}}
</div>
</div>
</div>
</div>
<Card
v-for="(item, index) in data.product_order_items"
@@ -303,34 +315,10 @@ const orderTypeMap = {
</template>
<style scoped>
.custom-list {
list-style-type: none;
padding-left: 0;
.tcm-detail-chip {
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
.custom-list-item {
background-color: rgba(64, 158, 255, 0.04);
border-radius: 4px;
margin-bottom: 8px;
padding: 8px 12px;
font-size: 14px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(64, 158, 255, 0.1);
}
&::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background-color: #409eff;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}
}
.ant-descriptions-item-label {
font-weight: bold;
}

View File

@@ -19,34 +19,44 @@ export const gridOptions: VxeGridProps<RowType> = {
// { type: 'checkbox', width: 60 },
{ type: 'expand', width: 80, slots: { content: 'expand-content' } },
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'order_no', align: 'left', title: '订单号' },
{ field: 'order_no', align: 'left', width: 200, title: '订单号' },
{
field: 'user.avatarUrl',
title: '下单用户信息',
slots: { default: 'avatar' },
width: 100,
},
{ field: 'store.name', title: '诊所名称' },
{ field: 'store.name', title: '诊所名称', width: 240 },
{
field: 'address.name',
title: '收货人姓名',
width: 120,
slots: { default: 'address-name' },
},
{
field: 'address.mobile',
title: '收货人联系方式',
width: 120,
slots: { default: 'address-mobile' },
},
{
field: 'delivery_method',
title: '订单类型&邮寄方式&订单状态',
width: 120,
slots: { default: 'delivery-method' },
},
{ field: 'items_price', title: '药品总价' },
{ field: 'total_pay_price', title: '支付总价' },
{ field: 'items_price', width: 120, title: '药品总价' },
{ field: 'total_pay_price', width: 120, title: '支付总价' },
{
field: 'register_price',
title: '挂号金额',
width: 110,
slots: { default: 'register-fee' },
},
{
field: 'is_sync_erp',
title: 'Erp状态',
width: 120,
slots: { default: 'is-sync-erp' },
},
{
@@ -61,11 +71,17 @@ export const gridOptions: VxeGridProps<RowType> = {
width: 100,
slots: { default: 'is-online' },
},
{ field: 'pay_time', title: '支付时间', slots: { default: 'pay-time' } },
{ field: 'created_at', title: '下单时间' },
{
field: 'pay_time',
title: '支付时间',
width: 240,
slots: { default: 'pay-time' },
},
{ field: 'created_at', width: 240, title: '下单时间' },
{
type: 'html',
title: '操作',
fixed: 'right',
slots: { default: 'action' },
width: 200,
},

View File

@@ -2,6 +2,7 @@
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import {
AnalysisOverview,
@@ -31,8 +32,48 @@ import Refund from './components/refund.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
const router = useRouter();
const hasTopTableDropDownActions = ref(false);
function findRegisterOrderListPath(): null | string {
const routes = router.getRoutes();
const hit = routes.find(
(r) =>
typeof r.path === 'string' &&
r.path.length > 0 &&
!r.redirect &&
/register/i.test(r.path) &&
/order/i.test(r.path),
);
return hit?.path ?? null;
}
function goRegisterOrder(row: Record<string, any>) {
const orderNo = row.register_order_no;
if (!orderNo) {
message.warning('无关联挂号订单号');
return;
}
const path = findRegisterOrderListPath();
if (!path) {
message.warning('未找到挂号订单菜单路由,请从左侧菜单进入挂号订单');
return;
}
void router.push({ path, query: { order_no: String(orderNo) } });
}
function formatRegisterAmount(row: Record<string, any>) {
if (row.register_id == null || row.register_id === '') {
return '—';
}
const n = Number(row.register_price);
if (!Number.isFinite(n)) {
return '—';
}
return `¥${n.toFixed(2)}`;
}
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
// eslint-disable-next-line no-use-before-define
@@ -119,8 +160,8 @@ const saleAmount = () => {
overviewItems.value = [
{
icon: SvgCakeIcon,
title: '累计收益',
totalTitle: '累计收益',
title: '销售金额',
totalTitle: '销售金额',
totalValue: total.value,
value: total.value,
},
@@ -338,6 +379,19 @@ const openOrderAmountVerify = () => {
<template #pay-time="{ row }">
{{ row?.pay_time || '未支付' }}
</template>
<template #register-fee="{ row }">
<div class="flex flex-col items-start leading-snug">
<Button
v-if="row.register_order_no"
class="!h-auto !px-0 !py-0"
type="link"
@click="goRegisterOrder(row)"
>
{{ formatRegisterAmount(row) }}
</Button>
<span v-else>{{ formatRegisterAmount(row) }}</span>
</div>
</template>
<template #is-sync-erp="{ row }">
<Tag v-if="row.is_sync_erp === 1" color="green">已同步</Tag>
<Tag v-else color="red">未同步</Tag>
@@ -519,19 +573,30 @@ const openOrderAmountVerify = () => {
<div
v-if="row.prescription_type === 1"
class="medication-details mt-5 p-10"
class="medication-details mt-5 px-4 pb-4"
>
<span class="mb-2 font-medium">药品明细</span>
<ul class="custom-list pl-6">
<li
<span class="mb-3 block font-medium">药品明细</span>
<div class="flex flex-wrap gap-3">
<div
v-for="(item, index) in row.product_order_items"
:key="index"
class="custom-list-item"
class="tcm-drug-chip w-[200px] max-w-full flex-shrink-0 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-gray-600 dark:bg-gray-900"
>
{{ item.drug.drug_number }} {{ item.drug_name }} *
{{ item.number * (item.dosage > 0 ? item.dosage : 1) }}
</li>
</ul>
<div class="font-medium">
{{ item.drug?.drug_number }}{{ item.drug_name }}
</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">
数量*{{ item.number * (item.dosage > 0 ? item.dosage : 1) }}
</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">
供货价{{
item.buy_price != null && item.buy_price !== ''
? `${item.buy_price}`
: '—'
}}
</div>
</div>
</div>
</div>
<div v-if="!row.prescription_type"></div>
<div
@@ -547,32 +612,7 @@ const openOrderAmountVerify = () => {
</Page>
</template>
<style scoped lang="scss">
.custom-list {
list-style-type: none;
padding-left: 0;
}
.custom-list-item {
background-color: rgba(64, 158, 255, 0.04);
border-radius: 4px;
margin-bottom: 8px;
padding: 8px 12px;
font-size: 14px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(64, 158, 255, 0.1);
}
&::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background-color: #409eff;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}
.tcm-drug-chip {
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
</style>

View File

@@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { nextTick, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import {
Page,
@@ -38,6 +39,20 @@ const [Grid, gridApi] = useVbenVxeGrid({
gridEvents,
});
const route = useRoute();
onMounted(() => {
void nextTick(async () => {
const raw = route.query.order_no;
const orderNo = Array.isArray(raw) ? raw[0] : raw;
if (!orderNo || !gridApi.formApi?.setValues) {
return;
}
await gridApi.formApi.setValues({ order_no: String(orderNo) });
await gridApi.query();
});
});
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
});

View File

@@ -18,6 +18,13 @@ export async function getWarehouseDrugManagementOption(data: any) {
return requestClient.get<any>(`${prefix}option`, { params: data });
}
/** 仓库筛选:商品类型(与后端 ProductTypeEnum 一致) */
export async function getWarehouseProductTypeOptions() {
return requestClient.get<{ label: string; value: number }[]>(
`${prefix}product-type-options`,
);
}
/**
* 获取西(中成)药详情
* @param id

View File

@@ -2,7 +2,6 @@
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { message } from 'ant-design-vue';
@@ -12,11 +11,11 @@ import {
createWarehouseDrugManagement,
updateWarehouseDrugManagement,
} from '../api';
import { modalFormProps } from '../config/form';
import {getDrugUseList} from "#/views/doctor/doctor-reception/api";
const userStore = useUserStore();
import {
modalFormProps,
warehouseAdminDrugIdApiSelectProps,
} from '../config/form';
import { getDrugUseList } from '#/views/doctor/doctor-reception/api';
const drugTime = ref([]);
const drugType = ref([]);
@@ -86,8 +85,35 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const modalData = modalApi.getData<{
gridApi?: unknown;
listDrugType?: number;
update?: boolean;
values?: Record<string, any>;
}>();
const listDrugType = modalData?.listDrugType ?? 1;
const drugIdSchemaPatch =
listDrugType === 5
? {
fieldName: 'drug_id',
component: 'ApiSelect' as const,
componentProps: {
class: 'w-full',
...warehouseAdminDrugIdApiSelectProps,
},
}
: {
fieldName: 'drug_id',
component: 'WarehouseAdminDrugSearch' as const,
componentProps: {
class: 'w-full',
productType: listDrugType,
},
};
formApi.updateSchema([
drugIdSchemaPatch,
{
componentProps: {
options: drugTime.value,
@@ -113,10 +139,9 @@ const [Modal, modalApi] = useVbenModal({
fieldName: 'frequency_id',
},
]);
const { values, update } = modalApi.getData<Record<string, any>>();
const { values, update } = modalData ?? {};
if (values) {
console.log(values, 'sssssssssssss')
isUpdate.value = update;
isUpdate.value = !!update;
formApi.setValues(values);
}
}

View File

@@ -1,10 +1,24 @@
import type {VbenFormProps} from '#/adapter/form';
import type { VbenFormProps } from '#/adapter/form';
import {getSupplierOption} from '#/views/system/supplier/api';
import {
getWarehouseDrugManagementOption
} from "#/views/business/warehouse-drug-management/admin/api";
getWarehouseDrugManagementOption,
} from '#/views/business/warehouse-drug-management/admin/api';
/** 服务包等场景:总仓 option 下拉(与原先 ApiSelect 一致) */
export const warehouseAdminDrugIdApiSelectProps = {
api: getWarehouseDrugManagementOption,
allowClear: true,
showSearch: true,
filterOption: (input: string, option: any) => {
return option?.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
},
afterFetch: (data: { drug_name: string; id: number }[]) => {
return data.map((item: any) => ({
label: `${item.drug_name}${item.pinyin_simple}`,
value: item.id,
}));
},
};
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12', // 24栅格,
@@ -29,26 +43,13 @@ export const modalFormProps: VbenFormProps = {
},
},
{
component: 'ApiSelect',
component: 'WarehouseAdminDrugSearch',
fieldName: 'drug_id',
label: '选择加入的药品',
formItemClass: 'col-span-12',
rules: 'required',
componentProps: {
api: getWarehouseDrugManagementOption,
allowClear: true,
showSearch: true,
filterOption: (input: string, option: any) => {
// 自定义过滤逻辑,确保可以根据 name 进行搜索
return option?.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
},
// 菜单接口转options格式
afterFetch: (data: { drug_name: string; id: number }[]) => {
return data.map((item: any) => ({
label: `${item.drug_name}${item.pinyin_simple}`,
value: item.id,
}));
},
productType: 1,
},
dependencies: {
show(formValues: any) {

View File

@@ -1,5 +1,7 @@
import type { VbenFormProps } from '#/adapter/form';
import { getWarehouseProductTypeOptions } from '../api';
// import dayjs from 'dayjs';
export const formOptions: VbenFormProps = {
@@ -16,28 +18,23 @@ export const formOptions: VbenFormProps = {
label: '药名称',
},
{
component: 'VbenSelect',
component: 'ApiSelect',
componentProps: {
placeholder: '输入名称',
options: [
{
label: '中药',
value: 1,
},
{
label: '西药',
value: 2,
},
{
label: '服务包',
value: 5,
},
],
allowClear: true,
api: getWarehouseProductTypeOptions,
placeholder: '请选择类型',
afterFetch: (data: { label: string; value: number }[]) => {
if (!Array.isArray(data)) return [];
return data.map((item) => ({
label: item.label,
value: item.value,
}));
},
},
defaultValue: 1,
fieldName: 'type',
label: '类型',
}
},
// {
// component: 'RangePicker',
// componentProps: {

View File

@@ -50,12 +50,24 @@ const [ExcelUploadModal, ExcelUploadModalApi] = useVbenModal({
connectedComponent: ExcelUpload,
});
const TCM_PLACEHOLDER_IMAGE =
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
function rowProductImageSrc(row: any) {
const drug = row?.drug;
const isChinese = drug?.type === 1;
if (isChinese) {
return drug?.image || TCM_PLACEHOLDER_IMAGE;
}
return drug?.image || '';
}
const showModal = (data = {}, isUpdate = false) => {
formModalApi.setData({
// 表单值
values: data,
update: isUpdate,
gridApi,
listDrugType: gridApi.formApi.latestSubmissionValues?.type,
});
formModalApi.open();
};
@@ -165,10 +177,8 @@ const updateStatus = (id: number) => {
</template>
<template #image="{ row }">
<Image
:src="
row.drug?.image ||
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg'
"
v-if="rowProductImageSrc(row)"
:src="rowProductImageSrc(row)"
height="30"
width="30"
/>

View File

@@ -18,6 +18,13 @@ export async function getWarehouseDrugManagementStoreOption(data: any) {
return requestClient.get<any>(`${prefix}option`, { params: data });
}
/** 仓库筛选:商品类型(与后端 ProductTypeEnum 一致) */
export async function getWarehouseProductTypeOptions() {
return requestClient.get<{ label: string; value: number }[]>(
`${prefix}product-type-options`,
);
}
/**
* 获取诊所仓库商品药详情
* @param id

View File

@@ -1,5 +1,7 @@
import type { VbenFormProps } from '#/adapter/form';
import { getWarehouseProductTypeOptions } from '../api';
// import dayjs from 'dayjs';
export const formOptions: VbenFormProps = {
@@ -16,26 +18,19 @@ export const formOptions: VbenFormProps = {
label: '药名称',
},
{
component: 'VbenSelect',
component: 'ApiSelect',
componentProps: {
placeholder: '输入名称',
options: [
{
label: '中药',
value: 1,
},
{
label: '西药',
value: 2,
},
{
label: '服务包',
value: 5,
},
],
show: () => {
return false;
allowClear: true,
api: getWarehouseProductTypeOptions,
placeholder: '请选择类型',
afterFetch: (data: { label: string; value: number }[]) => {
if (!Array.isArray(data)) return [];
return data.map((item) => ({
label: item.label,
value: item.value,
}));
},
show: () => false,
},
disabled: true,
defaultValue: 2,

View File

@@ -18,6 +18,13 @@ export async function getWarehouseDrugManagementStoreOption(data: any) {
return requestClient.get<any>(`${prefix}option`, { params: data });
}
/** 仓库筛选:商品类型(与后端 ProductTypeEnum 一致) */
export async function getWarehouseProductTypeOptions() {
return requestClient.get<{ label: string; value: number }[]>(
`${prefix}product-type-options`,
);
}
/**
* 获取诊所仓库商品药详情
* @param id

View File

@@ -1,5 +1,7 @@
import type { VbenFormProps } from '#/adapter/form';
import { getWarehouseProductTypeOptions } from '../api';
// import dayjs from 'dayjs';
export const formOptions: VbenFormProps = {
@@ -16,28 +18,23 @@ export const formOptions: VbenFormProps = {
label: '药名称',
},
{
component: 'VbenSelect',
component: 'ApiSelect',
componentProps: {
placeholder: '输入名称',
options: [
{
label: '中药',
value: 1,
},
{
label: '西药',
value: 2,
},
{
label: '服务包',
value: 5,
},
],
allowClear: true,
api: getWarehouseProductTypeOptions,
placeholder: '请选择类型',
afterFetch: (data: { label: string; value: number }[]) => {
if (!Array.isArray(data)) return [];
return data.map((item) => ({
label: item.label,
value: item.value,
}));
},
},
defaultValue: 1,
fieldName: 'type',
label: '类型',
}
},
// {
// component: 'RangePicker',
// componentProps: {

View File

@@ -86,6 +86,17 @@ export async function receptionApi(id: any) {
return requestClient.post<any>(`${prefix}reception`, { id });
}
/**
* 医生撤回处方(仅待支付订单)
*/
export async function withdrawPrescriptionApi(data: {
prescription_id: number;
register_id: number;
reason?: string;
}) {
return requestClient.post<any>(`${prefix}withdraw-prescription`, data);
}
/**
* 接诊
*/
@@ -301,6 +312,16 @@ export async function saveChineseCommonPrescriptionApi(data: {
dosage?: number;
/** 每日次数可选默认2 */
day_dosage?: number;
/** 1=自制剂 2=委托调剂(与后端 saveChinese 一致) */
rule_type?: number;
/** 包法ID自制剂时 */
package_method_id?: number;
/** 制剂规则ID委托调剂时 */
process_rule_id?: number;
/** 煎法子规则ID委托调剂时 */
child_process_rule_id?: number;
/** 规格/备注规则ID委托调剂时 */
process_rule_note_id?: number;
}) {
return requestClient.post<any>(
`${commonPrescriptionPrefix}save-chinese`,

View File

@@ -166,7 +166,7 @@ const getImageSource = (imageString) => {
</div>
<div class="preparation-info">
<p>
用法:每日{{ recipe.deployment }}{{ recipe.dosage }}
用法:每日{{ recipe.consumption }}{{ recipe.dosage }}
</p>
<p>
使用方式{{

View File

@@ -43,10 +43,12 @@ import {
getCurrentStoreTypeApi,
getPatientItem,
getPatientList,
getPrescriptionInfoApi,
getProcessRuleList,
getProductListDoctorReception,
receptionApi,
switchStoreApi,
withdrawPrescriptionApi,
} from '#/views/doctor/doctor-reception/api';
import DiagnosisModal from './components/DiagnosisModal.vue';
@@ -181,6 +183,10 @@ const activePatient = ref<null | Patient>(null);
const patientInfo = ref<null | Patient>(null);
const userPatientHealthInquiry = ref<null | UserPatientHealthInquiry>(null);
const activeCategory = ref(2);
/** 仅中药/西药支持常用方模板(保健食品不提供常用方) */
const canUseCommonPrescription = computed(() =>
[1, 2].includes(activeCategory.value),
);
const diagnosis = ref('');
const medicalAdvice = ref('');
const treatmentPrice = ref(0);
@@ -746,57 +752,39 @@ function openCommonPrescriptionModal() {
async function handleSelectCommonPrescription(data: any, type: number) {
const { prescription, recipes } = data;
// 根据类型处理不同的药品数据
// 根据类型处理药品:整表覆盖为常用方内容,不与当前处方按药品 ID 去重合并
if (type === 1) {
// 西药处方
recipes.forEach((recipe: any) => {
const newProduct = {
index_id: recipe.id,
id: recipe.drug_id, // 使用药品主表ID
drug_name: recipe.drug_name,
number: recipe.number || 1,
use_num: recipe.use_time,
use_type: recipe.use_type,
use_frequency: recipe.use_frequency,
unit: recipe.west_unit,
price: recipe.price || 0,
time_id: recipe.time_id,
type_id: recipe.type_id,
frequency_id: recipe.frequency_id,
unit_id: recipe.unit_id,
image: recipe.image,
instruction: recipe.instruction,
type: recipe.type,
select_number: 1,
};
// 检查是否已存在 - 使用药品主表ID比对
const existItem = currentDrugs.value.find(
(item) => item.id === recipe.drug_id,
);
if (!existItem) {
currentDrugs.value.push(newProduct);
}
});
currentDrugs.value = (recipes || []).map((recipe: any) => ({
index_id: recipe.id,
id: recipe.drug_id,
drug_name: recipe.drug_name,
number: recipe.number || 1,
use_num: recipe.use_time,
use_type: recipe.use_type,
use_frequency: recipe.use_frequency,
unit: recipe.west_unit,
price: recipe.price || 0,
time_id: recipe.time_id,
type_id: recipe.type_id,
frequency_id: recipe.frequency_id,
unit_id: recipe.unit_id,
image: recipe.image,
instruction: recipe.instruction,
type: recipe.type,
select_number: recipe.select_number ?? 1,
}));
} else {
// 中药/颗粒药处方 - 使用 drug_id 作为唯一标识药品主表ID
recipes.forEach((recipe: any) => {
const drugId = recipe.drug_id || recipe.id; // 药品主表ID
const newProduct = {
index_id: recipe.id, // 处方明细ID
id: drugId, // 药品主表ID
currentDrugs.value = (recipes || []).map((recipe: any) => {
const drugId = recipe.drug_id || recipe.id;
return {
index_id: recipe.id,
id: drugId,
drug_name: recipe.drug_name || recipe.name,
number: recipe.number || 1,
price: recipe.price || 0,
way_id: recipe.way_id || 0,
select_number: 1,
select_number: recipe.select_number ?? 1,
};
// 检查是否已存在 - 使用药品主表ID比对
const existItem = currentDrugs.value.find(
(item) => item.id === drugId,
);
if (!existItem) {
currentDrugs.value.push(newProduct);
}
});
// 写入规则类型和剂量信息(中药)- 使用 ?? 运算符提供默认值
@@ -868,6 +856,11 @@ const saveAsCommonPrescription = async () => {
return;
}
if (![1, 2].includes(activeCategory.value)) {
message.warning('当前分类不支持保存为常用方');
return;
}
isSavingCommonPrescription.value = true;
try {
@@ -1135,6 +1128,274 @@ async function loadProcessRuleData(pid = 0, ruleId = 0) {
}
}
function parsePrescriptionDetailContent(detail: any): Record<string, any> {
let c = detail?.content;
if (typeof c === 'string') {
try {
c = JSON.parse(c);
} catch {
c = {};
}
}
return c && typeof c === 'object' ? c : {};
}
function mapWestRepiceToProduct(recipe: any): Record<string, any> | null {
if (!recipe) return null;
let drugObj: Record<string, any> = {};
try {
const raw = recipe.content;
drugObj = typeof raw === 'string' ? JSON.parse(raw) : (raw || {});
} catch {
return null;
}
const drugId = Number(drugObj.id);
if (!drugId) return null;
const selectNumber = Math.max(
1,
Number(recipe.number ?? drugObj.select_number ?? drugObj.number ?? 1) || 1,
);
const grainNumber = Math.max(
1,
Number(
drugObj.number ?? drugObj.grain_number ?? recipe.grain_number ?? 1,
) || 1,
);
const timeId = recipe.time_id ?? drugObj.time_id;
const typeId = recipe.type_id ?? drugObj.type_id;
const freqId = recipe.f_id ?? drugObj.frequency_id;
const unitId = recipe.wu_id ?? drugObj.unit_id;
return {
index_id: drugId,
id: drugId,
drug_name: drugObj.drug_name || drugObj.name || '',
number: grainNumber,
use_num: drugTime.value.find((item: any) => item.id === timeId),
use_type: drugUseType.value.find((item: any) => item.id === typeId),
use_frequency: drugUseFrequency.value.find(
(item: any) => item.id === freqId,
),
unit: drugUnit.value.find((item: any) => item.id === unitId),
price: Number(drugObj.sell_price ?? drugObj.price ?? 0),
way_id: drugObj.way_id,
use_ways: drugUseWay.value.find((item: any) => item.id === drugObj.way_id),
time_id: timeId,
type_id: typeId,
frequency_id: freqId,
unit_id: unitId,
image: drugObj.image || '',
instruction: drugObj.instruction || '',
type: drugObj.type,
select_number: selectNumber,
};
}
/**
* 将历史处方详情写入当前接诊编辑区(不提交)
*/
async function applyHistoricalPrescriptionDetail(detail: any): Promise<boolean> {
const content = parsePrescriptionDetailContent(detail);
const pt = Number(detail.prescription_type);
const supported = [1, 2, 3, 5, 6, 7];
if (!supported.includes(pt)) {
message.warning('该处方类型暂不支持复用');
return false;
}
const pid = activePatient.value?.id;
if (!pid) {
message.warning('请先选择患者');
return false;
}
localStorage.setItem(
getStorageKey(pid, activeCategory.value),
JSON.stringify(currentDrugs.value),
);
activeCategory.value = pt;
localStorage.setItem(`activeCategory${pid}`, String(pt));
const repiceList = Array.isArray(content.repice) ? content.repice : [];
if (pt === 1) {
const rep0 = repiceList[0];
if (!rep0) {
message.warning('处方中药品数据为空');
return false;
}
let rows: any[] = [];
try {
const raw = rep0.content;
rows = typeof raw === 'string' ? JSON.parse(raw) : (raw || []);
} catch {
message.error('解析中药处方失败');
return false;
}
if (!Array.isArray(rows) || rows.length === 0) {
message.warning('处方中药品数据为空');
return false;
}
currentDrugs.value = rows.map((d: any) => {
const drugId = Number(d.drug_id ?? d.id);
// 落库煎法在 ChineseMedicineModel.order详情可能带 use_way / useWay
const wayId =
d.way_id ?? d.use_way?.id ?? d.useWay?.id ?? d.order;
return {
index_id: d.id ?? drugId,
id: drugId,
drug_name: d.name || d.drug_name || '',
number: d.number ?? 1,
price: Number(d.price ?? 0),
way_id: wayId,
use_ways:
drugUseWay.value.find((item: any) => item.id === wayId) ??
d.use_way ??
d.useWay,
use_num: drugTime.value.find((item: any) => item.id === d.time_id),
use_type: drugUseType.value.find((item: any) => item.id === d.type_id),
use_frequency: drugUseFrequency.value.find(
(item: any) => item.id === d.frequency_id,
),
unit: drugUnit.value.find((item: any) => item.id === d.unit_id),
time_id: d.time_id,
type_id: d.type_id,
frequency_id: d.frequency_id,
unit_id: d.unit_id,
image: d.image || '',
instruction: d.instruction || '',
type: d.type,
};
});
ruleType.value = rep0.deployment === 2 ? 1 : 2;
dosage.value = Number(rep0.dosage ?? 7);
dayDosage.value = Number(rep0.consumption ?? 2);
packageMethodId.value = rep0.package_method_id ?? 2;
if (rep0.process_rule_id && Number(rep0.process_rule_id) > 0) {
processRuleId.value = rep0.process_rule_id;
await loadProcessRuleData(rep0.process_rule_id, 0);
}
if (rep0.child_process_rule_id && Number(rep0.child_process_rule_id) > 0) {
childProcessRuleId.value = rep0.child_process_rule_id;
await loadProcessRuleData(0, rep0.child_process_rule_id);
}
if (rep0.process_rule_note_id && Number(rep0.process_rule_note_id) > 0) {
processRuleNoteId.value = rep0.process_rule_note_id;
}
if (!rep0.process_rule_id) {
message.warning('历史处方未包含完整加工规则,请核对包法/制剂与规格');
}
} else {
const mapped: any[] = [];
for (const recipe of repiceList) {
const row = mapWestRepiceToProduct(recipe);
if (row) mapped.push(row);
}
if (mapped.length === 0) {
message.warning('未解析到可复用的药品行');
return false;
}
currentDrugs.value = mapped;
}
diagnosis.value =
detail.clinical_diagnose ?? content.clinical_diagnose ?? '';
medicalAdvice.value = detail.doctor_order ?? content.doctor_order ?? '';
category.value =
detail.category === 2 || detail.category === '2' || content.category === '医保'
? 2
: 1;
doctorSecondSign.value = 0;
processRulePrice.value = 0;
localStorage.setItem(
getStorageKey(pid, activeCategory.value),
JSON.stringify(currentDrugs.value),
);
if (activePatient.value?.id) {
localStorage.setItem(
`activeCategory${activePatient.value.id}`,
String(activeCategory.value),
);
}
getCurrentDrugs();
return true;
}
async function reusePrescriptionFromList(prescriptionId: number) {
if (!activePatient.value?.id) {
message.warning('请先选择患者');
return;
}
const draft = currentDrugs.value;
const go = async () => {
try {
const detail = await getPrescriptionInfoApi(prescriptionId);
const ok = await applyHistoricalPrescriptionDetail(detail);
if (ok) {
tabType.value = 2;
message.success('已复用到开方页,请核对后发送');
}
} catch (e) {
console.error(e);
message.error('加载处方详情失败');
}
};
if (draft.length > 0) {
AntModal.confirm({
title: '覆盖当前草稿?',
content: '当前处方清单中有药品,复用将替换为所选历史处方内容。',
onOk: go,
});
} else {
await go();
}
}
function confirmWithdrawPrescription(item: any) {
const registerId = Number.parseInt(
localStorage.getItem(`doctorReception-id`) || '0',
10,
);
if (!registerId || !item?.id) {
message.error('参数错误');
return;
}
AntModal.confirm({
title: '确认撤回处方?',
content:
'撤回后患者将无法继续支付该订单,库存占用将释放。此操作不可撤销。',
async onOk() {
try {
await withdrawPrescriptionApi({
prescription_id: item.id,
register_id: registerId,
});
message.success('处方已撤回');
if (selectPatientId.value) {
const value = await getPatientItem(selectPatientId.value);
patientInfo.value = value;
userPatientHealthInquiry.value = value.user_patient_health_inquiry;
prescriptionList.value = value.prescription;
}
} catch (e: any) {
message.error(
e?.response?.data?.message || e?.message || '撤回失败',
);
return Promise.reject(e);
}
},
});
}
loadProcessRuleData();
/**
@@ -1831,6 +2092,17 @@ watch(
<Button type="link" @click="openPrescriptionDetail(item.id)">
查看处方
</Button>
<Button
v-if="item.can_withdraw"
danger
type="link"
@click="confirmWithdrawPrescription(item)"
>
撤回
</Button>
<Button type="link" @click="reusePrescriptionFromList(item.id)">
复用
</Button>
<span class="time-line-item-created">{{
item.created_at
}}</span>
@@ -1847,11 +2119,22 @@ watch(
{{ item.category === 1 ? '自费' : '医保' }}
</Tag>
<Tag v-if="item.status === 0" color="warning">待审核</Tag>
<Tag v-else-if="item.status === 1" color="success">已通过</Tag>
<Tag v-else-if="item.status === 2" color="error">未通过</Tag>
<Tag v-else-if="item.status === 3" color="#455cda">无需审核</Tag>
<Tag v-else-if="item.status === 4" color="#455cda">无需审核</Tag>
<template v-if="Number(item.cancel_status) === 1">
<Tag color="default">
{{
String(item.cancel_remark || '').includes('医生撤回')
? '已撤回'
: '已取消'
}}
</Tag>
</template>
<template v-else>
<Tag v-if="item.status === 0" color="warning">待审核</Tag>
<Tag v-else-if="item.status === 1" color="success">已通过</Tag>
<Tag v-else-if="item.status === 2" color="error">未通过</Tag>
<Tag v-else-if="item.status === 3" color="#455cda">无需审核</Tag>
<Tag v-else-if="item.status === 4" color="#455cda">无需审核</Tag>
</template>
<Tag color="#455cda">处方ID{{ item.id }}</Tag>
<!-- <Tag class="ml-5">-->
<!-- {{ item.category === 1 ? '自费' : '医保' }}-->
@@ -1901,8 +2184,9 @@ watch(
>
添加商品
</Button>
<!-- 常用方按钮 -->
<!-- 常用方按钮(产品服务包/非药品/医疗器械无模板接口) -->
<Button
v-if="canUseCommonPrescription"
type="primary"
:class="activeCategory !== 1 ? 'ml-3' : ''"
@click="openCommonPrescriptionModal"
@@ -1910,6 +2194,7 @@ watch(
选择常用方
</Button>
<Button
v-if="canUseCommonPrescription"
type="default"
class="ml-3"
@click="openSaveCommonPrescriptionModal"

View File

@@ -55,7 +55,7 @@ const fetchPatientDetail = async () => {
// 确保正确解析返回数据
const data = res?.result || res?.data || res || null;
patientDetail.value = data;
// 检查是否有转诊挂号
if (data?.register_order?.is_from_transfer === 1 && registerId) {
await fetchTransferPrescription(registerId);
@@ -74,7 +74,7 @@ const fetchPatientDetail = async () => {
// 获取转诊信息
const fetchTransferPrescription = async (registerId) => {
if (!registerId) return;
loadingTransfer.value = true;
try {
const res = await getTransferPrescriptionByRegisterApi(registerId);

View File

@@ -1,6 +1,24 @@
import { requestClient } from '#/api/request';
import dayjs from 'dayjs';
const prefix = 'reconciliation/';
function formatSearchTimeParam(st: unknown): [string, string] | undefined {
if (!Array.isArray(st) || st.length < 2) return undefined;
const fmt = (v: unknown) => {
if (v && typeof v === 'object' && 'format' in v && typeof (v as { format: (s: string) => string }).format === 'function') {
return (v as dayjs.Dayjs).format('YYYY-MM-DD');
}
const d = dayjs(v as string | number);
return d.isValid() ? d.format('YYYY-MM-DD') : '';
};
const a = fmt(st[0]);
const b = fmt(st[1]);
if (!a || !b) return undefined;
return [a, b];
}
/**
* 诊所对账单
* @param data
@@ -8,10 +26,64 @@ const prefix = 'reconciliation/';
export async function getReconciliationStoreList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 诊所对账单
* @param data
* 药品对账列表
*/
export async function getReconciliationDrugList(data: any) {
return requestClient.get<any>(`${prefix}drug-list`, { params: data });
}
export async function getReconciliationDrugOptions(params: {
q?: string;
store_id?: number;
search_time: [string, string];
limit?: number;
}) {
return requestClient.get<{ items: { value: number; label: string }[] }>(`${prefix}drug-options`, {
params,
});
}
export async function getReconciliationOrderOptions(params: {
q?: string;
store_id: number;
search_time: [string, string];
limit?: number;
}) {
return requestClient.get<{ items: { value: number; label: string }[] }>(`${prefix}order-options`, {
params,
});
}
/**
* 对账明细 Excel药品维度 + 订单维度)
*/
export async function exportReconciliationExcelApi(params: {
store_id: number;
search_time: unknown;
drug_id?: number;
order_id?: number;
by_order?: 0 | 1;
}) {
const search_time = formatSearchTimeParam(params.search_time);
if (!search_time) {
return Promise.reject(new Error('时间范围无效'));
}
const query: Record<string, unknown> = {
store_id: params.store_id,
search_time,
};
if (params.drug_id != null && params.drug_id > 0) {
query.drug_id = params.drug_id;
}
if (params.order_id != null && params.order_id > 0) {
query.order_id = params.order_id;
}
if (params.by_order === 1) {
query.by_order = 1;
}
return requestClient.download(`${prefix}export-excel`, {
params: query,
});
}

View File

@@ -0,0 +1,272 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { ref, watch } from 'vue';
import { debounce } from 'lodash-es';
import { useVbenModal } from '@vben/common-ui';
import dayjs from 'dayjs';
import {
Button,
Checkbox,
DatePicker,
message,
Select,
Space,
} from 'ant-design-vue';
import { downloadByData } from '#/util/tool';
import {
exportReconciliationExcelApi,
getReconciliationDrugOptions,
getReconciliationOrderOptions,
} from '#/views/finance/reconciliation/api';
import { loadPageSearchTimeRange } from '#/views/finance/reconciliation/utils/formCache';
import { getStoreOption } from '#/views/system/store/api';
defineOptions({
name: 'ReconciliationExportModal',
});
type SelectLabeled = { value: number; label: string };
const storeId = ref<number | undefined>(undefined);
const storeLabel = ref('');
const searchTime = ref<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs()]);
const storeOptions = ref<{ label: string; value: number }[]>([]);
const loading = ref(false);
const fetchingStores = ref(false);
const drugSelect = ref<SelectLabeled | undefined>(undefined);
const orderSelect = ref<SelectLabeled | undefined>(undefined);
const drugSelectOptions = ref<SelectLabeled[]>([]);
const orderSelectOptions = ref<SelectLabeled[]>([]);
const byOrder = ref(false);
/** 诊所账号:仅允许导出本店,隐藏诊所选择 */
const storeExportLocked = ref(false);
function searchTimeRange(): [string, string] {
return [searchTime.value[0].format('YYYY-MM-DD'), searchTime.value[1].format('YYYY-MM-DD')];
}
const searchDrugOptions = debounce(async (raw: string) => {
if (!storeId.value) {
drugSelectOptions.value = [];
return;
}
try {
const res = await getReconciliationDrugOptions({
q: raw,
store_id: storeId.value,
search_time: searchTimeRange(),
});
drugSelectOptions.value = (res.items || []).map((i) => ({ label: i.label, value: i.value }));
} catch {
drugSelectOptions.value = [];
}
}, 300);
const searchOrderOptions = debounce(async (raw: string) => {
if (!storeId.value) {
orderSelectOptions.value = [];
return;
}
try {
const res = await getReconciliationOrderOptions({
q: raw,
store_id: storeId.value,
search_time: searchTimeRange(),
});
orderSelectOptions.value = (res.items || []).map((i) => ({ label: i.label, value: i.value }));
} catch {
orderSelectOptions.value = [];
}
}, 300);
watch(storeId, (id) => {
if (storeExportLocked.value) {
return;
}
const o = storeOptions.value.find((x) => x.value === id);
storeLabel.value = o?.label ?? '';
orderSelect.value = undefined;
orderSelectOptions.value = [];
});
const loadStores = () => {
fetchingStores.value = true;
getStoreOption({})
.then((res: any[]) => {
storeOptions.value = (res || []).map((item: any) => ({
label: item.name,
value: item.id,
}));
})
.finally(() => {
fetchingStores.value = false;
});
};
function popupContainerBody() {
return document.body;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!storeId.value) {
message.warning('请选择诊所');
return;
}
if (!searchTime.value?.[0] || !searchTime.value?.[1]) {
message.warning('请选择时间范围');
return;
}
loading.value = true;
try {
const res = await exportReconciliationExcelApi({
store_id: storeId.value,
search_time: searchTime.value,
drug_id: drugSelect.value?.value,
order_id: orderSelect.value?.value,
by_order: byOrder.value ? 1 : 0,
});
const end = searchTime.value[1].format('YYYY-MM-DD');
const safeName = (storeLabel.value || '诊所').replace(/[\\/:*?"<>|]+/g, '_');
downloadByData(res.data, `${safeName}_${end}.xlsx`);
message.success('导出成功');
modalApi.close();
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally {
loading.value = false;
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data =
modalApi.getData<{
search_time?: [Dayjs, Dayjs];
store_export_locked?: boolean;
locked_store_id?: number;
locked_store_name?: string;
drug_select?: SelectLabeled;
order_select?: SelectLabeled;
by_order?: boolean;
}>() || {};
if (data.search_time?.[0] && data.search_time?.[1]) {
searchTime.value = [dayjs(data.search_time[0]), dayjs(data.search_time[1])];
} else {
const r = loadPageSearchTimeRange();
searchTime.value = [r[0], r[1]];
}
drugSelect.value = data.drug_select;
orderSelect.value = data.order_select;
byOrder.value = !!data.by_order;
drugSelectOptions.value = [];
orderSelectOptions.value = [];
storeExportLocked.value = !!data.store_export_locked;
if (storeExportLocked.value && data.locked_store_id != null) {
storeId.value = data.locked_store_id;
storeLabel.value = data.locked_store_name || '本诊所';
} else {
storeId.value = undefined;
storeLabel.value = '';
loadStores();
}
}
},
});
</script>
<template>
<Modal class="w-[560px]" title="导出对账 Excel">
<div class="flex flex-col gap-4 py-2">
<div v-if="!storeExportLocked">
<div class="mb-1 text-sm text-muted-foreground">诊所</div>
<Select
v-model:value="storeId"
show-search
:filter-option="
(input: string, option: any) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
"
:options="storeOptions"
:loading="fetchingStores"
allow-clear
placeholder="请选择诊所"
class="w-full"
option-filter-prop="label"
/>
</div>
<div v-else>
<div class="mb-1 text-sm text-muted-foreground">诊所</div>
<div class="rounded border px-3 py-2 text-sm">
{{ storeLabel || '本诊所' }}ID: {{ storeId }}
</div>
</div>
<div>
<div class="mb-1 text-sm text-muted-foreground">时间范围</div>
<DatePicker.RangePicker
v-model:value="searchTime"
class="w-full"
format="YYYY-MM-DD"
:get-popup-container="popupContainerBody"
/>
</div>
<div>
<Checkbox v-model:checked="byOrder">按订单维度导出(药品 sheet 与列表勾选一致)</Checkbox>
</div>
<div>
<div class="mb-1 text-sm text-muted-foreground">药品(可选)</div>
<Select
v-model:value="drugSelect"
label-in-value
:filter-option="false"
show-search
allow-clear
:disabled="!storeId"
placeholder="先选诊所后搜索"
:options="drugSelectOptions"
class="w-full"
@search="searchDrugOptions"
/>
</div>
<div>
<div class="mb-1 text-sm text-muted-foreground">订单(可选)</div>
<Select
v-model:value="orderSelect"
label-in-value
:filter-option="false"
show-search
allow-clear
:disabled="!storeId"
placeholder="先选诊所后搜索"
:options="orderSelectOptions"
class="w-full"
@search="searchOrderOptions"
/>
</div>
<Space v-if="!storeExportLocked">
<Button @click="loadStores()">刷新诊所列表</Button>
</Space>
</div>
</Modal>
</template>

View File

@@ -1,84 +0,0 @@
import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
export const reconciliationStoreSearch: VbenFormProps = {
// 默认展开
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入诊所名称',
},
defaultValue: '',
fieldName: 'store_name',
label: '诊所名称',
},
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
},
defaultValue: [dayjs().startOf('month'), dayjs()],
fieldName: 'search_time',
label: '时间范围',
},
],
// 控制表单是否显示折叠按钮
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
// 是否在字段值改变时提交表单
submitOnChange: true,
// 按下回车时是否提交表单
submitOnEnter: false,
};
export const reconciliationDrugSearch: VbenFormProps = {
// 默认展开
collapsed: false,
schema: [
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入诊所ID',
},
defaultValue: '',
fieldName: 'store_id',
dependencies: {
show: false,
triggerFields: ['store_id'],
},
label: '诊所ID',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入药品名称',
},
defaultValue: '',
fieldName: 'drug_name',
label: '药品名称',
},
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
},
defaultValue: [dayjs().startOf('month'), dayjs()],
fieldName: 'search_time',
label: '时间范围',
},
],
// 控制表单是否显示折叠按钮
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
// 是否在字段值改变时提交表单
submitOnChange: true,
// 按下回车时是否提交表单
submitOnEnter: false,
};

View File

@@ -1,134 +0,0 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
interface RowType {
id: string;
nick_name: string;
email: string;
avatar: string;
roles: { name: string }[];
open_id: string;
code: string;
platform_id: string;
phone: string;
desc: string;
created_at: string;
}
export const storeReconciliationTable: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ field: 'name', title: '诊所名称' },
{
field: 'reconciliation.herbal_sales',
title: '草药',
slots: { default: 'herbal' },
},
// { field: 'reconciliation.herbal_sales', title: '草药销售数量' },
// { field: 'reconciliation.herbal_sales_price', title: '草药销售总价' },
// { field: 'reconciliation.herbal_supply_price', title: '草药供货总价' },
{
field: 'reconciliation.medicine_sales',
title: '西药',
slots: { default: 'medicine' },
},
// { field: 'reconciliation.medicine_sales', title: '西药销售数量' },
// { field: 'reconciliation.medicine_sales_price', title: '西药销售总价' },
// { field: 'reconciliation.medicine_supply_price', title: '西药供货总价' },
{
field: 'reconciliation.service_package_sales',
title: '服务包销售数量',
slots: { default: 'service-package' },
},
// { field: 'reconciliation.service_package_sales', title: '服务包销售数量' },
// {
// field: 'reconciliation.service_package_sales_price',
// title: '服务包销售总价',
// },
// {
// field: 'reconciliation.service_package_supply_price',
// title: '服务包供货总价',
// },
{ field: 'reconciliation.express_price', title: '其他费用', slots: { default: 'other'} },
{ field: 'reconciliation.total_sales_price', title: '总销售总价' },
{ field: 'reconciliation.total_supply_price', title: '总供货总价' },
// { field: 'reconciliation.express_price', title: '快递费用' },
// { field: 'reconciliation.process_price', title: '加工费用' },
// { field: 'reconciliation.treatment_price', title: '治疗费用' },
// { field: 'reconciliation.outpatient_price', title: '门诊费用' },
{ field: 'reconciliation.registration_price', title: '挂号费用' },
{ type: 'html', title: '操作', slots: { default: 'action' } },
],
keepSource: true,
pagerConfig: {},
border: false,
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
search: true,
refresh: true, // 刷新
print: false, // 打印
export: false, // 导出
// custom: true, // 自定义列
zoom: true, // 最大化最小化
slots: {
buttons: 'toolbar-buttons',
},
custom: {
// 自定义列-图标
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};
export const drugReconciliationTable: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ field: 'drug_name', title: '药名' },
{ field: 'type_txt', title: '药品类型' },
{ field: 'number', title: '出售数量' },
{ field: 'total_sales_price', title: '总销售金额' },
{ field: 'total_supply_price', title: '总供货金额' },
// { type: 'html', title: '操作', slots: { default: 'action' } },
],
keepSource: true,
pagerConfig: {},
border: false,
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
search: true,
refresh: true, // 刷新
print: false, // 打印
export: false, // 导出
// custom: true, // 自定义列
zoom: true, // 最大化最小化
slots: {
buttons: 'toolbar-buttons',
},
custom: {
// 自定义列-图标
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -1,191 +1,352 @@
<script lang="ts" setup>
import { ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { AnalysisChartsTabs, Page } from '@vben/common-ui';
import { computed, onMounted, ref, watch } from 'vue';
import { Tag, Tabs, TabPane } from 'ant-design-vue';
import { debounce } from 'lodash-es';
import { Page, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import dayjs from 'dayjs';
import {
Alert,
Button,
Checkbox,
DatePicker,
Input,
RadioGroup,
Select,
Space,
Table,
TabPane,
Tabs,
Tag,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import {
getReconciliationDrugList,
getReconciliationStoreList
getReconciliationDrugOptions,
getReconciliationOrderOptions,
getReconciliationStoreList,
} from '#/views/finance/reconciliation/api';
import ReconciliationExportModal from './components/ReconciliationExportModal.vue';
import StatisticsReconciliation from './components/statistics.vue';
import { reconciliationDrugSearch, reconciliationStoreSearch } from './config/search';
import { drugReconciliationTable, storeReconciliationTable } from './config/table';
import {
loadExcludeZeroSales,
loadPageSearchTimeRange,
saveExcludeZeroSales,
savePageSearchTimeRange,
} from './utils/formCache';
const [Grid, GridApi] = useVbenVxeGrid({
formOptions: reconciliationStoreSearch,
gridOptions: storeReconciliationTable,
/** 诊所列表一次拉全量(后端 paginate */
const STORE_LIST_PAGE_SIZE = 99_999;
const userStore = useUserStore();
const userInfo = computed(() => userStore.userInfo as Record<string, any> | null);
/** 平台用户user_type === 2药品未选诊所时为全平台汇总 */
const isPlatformUser = computed(
() => Number(userInfo.value?.roles?.user_type) === 2,
);
/** 诊所侧账号user_type === 1药品仅本诊所由后端强制 */
const isStoreClinicUser = computed(
() => Number(userInfo.value?.roles?.user_type) === 1 && !!userInfo.value?.store_id,
);
/** 下拉选项用的诊所 ID从诊所列表切入单店 / 诊所账号本店 */
const optionsStoreId = computed((): number | undefined => {
if (currentStoreId.value != null) {
return currentStoreId.value;
}
const sid = userInfo.value?.store_id;
if (isStoreClinicUser.value && sid != null && sid !== '') {
return Number(sid);
}
return undefined;
});
const [DrugGrid, DrugGridApi] = useVbenVxeGrid({
formOptions: reconciliationDrugSearch,
gridOptions: drugReconciliationTable,
/** 诊所管理员不展示「诊所范围」筛选 */
const showExcludeZeroSalesFilter = computed(() => !isStoreClinicUser.value);
const [ExportModal, exportModalApi] = useVbenModal({
connectedComponent: ReconciliationExportModal,
});
const activeTab = ref('store');
const statistics = ref();
const initTableAjax = () => {
GridApi.setGridOptions({
proxyConfig: {
ajax: {
// 请求后端接口方法
query: async ({ page }, formValues) => {
return await getReconciliationStoreList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
}).then((res) => {
statistics.value = null;
statistics.value = [
{
title: '总销售金额',
value: res.count.total_sales_price,
icon: 'mdi:cash-multiple',
color: 'text-blue-600',
unit: '¥',
},
{
title: '总供货额',
value: res.count.total_supply_price,
icon: 'mdi:truck-delivery',
color: 'text-gray-600',
unit: '¥',
},
{
title: '草药销量',
value: res.count.herbal_sales,
icon: 'mdi:leaf',
color: 'text-green-600',
},
{
title: '草药销售金额',
value: res.count.herbal_sales_price,
icon: 'mdi:currency-cny',
color: 'text-green-600',
unit: '¥',
},
{
title: '草药供货金额',
value: res.count.herbal_supply_price,
icon: 'mdi:package-variant',
color: 'text-green-600',
unit: '¥',
},
{
title: '成药销量',
value: res.count.medicine_sales,
icon: 'mdi:pill',
color: 'text-purple-600',
},
{
title: '成药销售金额',
value: res.count.medicine_sales_price,
icon: 'mdi:currency-usd',
color: 'text-purple-600',
unit: '¥',
},
{
title: '成药供货金额',
value: res.count.medicine_supply_price,
icon: 'mdi:pharmacy',
color: 'text-purple-600',
unit: '¥',
},
{
title: '快递费',
value: res.count.express_price,
icon: 'skill-icons:expressjs-dark',
color: 'text-orange-600',
unit: '¥',
},
{
title: '加工费',
value: res.count.process_price,
icon: 'mdi:factory',
color: 'text-yellow-600',
unit: '¥',
},
{
title: '诊疗费',
value: res.count.treatment_price,
icon: 'mdi:medical-bag',
color: 'text-red-600',
unit: '¥',
},
{
title: '门诊收入',
value: res.count.outpatient_price,
icon: 'mdi:hospital-box',
color: 'text-blue-600',
unit: '¥',
},
{
title: '挂号费',
value: res.count.registration_price,
icon: 'mdi:card-account-details',
color: 'text-pink-600',
unit: '¥',
},
];
return res.list;
});
},
},
},
});
// 药品
DrugGridApi.setGridOptions({
proxyConfig: {
ajax: {
// 请求后端接口方法
query: async ({ page }, formValues) => {
return await getReconciliationDrugList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
}).then((res) => {
statistics.value = null;
statistics.value = [
{
title: '总销售金额',
value: res.count.total_sales_price,
icon: 'mdi:cash-multiple',
color: 'text-blue-600',
unit: '¥',
},
{
title: '总供货额',
value: res.count.total_supply_price,
icon: 'mdi:truck-delivery',
color: 'text-gray-600',
unit: '¥',
},
{
title: '销量',
value: res.count.number,
icon: 'streamline-emojis:pill',
color: 'text-purple-600',
},
];
return res.list;
});
},
},
},
});
};
/** 整页共用时间 */
const searchTime = ref<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs()]);
const storeName = ref('');
const excludeZeroSales = ref(loadExcludeZeroSales());
const goDrug = (storeId) => {
reconciliationDrugSearch.schema[0].value = storeId;
type ReconciliationSelectOption = { value: number; label: string };
const drugSelect = ref<ReconciliationSelectOption | undefined>(undefined);
const orderSelect = ref<ReconciliationSelectOption | undefined>(undefined);
const drugSelectOptions = ref<ReconciliationSelectOption[]>([]);
const orderSelectOptions = ref<ReconciliationSelectOption[]>([]);
const byOrder = ref(false);
/** 平台/经理等从诊所表「查看」切入单店药品时携带null 表示不按单店过滤(平台为全量汇总) */
const currentStoreId = ref<number | null>(null);
const currentStoreName = ref('');
const statistics = ref<any[] | null>(null);
const storeLoading = ref(false);
const storeList = ref<any[]>([]);
const drugLoading = ref(false);
const drugList = ref<any[]>([]);
function searchTimeParam(): [string, string] {
return [searchTime.value[0].format('YYYY-MM-DD'), searchTime.value[1].format('YYYY-MM-DD')];
}
function setStoreStatistics(count: Record<string, any>) {
statistics.value = [
{ title: '总销售金额', value: count.total_sales_price, icon: 'mdi:cash-multiple', color: 'text-blue-600', unit: '¥' },
{ title: '总供货额', value: count.total_supply_price, icon: 'mdi:truck-delivery', color: 'text-muted-foreground', unit: '¥' },
{ title: '草药销量', value: count.herbal_sales, icon: 'mdi:leaf', color: 'text-green-600' },
{ title: '草药销售金额', value: count.herbal_sales_price, icon: 'mdi:currency-cny', color: 'text-green-600', unit: '¥' },
{ title: '草药供货金额', value: count.herbal_supply_price, icon: 'mdi:package-variant', color: 'text-green-600', unit: '¥' },
{ title: '成药销量', value: count.medicine_sales, icon: 'mdi:pill', color: 'text-purple-600' },
{ title: '成药销售金额', value: count.medicine_sales_price, icon: 'mdi:currency-usd', color: 'text-purple-600', unit: '¥' },
{ title: '成药供货金额', value: count.medicine_supply_price, icon: 'mdi:pharmacy', color: 'text-purple-600', unit: '¥' },
{ title: '快递费', value: count.express_price, icon: 'skill-icons:expressjs-dark', color: 'text-orange-600', unit: '¥' },
{ title: '加工费', value: count.process_price, icon: 'mdi:factory', color: 'text-yellow-600', unit: '¥' },
{ title: '诊疗费', value: count.treatment_price, icon: 'mdi:medical-bag', color: 'text-red-600', unit: '¥' },
{ title: '门诊收入', value: count.outpatient_price, icon: 'mdi:hospital-box', color: 'text-blue-600', unit: '¥' },
{ title: '挂号费', value: count.registration_price, icon: 'mdi:card-account-details', color: 'text-pink-600', unit: '¥' },
];
}
function setDrugStatistics(count: Record<string, any>) {
statistics.value = [
{ title: '总销售金额', value: count.total_sales_price, icon: 'mdi:cash-multiple', color: 'text-blue-600', unit: '¥' },
{ title: '总供货额', value: count.total_supply_price, icon: 'mdi:truck-delivery', color: 'text-muted-foreground', unit: '¥' },
{ title: '销量', value: count.number, icon: 'streamline-emojis:pill', color: 'text-purple-600' },
];
}
function buildDrugListParams(): Record<string, unknown> {
const params: Record<string, unknown> = {
search_time: searchTimeParam(),
};
if (drugSelect.value?.value) {
params.drug_id = drugSelect.value.value;
}
if (orderSelect.value?.value) {
params.order_id = orderSelect.value.value;
}
if (byOrder.value) {
params.by_order = 1;
}
if (currentStoreId.value != null) {
params.store_id = currentStoreId.value;
}
return params;
}
const searchDrugOptions = debounce(async (raw: string) => {
const st = searchTimeParam();
try {
const res = await getReconciliationDrugOptions({
q: raw,
search_time: st,
...(optionsStoreId.value != null ? { store_id: optionsStoreId.value } : {}),
});
drugSelectOptions.value = (res.items || []).map((i) => ({ label: i.label, value: i.value }));
} catch {
drugSelectOptions.value = [];
}
}, 300);
const searchOrderOptions = debounce(async (raw: string) => {
const sid = optionsStoreId.value;
if (sid == null) {
orderSelectOptions.value = [];
return;
}
try {
const res = await getReconciliationOrderOptions({
q: raw,
store_id: sid,
search_time: searchTimeParam(),
});
orderSelectOptions.value = (res.items || []).map((i) => ({ label: i.label, value: i.value }));
} catch {
orderSelectOptions.value = [];
}
}, 300);
async function fetchStoreList() {
storeLoading.value = true;
try {
const res = await getReconciliationStoreList({
page: 1,
pageSize: STORE_LIST_PAGE_SIZE,
store_name: storeName.value,
search_time: searchTimeParam(),
exclude_zero_sales: isStoreClinicUser.value ? 0 : excludeZeroSales.value,
});
setStoreStatistics(res.count);
const list = res.list;
storeList.value = list?.items ?? [];
} finally {
storeLoading.value = false;
}
}
async function fetchDrugList() {
drugLoading.value = true;
try {
const res = await getReconciliationDrugList(buildDrugListParams());
setDrugStatistics(res.count);
const list = res.list;
drugList.value = list?.items ?? [];
} finally {
drugLoading.value = false;
}
}
function onSearchTimeChange() {
if (!searchTime.value?.[0] || !searchTime.value?.[1]) {
return;
}
savePageSearchTimeRange(searchTime.value);
if (activeTab.value === 'store') {
fetchStoreList();
} else if (activeTab.value === 'drug') {
fetchDrugList();
}
}
function onExcludeZeroChange() {
saveExcludeZeroSales(excludeZeroSales.value);
if (activeTab.value === 'store') {
fetchStoreList();
}
}
function queryStore() {
fetchStoreList();
}
function queryDrug() {
fetchDrugList();
}
function openExportModal() {
const u = userInfo.value;
const locked =
Number(u?.roles?.user_type) === 1 && u?.store_id != null && u.store_id !== '';
exportModalApi.setData({
search_time: searchTime.value,
store_export_locked: locked,
locked_store_id: locked ? Number(u.store_id) : undefined,
locked_store_name:
(u?.store_name as string) ||
(u?.store?.name as string) ||
(u?.nick_name as string) ||
'',
drug_select: drugSelect.value,
order_select: orderSelect.value,
by_order: byOrder.value,
});
exportModalApi.open();
}
function goDrug(storeId: number, name: string) {
currentStoreId.value = storeId;
currentStoreName.value = name;
activeTab.value = 'drug';
}
initTableAjax();
function clearDrugStoreFilter() {
currentStoreId.value = null;
currentStoreName.value = '';
if (activeTab.value === 'drug') {
fetchDrugList();
}
}
const drugAlertMessage = computed(() => {
if (isStoreClinicUser.value) {
const sid = userInfo.value?.store_id;
const name =
(userInfo.value?.store_name as string) ||
(userInfo.value?.store?.name as string) ||
'';
const parts = ['当前为诊所账号,仅展示本诊所药品数据。'];
if (name) parts.push(`诊所:${name}`);
if (sid != null && sid !== '') parts.push(`ID: ${sid}`);
return parts.join('');
}
if (currentStoreId.value != null) {
return `当前查看诊所:${currentStoreName.value || '—'}ID: ${currentStoreId.value}`;
}
if (isPlatformUser.value) {
return '当前为全部诊所药品汇总。可在「诊所」列表点击「查看」切换为单个诊所;切换后也可点击「查看全部诊所汇总」恢复。';
}
return '未指定单个诊所时,将按账号权限展示对应范围内的药品汇总(与后端规则一致)。可在「诊所」列表点击「查看」筛选单个诊所。';
});
const storeColumns = [
{ title: '诊所名称', dataIndex: 'name', key: 'name', width: 160, ellipsis: true },
{ title: '草药', key: 'herbal', width: 240 },
{ title: '西药', key: 'medicine', width: 240 },
{ title: '服务包', key: 'servicePackage', width: 240 },
{ title: '其他费用', key: 'other', width: 200 },
{ title: '总销售总价', key: 'total_sales_price', width: 120 },
{ title: '总供货总价', key: 'total_supply_price', width: 120 },
{ title: '挂号费用', key: 'registration_price', width: 100 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
];
const drugColumnsAll = [
{ title: '药名', dataIndex: 'drug_name', key: 'drug_name', width: 200, ellipsis: true },
{ title: '药品编号', dataIndex: 'drug_number', key: 'drug_number', width: 120, ellipsis: true },
{ title: '订单编号', dataIndex: 'order_no', key: 'order_no', width: 160, ellipsis: true },
{ title: '药品类型', dataIndex: 'type_txt', key: 'type_txt', width: 120 },
{ title: '出售数量', dataIndex: 'number', key: 'number', width: 100 },
{ title: '供货价', dataIndex: 'market_price', key: 'market_price', width: 100 },
{ title: '总销售金额', dataIndex: 'total_sales_price', key: 'total_sales_price', width: 120 },
{ title: '总供货金额', dataIndex: 'total_supply_price', key: 'total_supply_price', width: 120 },
];
const drugColumns = computed(() =>
byOrder.value ? drugColumnsAll : drugColumnsAll.filter((c) => c.key !== 'order_no'),
);
function popupContainerBody() {
return document.body;
}
onMounted(() => {
searchTime.value = loadPageSearchTimeRange();
excludeZeroSales.value = loadExcludeZeroSales();
fetchStoreList();
});
watch(activeTab, (key) => {
if (key === 'store') {
fetchStoreList();
} else if (key === 'drug') {
fetchDrugList();
}
});
watch(byOrder, () => {
if (activeTab.value === 'drug') {
fetchDrugList();
}
});
</script>
<template>
@@ -193,243 +354,186 @@ initTableAjax();
auto-content-height
title="对账单"
>
<ExportModal />
<StatisticsReconciliation
v-if="statistics"
:statistics="statistics"
/>
<Tabs v-model:active-key="activeTab" type="card" :destroyInactiveTabPane="true">
<div class="mb-3 flex flex-wrap items-center gap-3 rounded border px-3 py-2">
<span class="text-sm text-muted-foreground">时间范围全页共用</span>
<DatePicker.RangePicker
v-model:value="searchTime"
format="YYYY-MM-DD"
:get-popup-container="popupContainerBody"
@change="onSearchTimeChange"
/>
</div>
<Tabs v-model:active-key="activeTab" type="card" :destroy-inactive-tab-pane="false">
<TabPane key="store" tab="诊所">
<Grid>
<template #toolbar-buttons></template>
<template #toolbar-tools></template>
<template #herbal="{ row }">
<div class="mt-2">
<Tag color="green">
销售数量{{ row.reconciliation.herbal_sales }}
</Tag>
</div>
<div class="mt-2">
<Tag color="orange">
销售总价{{ row.reconciliation.herbal_sales_price }}
</Tag>
</div>
<div class="mt-2">
<Tag color="red">
供货总价{{ row.reconciliation.herbal_supply_price }}
</Tag>
</div>
</template>
<template #medicine="{ row }">
<div class="mt-2">
<Tag color="green">
销售数量{{ row.reconciliation.medicine_sales }}
</Tag>
</div>
<div class="mt-2">
<Tag color="orange">
销售总价{{ row.reconciliation.medicine_sales_price }}
</Tag>
</div>
<div class="mt-2">
<Tag color="red">
供货总价{{ row.reconciliation.medicine_supply_price }}
</Tag>
</div>
</template>
<template #service-package="{ row }">
<div class="mt-2">
<Tag color="green">
销售数量{{ row.reconciliation.service_package_sales }}
</Tag>
</div>
<div class="mt-2">
<Tag color="orange">
销售总价{{ row.reconciliation.service_package_sales_price }}
</Tag>
</div>
<div class="mt-2">
<Tag color="red">
供货总价{{ row.reconciliation.service_package_supply_price }}
</Tag>
</div>
</template>
<template #other="{ row }">
<div class="mt-2">
<Tag color="green">
快递费用{{ row.reconciliation.express_price }}
</Tag>
</div>
<div class="mt-2">
<Tag color="green">
加工费{{ row.reconciliation.process_price }}
</Tag>
</div>
<div class="mt-2">
<Tag color="green">
诊疗费{{ row.reconciliation.treatment_price }}
</Tag>
</div>
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 诊疗费{{ row.reconciliation.outpatient_price }}-->
<!-- </Tag>-->
<!-- </div>-->
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '查看',
type: 'link',
icon: 'marketeq:eye',
size: 'small',
onClick: goDrug.bind(null, row.id),
},
]"
:drop-down-actions="[]"
<div class="mb-3 flex flex-wrap items-end gap-3">
<div>
<div class="mb-1 text-xs text-muted-foreground">诊所名称</div>
<Input
v-model:value="storeName"
allow-clear
placeholder="诊所名称或拼音首拼"
class="w-52"
@press-enter="queryStore"
/>
</div>
<div v-if="showExcludeZeroSalesFilter">
<div class="mb-1 text-xs text-muted-foreground">诊所范围</div>
<RadioGroup
v-model:value="excludeZeroSales"
button-style="solid"
option-type="button"
:options="[
{ label: '隐藏销售额为0的诊所', value: 1 },
{ label: '显示全部诊所', value: 0 },
]"
@change="onExcludeZeroChange"
/>
</div>
<Space>
<Button type="primary" @click="queryStore">查询</Button>
<Button type="primary" @click="openExportModal">导出 Excel</Button>
</Space>
</div>
<Table
:columns="storeColumns"
:data-source="storeList"
:loading="storeLoading"
:pagination="false"
row-key="id"
:scroll="{ x: 1400 }"
bordered
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'herbal'">
<div class="flex flex-col gap-1">
<Tag color="green">销售数量{{ record.reconciliation?.herbal_sales }}</Tag>
<Tag color="orange">销售总价{{ record.reconciliation?.herbal_sales_price }}</Tag>
<Tag color="red">供货总价{{ record.reconciliation?.herbal_supply_price }}</Tag>
</div>
</template>
<template v-else-if="column.key === 'medicine'">
<div class="flex flex-col gap-1">
<Tag color="green">销售数量{{ record.reconciliation?.medicine_sales }}</Tag>
<Tag color="orange">销售总价{{ record.reconciliation?.medicine_sales_price }}</Tag>
<Tag color="red">供货总价{{ record.reconciliation?.medicine_supply_price }}</Tag>
</div>
</template>
<template v-else-if="column.key === 'servicePackage'">
<div class="flex flex-col gap-1">
<Tag color="green">销售数量{{ record.reconciliation?.service_package_sales }}</Tag>
<Tag color="orange">销售总价{{ record.reconciliation?.service_package_sales_price }}</Tag>
<Tag color="red">供货总价{{ record.reconciliation?.service_package_supply_price }}</Tag>
</div>
</template>
<template v-else-if="column.key === 'other'">
<div class="flex flex-col gap-1">
<Tag color="green">快递费用{{ record.reconciliation?.express_price }}</Tag>
<Tag color="green">加工费{{ record.reconciliation?.process_price }}</Tag>
<Tag color="green">诊疗费{{ record.reconciliation?.treatment_price }}</Tag>
</div>
</template>
<template v-else-if="column.key === 'total_sales_price'">
{{ record.reconciliation?.total_sales_price }}
</template>
<template v-else-if="column.key === 'total_supply_price'">
{{ record.reconciliation?.total_supply_price }}
</template>
<template v-else-if="column.key === 'registration_price'">
{{ record.reconciliation?.registration_price }}
</template>
<template v-else-if="column.key === 'action'">
<TableAction
:actions="[
{
label: '查看',
type: 'link',
icon: 'marketeq:eye',
size: 'small',
onClick: () => goDrug(record.id, record.name),
},
]"
:drop-down-actions="[]"
/>
</template>
</template>
</Grid>
</Table>
</TabPane>
<TabPane key="drug" tab="药品">
<DrugGrid>
<template #toolbar-buttons></template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
// {
// label: '编辑',
// type: 'link',
// icon: 'uil:edit',
// size: 'small',
// // auth: ['admin', 'sys:role:detail'],
// onClick: showModal.bind(null, row, true),
// },
]"
:drop-down-actions="[]"
<Alert
type="info"
show-icon
class="mb-3"
:message="drugAlertMessage"
/>
<div
v-if="isPlatformUser && currentStoreId != null"
class="mb-3"
>
<Button type="link" size="small" @click="clearDrugStoreFilter">
查看全部诊所汇总
</Button>
</div>
<div class="mb-3 flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<Checkbox v-model:checked="byOrder">按订单维度</Checkbox>
<span class="text-xs text-muted-foreground">未勾选时按药品+供货价合并勾选后按订单拆分</span>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">药品</div>
<Select
v-model:value="drugSelect"
label-in-value
:filter-option="false"
show-search
allow-clear
placeholder="输入名称或拼音搜索"
:options="drugSelectOptions"
class="min-w-56"
@search="searchDrugOptions"
/>
</template>
</DrugGrid>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">订单</div>
<Select
v-model:value="orderSelect"
label-in-value
:filter-option="false"
show-search
allow-clear
:disabled="optionsStoreId == null"
:placeholder="
optionsStoreId == null ? '请先在本页选择单个诊所(诊所列表点「查看」)' : '输入订单号搜索'
"
:options="orderSelectOptions"
class="min-w-56"
@search="searchOrderOptions"
/>
</div>
<Button type="primary" @click="queryDrug">查询</Button>
</div>
<Table
:columns="drugColumns"
:data-source="drugList"
:loading="drugLoading"
:pagination="false"
row-key="agg_key"
:scroll="{ x: 1280 }"
bordered
size="small"
/>
</TabPane>
</Tabs>
<!-- <AnalysisChartsTabs :tabs="chartTabs" :value="activeTab" class="mt-5">-->
<!-- <template #store>-->
<!-- <Grid>-->
<!-- <template #toolbar-buttons></template>-->
<!-- <template #toolbar-tools></template>-->
<!-- <template #herbal="{ row }">-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 销售数量{{ row.reconciliation.herbal_sales }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 销售总价{{ row.reconciliation.herbal_sales_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="warning">-->
<!-- 供货总价{{ row.reconciliation.herbal_supply_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- </template>-->
<!-- <template #medicine="{ row }">-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 销售数量{{ row.reconciliation.medicine_sales }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 销售总价{{ row.reconciliation.medicine_sales_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="warning">-->
<!-- 供货总价{{ row.reconciliation.medicine_supply_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- </template>-->
<!-- <template #service-package="{ row }">-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 销售数量{{ row.reconciliation.service_package_sales }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 销售总价{{ row.reconciliation.service_package_sales_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="warning">-->
<!-- 供货总价{{ row.reconciliation.service_package_supply_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- </template>-->
<!-- <template #other="{ row }">-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 快递费用{{ row.reconciliation.express_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 加工费{{ row.reconciliation.process_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 治疗费{{ row.reconciliation.treatment_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- <div class="mt-2">-->
<!-- <Tag color="green">-->
<!-- 诊疗费{{ row.reconciliation.outpatient_price }}-->
<!-- </Tag>-->
<!-- </div>-->
<!-- </template>-->
<!-- <template #action="{ row }">-->
<!-- <TableAction-->
<!-- :actions="[-->
<!-- {-->
<!-- label: '查看',-->
<!-- type: 'link',-->
<!-- icon: 'uil:edit',-->
<!-- size: 'small',-->
<!-- onClick: goDrug.bind(null, row, true),-->
<!-- },-->
<!-- ]"-->
<!-- :drop-down-actions="[]"-->
<!-- />-->
<!-- </template>-->
<!-- </Grid>-->
<!-- </template>-->
<!-- <template #drug>-->
<!-- <DrugGrid>-->
<!-- <template #toolbar-buttons></template>-->
<!-- <template #toolbar-tools></template>-->
<!-- <template #action="{ row }">-->
<!-- <TableAction-->
<!-- :actions="[-->
<!-- // {-->
<!-- // label: '编辑',-->
<!-- // type: 'link',-->
<!-- // icon: 'uil:edit',-->
<!-- // size: 'small',-->
<!-- // // auth: ['admin', 'sys:role:detail'],-->
<!-- // onClick: showModal.bind(null, row, true),-->
<!-- // },-->
<!-- ]"-->
<!-- :drop-down-actions="[]"-->
<!-- />-->
<!-- </template>-->
<!-- </DrugGrid>-->
<!-- </template>-->
<!-- </AnalysisChartsTabs>-->
</Page>
</template>

View File

@@ -0,0 +1,80 @@
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
/** 整页共用时间(新) */
const KEY_PAGE_SEARCH_TIME = 'reconciliation.page.search_time';
/** 历史 key迁移后删除 */
const KEY_LEGACY_STORE_SEARCH_TIME = 'reconciliation.store.search_time';
const KEY_LEGACY_DRUG_SEARCH_TIME = 'reconciliation.drug.search_time';
const KEY_EXCLUDE_ZERO_SALES = 'reconciliation.store.exclude_zero_sales';
export function defaultSearchTimeRange(): [Dayjs, Dayjs] {
return [dayjs().startOf('month'), dayjs()];
}
function parseStoredRange(raw: string | null): [Dayjs, Dayjs] | null {
if (!raw) return null;
try {
const arr = JSON.parse(raw) as unknown;
if (!Array.isArray(arr) || arr.length !== 2) return null;
const a = dayjs(arr[0] as string);
const b = dayjs(arr[1] as string);
if (!a.isValid() || !b.isValid()) return null;
return [a, b];
} catch {
return null;
}
}
function persistPageRange(range: [Dayjs, Dayjs]) {
const a = range[0].format('YYYY-MM-DD');
const b = range[1].format('YYYY-MM-DD');
localStorage.setItem(KEY_PAGE_SEARCH_TIME, JSON.stringify([a, b]));
}
/**
* 读取整页时间缓存;若无新 key 则从旧 store/drug key 迁移一次后删除旧项。
*/
export function loadPageSearchTimeRange(): [Dayjs, Dayjs] {
const page = parseStoredRange(localStorage.getItem(KEY_PAGE_SEARCH_TIME));
if (page) {
return page;
}
const legacyStore = parseStoredRange(localStorage.getItem(KEY_LEGACY_STORE_SEARCH_TIME));
const legacyDrug = parseStoredRange(localStorage.getItem(KEY_LEGACY_DRUG_SEARCH_TIME));
const merged = legacyStore ?? legacyDrug ?? defaultSearchTimeRange();
persistPageRange(merged);
localStorage.removeItem(KEY_LEGACY_STORE_SEARCH_TIME);
localStorage.removeItem(KEY_LEGACY_DRUG_SEARCH_TIME);
return merged;
}
function pickYmd(v: unknown): string {
if (v == null) return '';
if (typeof v === 'object' && v !== null && 'format' in v && typeof (v as { format: (s: string) => string }).format === 'function') {
return (v as Dayjs).format('YYYY-MM-DD');
}
const d = dayjs(v as string | number);
return d.isValid() ? d.format('YYYY-MM-DD') : '';
}
export function savePageSearchTimeRange(range: unknown) {
if (!range || !Array.isArray(range) || range.length < 2) return;
const a = pickYmd(range[0]);
const b = pickYmd(range[1]);
if (!a || !b) return;
localStorage.setItem(KEY_PAGE_SEARCH_TIME, JSON.stringify([a, b]));
}
/** 1 = 隐藏销售额为 0 的诊所默认0 = 显示全部 */
export function loadExcludeZeroSales(): number {
const v = localStorage.getItem(KEY_EXCLUDE_ZERO_SALES);
if (v === null) return 1;
return v === '0' ? 0 : 1;
}
export function saveExcludeZeroSales(v: number) {
localStorage.setItem(KEY_EXCLUDE_ZERO_SALES, v === 0 ? '0' : '1');
}