V 1.0
This commit is contained in:
commit
56dd8a9628
|
|
@ -0,0 +1,142 @@
|
|||
# AI 问答策略
|
||||
|
||||
本文档定义:**用户在智能问答中提交自定义问题后**,系统(或对接的大模型)应遵循的总体流程,以及**按问题类型组织的回答思路**。实施时可作为 Prompt 设计、RAG 检索策略与后处理校验的共用规范。
|
||||
|
||||
---
|
||||
|
||||
## 一、文档用途
|
||||
|
||||
1. **自定义问题回答流程**:用户自由输入问题后,按第二节的流水线完成意图识别、数据与口径对齐、推理与输出,保证回答可追溯、口径一致。
|
||||
2. **分类型回答思路**:第三节按典型业务问题归类,给出「应从哪些角度组织答案」的检查清单;具体数值以当前权限内数仓 / 分析上下文为准。
|
||||
|
||||
---
|
||||
|
||||
## 二、自定义问题通用回答流程
|
||||
|
||||
建议固定为以下步骤(可部分自动化,但逻辑顺序不宜打乱)。
|
||||
|
||||
### 2.1 输入校验与安全
|
||||
|
||||
- 判空、过长截断提示、敏感操作拒绝(如导出未授权明细)。
|
||||
- 不臆造未在上下文或检索结果中出现的具体数字、医院名、批号等。
|
||||
|
||||
### 2.2 意图与领域分类
|
||||
|
||||
将用户问题归入一个或多个标签,例如:
|
||||
|
||||
| 大类 | 说明 | 典型关键词或语义 |
|
||||
|------|------|------------------|
|
||||
| 不良事件(AE) | 条数、趋势、占比、省份/产品切片 | AE、不良事件、上报、SAE、发生月、报告条数 |
|
||||
| 质量投诉 | 件数、结论、赔付、与 AE 关联 | 投诉、C3、调查结论、操作不当、关闭 |
|
||||
| 营销 / 入院量 | 率、排名、与投诉联动 | 入院量、Qty、投诉率、每千件 |
|
||||
| 合规审计 | 漏报、匹配规则、标记集合 | 漏报、合规、审计、匹配 |
|
||||
| 元问题 | 数据范围、口径定义、Demo 说明 | 多少条数据、口径、时间范围 |
|
||||
|
||||
无法归类时,先**澄清**(缺时间范围、统计维度、指标定义)再答,或给出**安全泛化**说明并建议可下钻维度。
|
||||
|
||||
### 2.3 指标与口径对齐
|
||||
|
||||
- 从**指标口径文档 / 本页约定**中解析:统计单元(如「报告条数」)、时间轴(如「发生日期」所在月 vs「审核日期」)、过滤条件(如是否含重复报告规则)。
|
||||
- 若用户口径与系统默认不一致,**显式说明**正在采用的定义,并提示如何按用户口径重新查询(若支持)。
|
||||
|
||||
### 2.4 数据与证据获取
|
||||
|
||||
- 拉取与问题匹配的**聚合结果**(趋势、对比、TopN、占比),必要时附**下钻维度**(产品、省份、医院、事业线、是否 SAE)。
|
||||
- 记录**证据摘要**(用于回答中的「依据当前数据集…」及后续审计追溯)。
|
||||
|
||||
### 2.5 组织答案结构
|
||||
|
||||
推荐顺序(可按问题类型裁剪):
|
||||
|
||||
1. **结论摘要**(一句话回应用户核心问法)。
|
||||
2. **关键数字**(绝对量、占比、同比/环比,与用户问法一致)。
|
||||
3. **对比与背景**(历史同期、相邻月份、同类产品线是否同步波动)。
|
||||
4. **可能解释**(分点列出假设因素,标注为「需结合业务验证」而非定论)。
|
||||
5. **局限与下一步**(样本量、缺失字段、建议图表或专题分析页)。
|
||||
|
||||
### 2.6 输出质量检查
|
||||
|
||||
- 数字与上下文一致;单位、时间范围写清。
|
||||
- 「因果」类表述避免绝对化:用「可能与…相关」「建议核查…」等措辞。
|
||||
- 附**来源**标签(如:聚合接口、预设模板、知识库片段),便于 Demo 与生产环境区分。
|
||||
|
||||
---
|
||||
|
||||
## 三、分类型问题:回答思路清单
|
||||
|
||||
### 3.1 AE 数量变化 / 「为何增加或减少」
|
||||
|
||||
用户关心的是**变化是否异常**以及**可能原因**,回答宜覆盖:
|
||||
|
||||
1. **绝对值**:涉及月份(或区间)的报告条数各为多少;净增减多少条。
|
||||
2. **相对变化**:环比/同比**增长率或倍数**;避免仅用口语「翻倍」而不给基数。
|
||||
3. **历史可比性**:往年同月、近若干月的中位数或分位数;是否**首次出现**此类波动或**周期性重复**(如季节、集中上报批次)。
|
||||
4. **结构分解(产品因素)**:按事业线 / 产品 / 注册证 / 型号拆分,增量是否由**少数 SKU 或聚集事件**驱动。
|
||||
5. **结构分解(地域与机构)**:省份、医院是否有点状爆发;是否与新入院区域或新装机相关(若数据支持)。
|
||||
6. **报告与流程因素**:上报批次截止、监管填报窗口、重复报告剔除规则变更等**流程性解释**(需与合规口径一致)。
|
||||
7. **报告者 / 伤害与故障表现**:严重度(SAE 占比)、伤害表现或器械故障类别是否结构迁移(提示是否「报告质量或分类变化」而非单纯件数变化)。
|
||||
8. **收尾**:明确哪些结论可由当前数据直接支持,哪些需业务侧补充(临床反馈、批次调查、外部事件)。
|
||||
|
||||
### 3.2 AE 简单计数 / 排名类(如某月多少条、哪省最多)
|
||||
|
||||
1. 直接给出**当前口径下**的数字或 Top 列表。
|
||||
2. 说明**时间维度**(发生月 / 审核月)与**统计范围**(全量 / 某产品线)。
|
||||
3. 若有并列第二、第三名,可一句带过以增强可比性。
|
||||
|
||||
### 3.3 两个月或多个月对比(「差异大吗」「哪个月更高」)
|
||||
|
||||
1. 各月**绝对条数**并列。
|
||||
2. **差值与比率**(注意小基数时比率易失真,需提示)。
|
||||
3. **结构对比**:若条数接近,可对比 SAE 占比、产品构成是否不同。
|
||||
4. **解释边界**:样本随机波动 vs 可叙述的业务假设,分条列出并标注置信程度。
|
||||
|
||||
### 3.4 合规与漏报 / 匹配审计类
|
||||
|
||||
1. 说明**基准集合**定义(例如:投诉标记为不良事件的记录数)。
|
||||
2. 给出**匹配规则摘要**(如医院 + 产品 + 型号,时间窗 ≤30 天等,以实际规则为准)。
|
||||
3. 输出**疑似漏报条数与漏报率**,并说明「疑似」含义及人工复核必要性。
|
||||
|
||||
### 3.5 质量投诉:调查结论、操作不当、医院排名等
|
||||
|
||||
1. 明确统计的是**投诉条数**还是**已关闭子集**等。
|
||||
2. 给出**分布或 TopN**;若问「最多」,写清维度(如调查结论 = 操作不当)。
|
||||
3. 与 AE 或营销页**交叉引用**时,说明是否为同一数据源、时间是否对齐。
|
||||
|
||||
### 3.6 营销:投诉率(如每千件入院量)、省级对比
|
||||
|
||||
1. 写清**分子**(投诉条数如何映射到省)、**分母**(入院 Qty 汇总口径)。
|
||||
2. 给出**率值与排名**;注意极低分母导致的极值,必要时提示稳健性。
|
||||
3. 区分「投诉多」与「率高」:入院量大的省份可能件数多但率不高。
|
||||
|
||||
### 3.7 占比类(如 SAE 占 AE、调查结论占比)
|
||||
|
||||
1. 定义**分母**(全部 AE、全部投诉等)。
|
||||
2. 给出**整体或按月的比例**;若给平均值,说明时间范围。
|
||||
3. 指向可视化(曲线/饼图)以便用户复核。
|
||||
|
||||
### 3.8 数据范围与元问题(多少条记录、Demo 说明)
|
||||
|
||||
1. 当前加载或权限范围内的**表名 + 记录数 + 时间跨度**。
|
||||
2. 说明与正式环境的差异(mock、缓存、权限)。
|
||||
|
||||
### 3.9 无法命中或超出数据能力
|
||||
|
||||
1. 礼貌说明**未命中**或可答部分。
|
||||
2. 列出**建议改写**或**可支持的关键词 / 维度**(与产品示例问题对齐)。
|
||||
3. 生产环境:说明可对接**数仓 / 文档检索 / 人工工单**等扩展路径。
|
||||
|
||||
---
|
||||
|
||||
## 四、与实现的衔接建议
|
||||
|
||||
- **前端 / Demo**:可将用户问题、当前 `AnalysisContext` 摘要、本策略第三节匹配到的小节标题,一并作为 System 或 Tool 提示,约束模型输出结构。
|
||||
- **知识库**:将《数据与表结构》、各分析页指标编号(如 MKT-RG-201)与本文档第二节、第三节联合检索,减少口径漂移。
|
||||
- **评测**:对每类问题准备标准问法与必含要点(如 3.1 须含绝对值 + 百分比 + 至少两类因素分解),用于回归测试。
|
||||
|
||||
---
|
||||
|
||||
## 五、修订记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| 0.1 | 2026-04-16 | 初稿:通用流程 + 分类型思路(含 AE 增减分析维度) |
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>analytics-demo-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "analytics-demo-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.7",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass-embedded": "^1.85.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.0.0",
|
||||
"vite": "^8.0.4",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -0,0 +1,69 @@
|
|||
/* 页面布局与卡片(规范 4.2 / 4.3 子集) */
|
||||
.page-container {
|
||||
padding: var(--padding-md);
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-container-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--padding-md);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--padding-lg);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--padding-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-bottom: var(--padding-md);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.main-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.module-content {
|
||||
padding: var(--padding-md) 0 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/* 设计令牌(与《前端技术设计规范》对齐的子集) */
|
||||
:root {
|
||||
--customer-board-color: #409eff;
|
||||
--padding-md: 16px;
|
||||
--padding-lg: 24px;
|
||||
--border-radius-md: 8px;
|
||||
--text-primary: #303133;
|
||||
--text-secondary: #606266;
|
||||
--text-placeholder: #909399;
|
||||
--border-color: #ebeef5;
|
||||
--bg-color: #f5f7fa;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
'Helvetica Neue',
|
||||
Helvetica,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
Arial,
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 是否显示右上角「刷新数据」 */
|
||||
showRefresh?: boolean
|
||||
}>(),
|
||||
{ showRefresh: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
function onRefresh() {
|
||||
emit('refresh')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="showRefresh" class="page-container-toolbar">
|
||||
<el-button text type="primary" @click="onRefresh">
|
||||
<el-icon class="el-icon--left"><Refresh /></el-icon>
|
||||
刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
|
||||
import type { ChartExplain } from '../../types/analysis'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
option?: EChartsOption
|
||||
loading?: boolean
|
||||
placeholder?: boolean
|
||||
description?: string
|
||||
chartExplain?: ChartExplain
|
||||
}>(),
|
||||
{
|
||||
option: undefined,
|
||||
loading: false,
|
||||
placeholder: false,
|
||||
description: '',
|
||||
chartExplain: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const canRender = computed(() => !props.placeholder && props.option)
|
||||
|
||||
function renderChart() {
|
||||
if (!canRender.value || !chartRef.value) {
|
||||
return
|
||||
}
|
||||
if (!chart) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
}
|
||||
chart.setOption(props.option as EChartsOption, true)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
renderChart()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
watch(() => props.option, renderChart, { deep: true })
|
||||
watch(() => props.placeholder, renderChart)
|
||||
|
||||
function handleResize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="chart-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="chart-card-header">
|
||||
<span>{{ title }}</span>
|
||||
<span v-if="description" class="chart-card-desc">{{ description }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="chartExplain" class="chart-explain">
|
||||
<p><span class="chart-explain-label">取数范围</span>{{ chartExplain.dataScope }}</p>
|
||||
<p><span class="chart-explain-label">分析逻辑</span>{{ chartExplain.analysisLogic }}</p>
|
||||
</div>
|
||||
<div v-loading="loading" class="chart-body">
|
||||
<div v-if="placeholder" class="placeholder-box">
|
||||
<el-empty description="占位图,待接数据"></el-empty>
|
||||
</div>
|
||||
<div v-else-if="canRender" ref="chartRef" class="chart-canvas"></div>
|
||||
<div v-else class="placeholder-box">
|
||||
<el-empty description="暂无可展示数据"></el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
border-radius: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chart-card-desc {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.chart-explain {
|
||||
padding: 0 12px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--el-text-color-regular);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.chart-explain p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.chart-explain p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chart-explain-label {
|
||||
display: inline-block;
|
||||
min-width: 4.5em;
|
||||
margin-right: 6px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.placeholder-box {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { onMounted, ref, shallowRef } from 'vue'
|
||||
import type { AnalysisContext } from '@/types/analysis'
|
||||
import { invalidateAnalysisContextCache, loadAnalysisContext } from '@/lib/mock-data'
|
||||
|
||||
export interface UseAnalysisContextOptions {
|
||||
/** 挂载时是否立即加载,默认 true */
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export function useAnalysisContext(options: UseAnalysisContextOptions = {}) {
|
||||
const immediate = options.immediate !== false
|
||||
const context = shallowRef<AnalysisContext | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
invalidateAnalysisContextCache()
|
||||
context.value = await loadAnalysisContext()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (immediate) {
|
||||
void reload()
|
||||
}
|
||||
})
|
||||
|
||||
return { context, loading, reload }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* 与《分析策略/合规方向分析.md》§1.4 对齐的演示口径文案(用于页面脚注/图表说明)。
|
||||
* 演示数据为模拟;生产环境由配置中心或语义层下发。
|
||||
*/
|
||||
export const COMPLIANCE_DOC_REF = '分析策略/合规方向分析.md §1.4'
|
||||
|
||||
export const COMPLIANCE_FOOTNOTE_SHORT =
|
||||
'主时间轴:发生日期|单元:报告条数|PPM 分母:入院量 cyQty|获知:登记日期|闭环:审核日期|SAE:四类严重结局任一'
|
||||
|
||||
export const COMPLIANCE_FOOTNOTE_PPM =
|
||||
'PPM = AE 报告条数÷同期入院量 cyQty×1e6;AE 按发生日期入桶,入院量按年月与产品汇总后与 AE 产品名对齐(演示)。'
|
||||
|
||||
export const COMPLIANCE_FOOTNOTE_AUDIT =
|
||||
'漏报:投诉「是否不良事件=是」在 ±30 天内无匹配 AE(医院+产品+型号,发生日期↔C3登记);时效:Lag=审核日期−登记日期,演示阈值≤15 天。'
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** 与《分析策略/营销方向分析.md》口径对齐的 UI 脚注常量 */
|
||||
export const MARKETING_DOC_REF = '《营销方向分析》'
|
||||
|
||||
export const MKT_TIME_AXIS = '投诉主时间轴=C3登记日期(registerDate);统计单元=投诉条数'
|
||||
|
||||
export const MKT_ADMISSION_DENOM = '入院量分母=入院表 CY Qty 在省或经销商维度汇总(演示与质量方向一致)'
|
||||
|
||||
export const MKT_HOSPITAL_MAP = '医院→省/经销商:由入院量表按 HospitalName 对 Province、DealerName 取众数'
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import type { PillarType } from '../types/analysis'
|
||||
|
||||
export interface MenuLeaf {
|
||||
path: string
|
||||
title: string
|
||||
analysisKey: string
|
||||
strategyRef: string
|
||||
pillar: PillarType
|
||||
/** 默认 AnalysisView;综合分析 / 问答为独立页面 */
|
||||
view?: 'comprehensive' | 'qa'
|
||||
}
|
||||
|
||||
export interface MenuGroup {
|
||||
title: string
|
||||
children: MenuLeaf[]
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
title: string
|
||||
basePath: string
|
||||
children: MenuGroup[]
|
||||
}
|
||||
|
||||
export const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: '问答',
|
||||
basePath: '/qa',
|
||||
children: [
|
||||
{
|
||||
title: '数据问答',
|
||||
children: [
|
||||
{
|
||||
path: '/qa/chat',
|
||||
title: '智能问答',
|
||||
analysisKey: 'qa-chat',
|
||||
strategyRef: 'Demo 问答',
|
||||
pillar: 'goal',
|
||||
view: 'qa',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '合规方向',
|
||||
basePath: '/compliance',
|
||||
children: [
|
||||
{
|
||||
title: '监管报告',
|
||||
children: [
|
||||
{
|
||||
path: '/compliance/psur',
|
||||
title: 'PSUR 监管分析',
|
||||
analysisKey: 'compliance-psur',
|
||||
strategyRef: '合规方向分析 §2',
|
||||
pillar: 'monitor',
|
||||
},
|
||||
{
|
||||
path: '/compliance/audit',
|
||||
title: '上报合规性审计',
|
||||
analysisKey: 'compliance-audit',
|
||||
strategyRef: '合规方向分析 §3',
|
||||
pillar: 'diagnose',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '质量方向',
|
||||
basePath: '/quality',
|
||||
children: [
|
||||
{
|
||||
title: '核心分析',
|
||||
children: [
|
||||
{
|
||||
path: '/quality/concentration',
|
||||
title: '投诉集中性与 Pareto',
|
||||
analysisKey: 'quality-concentration',
|
||||
strategyRef: '3.1',
|
||||
pillar: 'diagnose',
|
||||
},
|
||||
{
|
||||
path: '/quality/trends',
|
||||
title: '趋势分析',
|
||||
analysisKey: 'quality-trends',
|
||||
strategyRef: '3.2',
|
||||
pillar: 'monitor',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '投诉行为聚类',
|
||||
children: [
|
||||
{
|
||||
path: '/quality/complaint-sales',
|
||||
title: '销售与入院关联',
|
||||
analysisKey: 'quality-complaint-sales',
|
||||
strategyRef: '3.3 a-c',
|
||||
pillar: 'diagnose',
|
||||
},
|
||||
{
|
||||
path: '/quality/complaint-investigation',
|
||||
title: '调查与赔付关系',
|
||||
analysisKey: 'quality-complaint-investigation',
|
||||
strategyRef: '3.3 e-f',
|
||||
pillar: 'act',
|
||||
},
|
||||
{
|
||||
path: '/quality/complaint-batch',
|
||||
title: '批次与稳定性',
|
||||
analysisKey: 'quality-complaint-batch',
|
||||
strategyRef: '3.3 g-o',
|
||||
pillar: 'diagnose',
|
||||
},
|
||||
{
|
||||
path: '/quality/complaint-text',
|
||||
title: '文本与流程类',
|
||||
analysisKey: 'quality-complaint-text',
|
||||
strategyRef: '3.3 p-x',
|
||||
pillar: 'goal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'AE 报告行为',
|
||||
children: [
|
||||
{
|
||||
path: '/quality/ae-cause',
|
||||
title: 'AE 报告原因推测',
|
||||
analysisKey: 'quality-ae-cause',
|
||||
strategyRef: '3.4.1',
|
||||
pillar: 'diagnose',
|
||||
},
|
||||
{
|
||||
path: '/quality/ae-only',
|
||||
title: '报告不投诉分析',
|
||||
analysisKey: 'quality-ae-only',
|
||||
strategyRef: '3.4.2',
|
||||
pillar: 'monitor',
|
||||
},
|
||||
{
|
||||
path: '/quality/ae-with-complaint',
|
||||
title: '报告且投诉分析',
|
||||
analysisKey: 'quality-ae-with-complaint',
|
||||
strategyRef: '3.4.3',
|
||||
pillar: 'diagnose',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '营销方向',
|
||||
basePath: '/marketing',
|
||||
children: [
|
||||
{
|
||||
title: '渠道与培训',
|
||||
children: [
|
||||
{
|
||||
path: '/marketing/operation-training',
|
||||
title: '操作不当与培训',
|
||||
analysisKey: 'marketing-operation-training',
|
||||
strategyRef: '营销方向分析 §2',
|
||||
pillar: 'act',
|
||||
},
|
||||
{
|
||||
path: '/marketing/region-dealer',
|
||||
title: '区域与经销商投诉率',
|
||||
analysisKey: 'marketing-region-dealer',
|
||||
strategyRef: '营销方向分析 §3',
|
||||
pillar: 'monitor',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '事件实质',
|
||||
basePath: '/substance',
|
||||
children: [
|
||||
{
|
||||
title: '风险本体',
|
||||
children: [
|
||||
{
|
||||
path: '/substance/matrix',
|
||||
title: '伤害 × 故障矩阵',
|
||||
analysisKey: 'substance-matrix',
|
||||
strategyRef: '合规方向分析 §2.4',
|
||||
pillar: 'diagnose',
|
||||
},
|
||||
{
|
||||
path: '/substance/seasonality',
|
||||
title: '故障季节性分析',
|
||||
analysisKey: 'substance-seasonality',
|
||||
strategyRef: '5.2',
|
||||
pillar: 'monitor',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '画像与综合',
|
||||
basePath: '/portfolio',
|
||||
children: [
|
||||
{
|
||||
title: '综合洞察',
|
||||
children: [
|
||||
{
|
||||
path: '/portfolio/comprehensive',
|
||||
title: '综合分析',
|
||||
analysisKey: 'portfolio-comprehensive',
|
||||
strategyRef: '综合分析.md',
|
||||
pillar: 'goal',
|
||||
view: 'comprehensive',
|
||||
},
|
||||
{
|
||||
path: '/portfolio/persona',
|
||||
title: '安全维度客户画像',
|
||||
analysisKey: 'portfolio-persona',
|
||||
strategyRef: '6',
|
||||
pillar: 'goal',
|
||||
},
|
||||
{
|
||||
path: '/portfolio/integrated',
|
||||
title: '综合联动分析',
|
||||
analysisKey: 'portfolio-integrated',
|
||||
strategyRef: '7',
|
||||
pillar: 'act',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const allLeaves = menuSections.flatMap((section) =>
|
||||
section.children.flatMap((group) => group.children),
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/** 与《分析策略/质量方向分析.md》口径对齐的 UI 脚注常量 */
|
||||
export const QUALITY_DOC_REF = '《质量方向分析》'
|
||||
|
||||
export const QLT_TIME_AXIS = '投诉主时间轴=C3登记日期(registerDate);统计单元=投诉条数(c3Code)'
|
||||
|
||||
export const QLT_ADMISSION_DENOM = '入院量分母=入院表 cyQty 按医院+产品汇总(演示未按月份与投诉月严格对齐)'
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<script setup lang="ts">
|
||||
import { SwitchButton } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { menuSections } from '../config/menu'
|
||||
import { useAuthStore } from '../stores/useAuth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push({ path: '/login' })
|
||||
}
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
|
||||
function onSelect(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
for (const section of menuSections) {
|
||||
for (const group of section.children) {
|
||||
const leaf = group.children.find((item) => item.path === route.path)
|
||||
if (leaf) {
|
||||
return [section.title, group.title, leaf.title]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['首页']
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="app-shell">
|
||||
<el-aside width="280px" class="side">
|
||||
<div class="brand">贝朗医疗数据分析 Demo</div>
|
||||
<el-menu :default-active="activePath" class="menu" @select="onSelect">
|
||||
<el-sub-menu v-for="section in menuSections" :key="section.title" :index="section.basePath">
|
||||
<template #title>{{ section.title }}</template>
|
||||
<el-sub-menu
|
||||
v-for="group in section.children"
|
||||
:key="`${section.title}-${group.title}`"
|
||||
:index="`${section.basePath}-${group.title}`"
|
||||
>
|
||||
<template #title>{{ group.title }}</template>
|
||||
<el-menu-item v-for="leaf in group.children" :key="leaf.path" :index="leaf.path">
|
||||
{{ leaf.title }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<el-breadcrumb separator="/" class="header-crumb">
|
||||
<el-breadcrumb-item v-for="item in breadcrumbItems" :key="item">{{ item }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<div class="header-actions">
|
||||
<span class="user-label">Demo</span>
|
||||
<el-button type="primary" link @click="logout">
|
||||
<el-icon class="el-icon--left"><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-color, #f5f7fa);
|
||||
}
|
||||
|
||||
.side {
|
||||
background: #001529;
|
||||
color: #fff;
|
||||
border-right: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 18px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border-color, #eef2f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-crumb {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
import type { AeRecord, AdmissionRecord, ComplaintRecord } from '../types/domain'
|
||||
import type { AnalysisContext } from '../types/analysis'
|
||||
|
||||
const monthKey = (dateString: string): string => dateString.slice(0, 7)
|
||||
|
||||
function parseDay(dateString: string): number {
|
||||
return Date.parse(`${dateString}T00:00:00`)
|
||||
}
|
||||
|
||||
export function isSaeRecord(record: AeRecord): boolean {
|
||||
return !!(
|
||||
record.saeDeath ||
|
||||
record.saeLifeThreatening ||
|
||||
record.saeDisability ||
|
||||
record.saeHospitalization
|
||||
)
|
||||
}
|
||||
|
||||
/** CMP-AE-101:按发生日期的月度 AE 报告条数 */
|
||||
export function buildAeMonthlyByOccurrence(context: AnalysisContext) {
|
||||
const counter = new Map<string, number>()
|
||||
context.ae.forEach((record) => {
|
||||
const key = monthKey(record.occurrenceDate)
|
||||
counter.set(key, (counter.get(key) ?? 0) + 1)
|
||||
})
|
||||
const months = [...counter.keys()].sort()
|
||||
return {
|
||||
months,
|
||||
values: months.map((item) => counter.get(item) ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** 产品事件 Top(全期,按发生日期归属可选扩展;此处为全量计数) */
|
||||
export function buildPsurTopProducts(context: AnalysisContext) {
|
||||
const counter = new Map<string, number>()
|
||||
context.ae.forEach((record) => {
|
||||
counter.set(record.productName, (counter.get(record.productName) ?? 0) + 1)
|
||||
})
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
|
||||
return {
|
||||
labels: sorted.map((item) => item[0]),
|
||||
values: sorted.map((item) => item[1]),
|
||||
}
|
||||
}
|
||||
|
||||
function admissionMonthKey(row: AdmissionRecord): string {
|
||||
return `${row.year}-${String(row.month).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 入院量:按年月汇总 cyQty(演示:不按产品拆,全量暴露) */
|
||||
function buildAdmissionQtyByMonth(context: AnalysisContext): Map<string, number> {
|
||||
const map = new Map<string, number>()
|
||||
context.admissions.forEach((row) => {
|
||||
const key = admissionMonthKey(row)
|
||||
map.set(key, (map.get(key) ?? 0) + row.cyQty)
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
/** CMP-AE-201 简化:全局月度 PPM(AE 按发生月 / 入院量按业务月) */
|
||||
export function buildGlobalMonthlyPpm(context: AnalysisContext) {
|
||||
const aeByMonth = new Map<string, number>()
|
||||
context.ae.forEach((r) => {
|
||||
const k = monthKey(r.occurrenceDate)
|
||||
aeByMonth.set(k, (aeByMonth.get(k) ?? 0) + 1)
|
||||
})
|
||||
const qtyByMonth = buildAdmissionQtyByMonth(context)
|
||||
const months = [...new Set([...aeByMonth.keys(), ...qtyByMonth.keys()])].sort()
|
||||
const ppm = months.map((m) => {
|
||||
const num = aeByMonth.get(m) ?? 0
|
||||
const den = qtyByMonth.get(m) ?? 0
|
||||
if (den <= 0) return null
|
||||
return Number(((num / den) * 1_000_000).toFixed(2))
|
||||
})
|
||||
return { months, ppm }
|
||||
}
|
||||
|
||||
/** CMP-AE-501:SAE 报告条数(按发生月) */
|
||||
export function buildSaeMonthlyByOccurrence(context: AnalysisContext) {
|
||||
const counter = new Map<string, number>()
|
||||
context.ae.forEach((record) => {
|
||||
if (!isSaeRecord(record)) return
|
||||
const key = monthKey(record.occurrenceDate)
|
||||
counter.set(key, (counter.get(key) ?? 0) + 1)
|
||||
})
|
||||
const months = [...counter.keys()].sort()
|
||||
return {
|
||||
months,
|
||||
values: months.map((item) => counter.get(item) ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** CMP-AE-502:SAE 占全部 AE 比例(按发生月) */
|
||||
export function buildSaeShareMonthly(context: AnalysisContext) {
|
||||
const total = new Map<string, number>()
|
||||
const sae = new Map<string, number>()
|
||||
context.ae.forEach((r) => {
|
||||
const k = monthKey(r.occurrenceDate)
|
||||
total.set(k, (total.get(k) ?? 0) + 1)
|
||||
if (isSaeRecord(r)) sae.set(k, (sae.get(k) ?? 0) + 1)
|
||||
})
|
||||
const months = [...total.keys()].sort()
|
||||
const shares = months.map((m) => {
|
||||
const t = total.get(m) ?? 1
|
||||
const s = sae.get(m) ?? 0
|
||||
return Number(((s / t) * 100).toFixed(1))
|
||||
})
|
||||
return { months, shares }
|
||||
}
|
||||
|
||||
/** CMP-AE-601:省级 AE 报告数(发生日期全期) */
|
||||
export function buildProvinceAeCounts(context: AnalysisContext) {
|
||||
const counter = new Map<string, number>()
|
||||
context.ae.forEach((r) => {
|
||||
counter.set(r.province, (counter.get(r.province) ?? 0) + 1)
|
||||
})
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1])
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
||||
/** CMP-AE-701:Lag = T_end(审核日期) − 登记日期,返回原始天数列表 */
|
||||
export function buildReportingLagDays(context: AnalysisContext): number[] {
|
||||
return context.ae.map((r) => {
|
||||
const lagMs = parseDay(r.reviewDate) - parseDay(r.registrationDate)
|
||||
return Math.round(lagMs / 86400000)
|
||||
})
|
||||
}
|
||||
|
||||
/** CMP-AE-702 演示:≤15 天为合规 */
|
||||
export function buildReportingTimelinessRate(context: AnalysisContext, thresholdDays = 15) {
|
||||
const lags = buildReportingLagDays(context)
|
||||
const ok = lags.filter((d) => d >= 0 && d <= thresholdDays).length
|
||||
const denom = lags.filter((d) => d >= 0).length || 1
|
||||
return { rate: Number(((ok / denom) * 100).toFixed(1)), sample: denom }
|
||||
}
|
||||
|
||||
/** CMP-AE-301:器械故障 Top */
|
||||
export function buildTopDeviceFailures(context: AnalysisContext, topN = 10) {
|
||||
const counter = new Map<string, number>()
|
||||
context.ae.forEach((r) => {
|
||||
const k = r.deviceFailure || '(空)'
|
||||
counter.set(k, (counter.get(k) ?? 0) + 1)
|
||||
})
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||||
const total = context.ae.length || 1
|
||||
return {
|
||||
labels: sorted.map((i) => i[0]),
|
||||
values: sorted.map((i) => i[1]),
|
||||
pct: sorted.map((i) => Number(((i[1] / total) * 100).toFixed(1))),
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
injury: string
|
||||
device: string
|
||||
value: number
|
||||
}
|
||||
|
||||
/** CMP-AE-401 / 402:伤害×故障矩阵;saeOnly 为 true 时等同 CMP-AE-402 */
|
||||
export function buildInjuryDeviceMatrix(context: AnalysisContext, saeOnly: boolean): {
|
||||
injuries: string[]
|
||||
devices: string[]
|
||||
matrix: number[][]
|
||||
cells: HeatmapCell[]
|
||||
} {
|
||||
const rows = saeOnly ? context.ae.filter((r) => isSaeRecord(r)) : context.ae
|
||||
const injuries = [...new Set(rows.map((r) => r.injuryExpression || '(空)'))].sort()
|
||||
const devices = [...new Set(rows.map((r) => r.deviceFailure || '(空)'))].sort()
|
||||
const idxI = new Map(injuries.map((v, i) => [v, i]))
|
||||
const idxD = new Map(devices.map((v, i) => [v, i]))
|
||||
const matrix = injuries.map(() => devices.map(() => 0))
|
||||
const cells: HeatmapCell[] = []
|
||||
rows.forEach((r) => {
|
||||
const i = idxI.get(r.injuryExpression || '(空)') ?? 0
|
||||
const j = idxD.get(r.deviceFailure || '(空)') ?? 0
|
||||
matrix[i][j] += 1
|
||||
})
|
||||
for (let i = 0; i < injuries.length; i++) {
|
||||
for (let j = 0; j < devices.length; j++) {
|
||||
if (matrix[i][j] > 0) {
|
||||
cells.push({ injury: injuries[i], device: devices[j], value: matrix[i][j] })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { injuries, devices, matrix, cells }
|
||||
}
|
||||
|
||||
export function buildComplaintPareto(context: AnalysisContext) {
|
||||
const comboCounter = new Map<string, number>()
|
||||
context.complaints.forEach((record) => {
|
||||
const key = `${record.productName} / ${record.faultType}`
|
||||
comboCounter.set(key, (comboCounter.get(key) ?? 0) + 1)
|
||||
})
|
||||
const sorted = [...comboCounter.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
|
||||
const total = sorted.reduce((sum, item) => sum + item[1], 0) || 1
|
||||
let cumulative = 0
|
||||
return {
|
||||
labels: sorted.map((item) => item[0]),
|
||||
bars: sorted.map((item) => item[1]),
|
||||
line: sorted.map((item) => {
|
||||
cumulative += item[1]
|
||||
return Number(((cumulative / total) * 100).toFixed(1))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBatchConcentration(context: AnalysisContext) {
|
||||
const batchCounter = new Map<string, number>()
|
||||
context.complaints.forEach((record) => {
|
||||
batchCounter.set(record.batchNo, (batchCounter.get(record.batchNo) ?? 0) + 1)
|
||||
})
|
||||
const sorted = [...batchCounter.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
|
||||
return {
|
||||
labels: sorted.map((item) => item[0]),
|
||||
values: sorted.map((item) => item[1]),
|
||||
}
|
||||
}
|
||||
|
||||
const MATCH_WINDOW_DAYS = 30
|
||||
|
||||
function daysBetween(a: string, b: string): number {
|
||||
return Math.round((parseDay(a) - parseDay(b)) / 86400000)
|
||||
}
|
||||
|
||||
/** 主题 J:投诉 isAe 与 AE 匹配(发生日期 ↔ C3 登记) */
|
||||
export function buildComplianceMatching(context: AnalysisContext) {
|
||||
const flagged = context.complaints.filter((c) => c.isAe)
|
||||
let matched = 0
|
||||
let missed = 0
|
||||
const missedRows: ComplaintRecord[] = []
|
||||
|
||||
for (const c of flagged) {
|
||||
const candidates = context.ae.filter(
|
||||
(a) =>
|
||||
a.unitName === c.hospitalName &&
|
||||
a.productName === c.productName &&
|
||||
a.model === c.model &&
|
||||
Math.abs(daysBetween(a.occurrenceDate, c.registerDate)) <= MATCH_WINDOW_DAYS,
|
||||
)
|
||||
if (candidates.length) {
|
||||
matched += 1
|
||||
} else {
|
||||
missed += 1
|
||||
missedRows.push(c)
|
||||
}
|
||||
}
|
||||
|
||||
const total = flagged.length || 1
|
||||
const leakRate = Number(((missed / total) * 100).toFixed(1))
|
||||
|
||||
let lagCompliantFromRegistration = 0
|
||||
let lagSample = 0
|
||||
let lagCompliantFromC3 = 0
|
||||
for (const c of flagged) {
|
||||
const candidates = context.ae.filter(
|
||||
(a) =>
|
||||
a.unitName === c.hospitalName &&
|
||||
a.productName === c.productName &&
|
||||
a.model === c.model &&
|
||||
Math.abs(daysBetween(a.occurrenceDate, c.registerDate)) <= MATCH_WINDOW_DAYS,
|
||||
)
|
||||
if (!candidates.length) continue
|
||||
const best = candidates.reduce((prev, cur) => {
|
||||
const da = Math.abs(daysBetween(cur.occurrenceDate, c.registerDate))
|
||||
const db = Math.abs(daysBetween(prev.occurrenceDate, c.registerDate))
|
||||
return da < db ? cur : prev
|
||||
})
|
||||
lagSample += 1
|
||||
const lagReg = daysBetween(best.reviewDate, best.registrationDate)
|
||||
if (lagReg >= 0 && lagReg <= 15) lagCompliantFromRegistration += 1
|
||||
const lagC3 = daysBetween(best.reviewDate, c.registerDate)
|
||||
if (lagC3 >= 0 && lagC3 <= 15) lagCompliantFromC3 += 1
|
||||
}
|
||||
|
||||
return {
|
||||
flaggedTotal: flagged.length,
|
||||
matched,
|
||||
missed,
|
||||
leakRate,
|
||||
matchRate: Number(((matched / total) * 100).toFixed(1)),
|
||||
timelinessFromRegistrationPct:
|
||||
lagSample > 0 ? Number(((lagCompliantFromRegistration / lagSample) * 100).toFixed(1)) : 0,
|
||||
timelinessFromC3Pct: lagSample > 0 ? Number(((lagCompliantFromC3 / lagSample) * 100).toFixed(1)) : 0,
|
||||
lagSample,
|
||||
missedRows,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
import type { AnalysisContext } from '../types/analysis'
|
||||
import { getAnalysisDefinition } from '../config/analysis-config'
|
||||
import {
|
||||
buildAeMonthlyByOccurrence,
|
||||
buildComplaintPareto,
|
||||
buildComplianceMatching,
|
||||
buildGlobalMonthlyPpm,
|
||||
buildInjuryDeviceMatrix,
|
||||
buildProvinceAeCounts,
|
||||
buildPsurTopProducts,
|
||||
buildReportingLagDays,
|
||||
buildSaeMonthlyByOccurrence,
|
||||
buildSaeShareMonthly,
|
||||
buildTopDeviceFailures,
|
||||
isSaeRecord,
|
||||
} from './aggregate'
|
||||
import {
|
||||
buildBatchComplaintVsAeCounts,
|
||||
buildComplaintBatchTop,
|
||||
buildComplaintCloseCycleDays,
|
||||
buildComplaintMonthly,
|
||||
buildCompensationDist,
|
||||
buildDefectConfirmedProductFault,
|
||||
buildFaultByRegisterMonthMatrix,
|
||||
buildHospitalAeVsAdmissionScatter,
|
||||
buildHospitalComplaintVsAdmissionQty,
|
||||
buildHospitalProductComplaintRate,
|
||||
buildInvestigationConclusionDist,
|
||||
buildIsAeRateByProduct,
|
||||
buildProductComplaintHalfYearGrowth,
|
||||
buildProvinceComplaintHalfYearGrowth,
|
||||
buildTopFaultTypes,
|
||||
buildTopHospitalsComplaints,
|
||||
buildTopProductsComplaints,
|
||||
} from './quality-aggregate'
|
||||
import {
|
||||
buildDealerComplaintRatePer1000,
|
||||
buildDealerOpWrongRatePer1000,
|
||||
buildOpWrongDealerCounts,
|
||||
buildOpWrongHospitalTop,
|
||||
buildProductOpWrongSharePct,
|
||||
buildProvinceComplaintCounts,
|
||||
buildProvinceComplaintRatePer1000,
|
||||
} from './marketing-aggregate'
|
||||
|
||||
export type InsightDimension = '合规' | '质量' | '营销' | '事件实质'
|
||||
|
||||
export interface InsightRow {
|
||||
dimension: InsightDimension
|
||||
pageTitle: string
|
||||
chartId: string
|
||||
chartTitle: string
|
||||
bullets: string[]
|
||||
}
|
||||
|
||||
const PAGE_ORDER: { key: string; dimension: InsightDimension }[] = [
|
||||
{ key: 'compliance-psur', dimension: '合规' },
|
||||
{ key: 'compliance-audit', dimension: '合规' },
|
||||
{ key: 'quality-concentration', dimension: '质量' },
|
||||
{ key: 'quality-trends', dimension: '质量' },
|
||||
{ key: 'quality-complaint-sales', dimension: '质量' },
|
||||
{ key: 'quality-complaint-investigation', dimension: '质量' },
|
||||
{ key: 'quality-complaint-batch', dimension: '质量' },
|
||||
{ key: 'quality-ae-cause', dimension: '质量' },
|
||||
{ key: 'quality-ae-only', dimension: '质量' },
|
||||
{ key: 'quality-ae-with-complaint', dimension: '质量' },
|
||||
{ key: 'marketing-operation-training', dimension: '营销' },
|
||||
{ key: 'marketing-region-dealer', dimension: '营销' },
|
||||
{ key: 'substance-matrix', dimension: '事件实质' },
|
||||
{ key: 'substance-seasonality', dimension: '事件实质' },
|
||||
]
|
||||
|
||||
function seriesPeak<T extends string | number>(labels: T[], values: number[], unit: string): string[] {
|
||||
if (!labels.length) return ['当前序列无有效数据点。']
|
||||
let maxI = 0
|
||||
let minI = 0
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
if (values[i] > values[maxI]) maxI = i
|
||||
if (values[i] < values[minI]) minI = i
|
||||
}
|
||||
const sum = values.reduce((a, b) => a + b, 0)
|
||||
const avg = sum / values.length
|
||||
const bullets = [
|
||||
`统计范围内合计 ${sum}${unit},覆盖 ${labels.length} 个序列点。`,
|
||||
`峰值出现在「${labels[maxI]}」(${values[maxI]}${unit});低谷为「${labels[minI]}」(${values[minI]}${unit})。`,
|
||||
]
|
||||
if (values[maxI] > avg * 1.25) bullets.push('峰值明显高于均值,建议结合下钻维度核查是否存在集中爆发或外部因素。')
|
||||
else bullets.push('波动相对温和,可继续跟踪后续周期是否突破历史区间。')
|
||||
return bullets
|
||||
}
|
||||
|
||||
type Builder = (ctx: AnalysisContext) => string[]
|
||||
|
||||
const INSIGHTS: Record<string, Builder> = {
|
||||
'psur-monthly': (ctx) => {
|
||||
const t = buildAeMonthlyByOccurrence(ctx)
|
||||
return seriesPeak(t.months, t.values, ' 条 AE')
|
||||
},
|
||||
'psur-ppm': (ctx) => {
|
||||
const p = buildGlobalMonthlyPpm(ctx)
|
||||
const pairs = p.months.map((m, i) => ({ m, v: p.ppm[i] }))
|
||||
const valid = pairs.filter((x) => x.v != null) as { m: string; v: number }[]
|
||||
if (!valid.length) return ['多数月份缺少入院量分母,PPM 不可算;需检查入院数据覆盖。']
|
||||
const vals = valid.map((x) => x.v)
|
||||
const maxI = vals.indexOf(Math.max(...vals))
|
||||
return [
|
||||
`可计算 PPM 的月份共 ${valid.length} 个;最高 PPM 出现在 ${valid[maxI].m}(${valid[maxI].v})。`,
|
||||
'PPM 同时受分子(AE 条数)与分母(CY Qty)波动影响,解读时需双因子对照。',
|
||||
]
|
||||
},
|
||||
'psur-device-top': (ctx) => {
|
||||
const top = buildTopDeviceFailures(ctx, 10)
|
||||
if (!top.labels.length) return ['暂无器械故障字段数据。']
|
||||
return [
|
||||
`报告条数最多的器械故障表现为「${top.labels[0]}」(${top.values[0]} 条)。`,
|
||||
`Top3 合计约占当期可分类故障的显著份额,适合作为 PSUR「常见原因」叙述抓手。`,
|
||||
]
|
||||
},
|
||||
'psur-product-top': (ctx) => {
|
||||
const t = buildPsurTopProducts(ctx)
|
||||
if (!t.labels.length) return ['暂无产品维度 AE。']
|
||||
return [`AE 条数最高的产品为「${t.labels[0]}」(${t.values[0]} 条)。`, '建议与投诉侧同产品趋势对照阅读,区分监管通道与市场通道信号。']
|
||||
},
|
||||
'psur-sae-monthly': (ctx) => {
|
||||
const s = buildSaeMonthlyByOccurrence(ctx)
|
||||
return seriesPeak(s.months, s.values, ' 条 SAE')
|
||||
},
|
||||
'psur-sae-share': (ctx) => {
|
||||
const s = buildSaeShareMonthly(ctx)
|
||||
const avg = s.shares.reduce((a, b) => a + b, 0) / s.shares.length
|
||||
const maxI = s.shares.indexOf(Math.max(...s.shares))
|
||||
return [
|
||||
`SAE 占全部 AE 比例月均约 ${avg.toFixed(1)}%。`,
|
||||
`占比峰值出现在 ${s.months[maxI]}(${s.shares[maxI]}%),需关注是否伴随总量上升(量率齐升)。`,
|
||||
]
|
||||
},
|
||||
'psur-province': (ctx) => {
|
||||
const p = buildProvinceAeCounts(ctx)
|
||||
if (!p.labels.length) return ['暂无省级 AE 分布。']
|
||||
return [
|
||||
`AE 报告条数最多的省份为「${p.labels[0]}」(${p.values[0]} 条)。`,
|
||||
'省级绝对量需结合人口与装机/入院暴露进一步做率化,再判「热区」。',
|
||||
]
|
||||
},
|
||||
'psur-lag-histogram': (ctx) => {
|
||||
const lags = buildReportingLagDays(ctx).filter((d) => d >= 0)
|
||||
if (!lags.length) return ['缺少可计算的滞后天数(审核日或登记日为空)。']
|
||||
const mean = lags.reduce((a, b) => a + b, 0) / lags.length
|
||||
const ok = lags.filter((d) => d <= 15).length
|
||||
return [
|
||||
`有效样本 ${lags.length} 条,上报滞后(审核−登记)均值约 ${mean.toFixed(1)} 天。`,
|
||||
`其中 ≤15 天约占 ${((ok / lags.length) * 100).toFixed(1)}%(演示阈值)。`,
|
||||
]
|
||||
},
|
||||
'audit-summary-bar': (ctx) => {
|
||||
const m = buildComplianceMatching(ctx)
|
||||
return [
|
||||
`应报样本(投诉标记不良事件)共 ${m.flaggedTotal} 条,疑似漏报率 ${m.leakRate}%,匹配率 ${m.matchRate}%。`,
|
||||
`匹配子集上,按登记与按 C3 起算的 ≤15 天占比分别为 ${m.timelinessFromRegistrationPct}% 与 ${m.timelinessFromC3Pct}%。`,
|
||||
]
|
||||
},
|
||||
'audit-counts-pie': (ctx) => {
|
||||
const m = buildComplianceMatching(ctx)
|
||||
return [`已匹配 ${m.matched} 条,疑似漏报 ${m.missed} 条。`, '饼图结构用于审计抽样框说明;漏报清单需进入逐条调查闭环。']
|
||||
},
|
||||
'quality-pareto-chart': (ctx) => {
|
||||
const p = buildComplaintPareto(ctx)
|
||||
if (!p.labels.length) return ['暂无产品×故障组合数据。']
|
||||
return [
|
||||
`贡献最大的组合为「${p.labels[0]}」(${p.bars[0]} 条投诉)。`,
|
||||
`Top 组合累计占比曲线末点约 ${p.line[p.line.length - 1]}%,可据此划定「80% 投诉池」优先改进范围。`,
|
||||
]
|
||||
},
|
||||
'quality-batch-chart': (ctx) => {
|
||||
const b = buildComplaintBatchTop(ctx, 8)
|
||||
if (!b.labels.length) return ['暂无批号信息。']
|
||||
return [`投诉集中度最高的批号为「${b.labels[0]}」(${b.values[0]} 条)。`, '建议对 Top 批号启动批次质量回溯并与 AE 批号对照。']
|
||||
},
|
||||
'quality-hospital-top': (ctx) => {
|
||||
const t = buildTopHospitalsComplaints(ctx, 8)
|
||||
return [`投诉条数最多的医院为「${t.labels[0]}」(${t.values[0]} 条)。`, '医院端集中可与培训、跟台及备件策略联动。']
|
||||
},
|
||||
'quality-product-fault-top': (ctx) => {
|
||||
const t = buildTopFaultTypes(ctx, 8)
|
||||
return [`频次最高的故障类型为「${t.labels[0]}」(${t.values[0]} 次)。`, '一维故障分布可与 Pareto 组合图交叉验证。']
|
||||
},
|
||||
'trend-monthly': (ctx) => {
|
||||
const t = buildComplaintMonthly(ctx)
|
||||
return seriesPeak(t.months, t.values, ' 条投诉')
|
||||
},
|
||||
'trend-product-half': (ctx) => {
|
||||
const g = buildProductComplaintHalfYearGrowth(ctx)
|
||||
return [
|
||||
`按上下半年对比,满足「增速≥20%」预警规则的产品共 ${g.length} 个(演示规则)。`,
|
||||
g.length ? `预警列表首项产品为「${g[0].product}」,建议结合 Pareto 与批号视图复核。` : '当前样本未触发产品增速预警,可结合更长窗口观察。',
|
||||
]
|
||||
},
|
||||
'trend-product-alert': (ctx) => {
|
||||
const g = buildProductComplaintHalfYearGrowth(ctx)
|
||||
return [
|
||||
g.length
|
||||
? `预警列表首位产品为「${g[0].product}」,建议优先安排工程/质量联合复盘。`
|
||||
: '未识别到达阈的增速异常产品(或样本不足)。',
|
||||
]
|
||||
},
|
||||
'trend-province-half': (ctx) => {
|
||||
const rows = buildProvinceComplaintHalfYearGrowth(ctx)
|
||||
const hit = rows.filter((r) => r.hit)
|
||||
return [
|
||||
`共统计 ${rows.length} 个省份上下半年投诉量。`,
|
||||
hit.length ? `其中 ${hit.length} 个省下半年相对上半年增幅≥20%,需关注区域侧原因。` : '未识别省级增速达阈信号。',
|
||||
]
|
||||
},
|
||||
'qc-sales-scatter': (ctx) => {
|
||||
const pts = buildHospitalComplaintVsAdmissionQty(ctx)
|
||||
const hi = [...pts].sort((a, b) => b.complaints - a.complaints)[0]
|
||||
return [
|
||||
`共 ${pts.length} 家医院同时具有投诉与入院量数据。`,
|
||||
hi ? `投诉条数最多的是「${hi.hospital}」(${hi.complaints} 条,cyQty 汇总 ${hi.cyQty})。` : '',
|
||||
'散点偏离带可提示「相对入院量投诉偏多」的医院,需结合床位与产品结构复核。',
|
||||
].filter(Boolean)
|
||||
},
|
||||
'qc-sales-rate': (ctx) => {
|
||||
const rows = buildHospitalProductComplaintRate(ctx)
|
||||
const top = rows[0]
|
||||
if (!top) return ['暂无医院×产品投诉率数据。']
|
||||
return [
|
||||
`医院×产品维度投诉率(件/千件)最高为「${top.hospital} / ${top.product}」(${top.ratePer1000})。`,
|
||||
'率化结果对入院量分母敏感,脚注需固定为 CY Qty 与对齐键版本。',
|
||||
]
|
||||
},
|
||||
'qc-inv-pie': (ctx) => {
|
||||
const d = buildInvestigationConclusionDist(ctx)
|
||||
const top = d[0]
|
||||
return top ? [`调查结论占比最高为「${top[0]}」(${top[1]} 条)。`, '结构变化适合按季度监控,识别「产品缺陷成立」是否抬升。'] : ['暂无调查结论数据。']
|
||||
},
|
||||
'qc-defect-bar': (ctx) => {
|
||||
const d = buildDefectConfirmedProductFault(ctx)
|
||||
return d.labels.length
|
||||
? [`「产品缺陷成立」场景下,最高频组合为「${d.labels[0]}」(${d.values[0]} 条)。`, '该池子应进入 CAPA/设计变更输入优先级队列。']
|
||||
: ['暂无产品缺陷成立记录。']
|
||||
},
|
||||
'qc-comp-pie': (ctx) => {
|
||||
const d = buildCompensationDist(ctx)
|
||||
const top = d[0]
|
||||
return top ? [`赔付方式占比最高为「${top[0]}」(${top[1]} 次)。`, '可与缺陷成立子集交叉,观察质量失败外显成本结构。'] : ['暂无赔付数据。']
|
||||
},
|
||||
'qc-product-top': (ctx) => {
|
||||
const t = buildTopProductsComplaints(ctx, 8)
|
||||
return [`投诉条数最多的产品为「${t.labels[0]}」(${t.values[0]} 条)。`, '与调查/赔付饼图联读可快速定位「问题产品」。']
|
||||
},
|
||||
'qc-batch-ae': (ctx) => {
|
||||
const rows = buildBatchComplaintVsAeCounts(ctx).filter((r) => r.complaints + r.aeReports > 0)
|
||||
if (!rows.length) return ['暂无可对照的批号双通道数据。']
|
||||
const top = [...rows].sort((a, b) => b.complaints + b.aeReports - (a.complaints + a.aeReports))[0]
|
||||
return [
|
||||
`批号「${top.batchNo}」投诉 ${top.complaints} 条、AE ${top.aeReports} 条,合计通道压力最大(演示口径)。`,
|
||||
'双通道同升批号应优先纳入监管沟通与质量联合调查。',
|
||||
]
|
||||
},
|
||||
'qc-close-cycle': (ctx) => {
|
||||
const days = buildComplaintCloseCycleDays(ctx)
|
||||
if (!days.length) return ['缺少关闭日期或登记日期,无法计算周期。']
|
||||
const mean = days.reduce((a, b) => a + b, 0) / days.length
|
||||
return [`投诉关闭周期(关闭−登记)均值约 ${mean.toFixed(1)} 天。`, '分布右尾偏长通常与补件、调查反复或跨部门协同有关。']
|
||||
},
|
||||
'ae-scatter-hosp': (ctx) => {
|
||||
const pts = buildHospitalAeVsAdmissionScatter(ctx)
|
||||
const hi = [...pts].sort((a, b) => b.aeCount - a.aeCount)[0]
|
||||
return [
|
||||
`共 ${pts.length} 家医院具备 AE 与入院量对照数据。`,
|
||||
hi ? `AE 条数最多的是「${hi.hospital}」(${hi.aeCount} 条)。` : '',
|
||||
'该图为报告行为异常筛查视角,不等同于医院医疗质量差。',
|
||||
].filter(Boolean)
|
||||
},
|
||||
'ae-upgrade-rate': (ctx) => {
|
||||
const rows = buildIsAeRateByProduct(ctx)
|
||||
const top = [...rows].sort((a, b) => b.ratePct - a.ratePct)[0]
|
||||
return top
|
||||
? [`升级率(是否不良事件=是)最高的产品为「${top.product}」(${top.ratePct}%)。`, '跨 BU 对比可反映上报文化与流程差异。']
|
||||
: ['暂无升级率数据。']
|
||||
},
|
||||
'ae-only-failure': (ctx) => {
|
||||
const top = buildTopDeviceFailures(ctx, 8)
|
||||
return [`未并发投诉的 AE 中,器械故障 Top1 为「${top.labels[0]}」。`, '用于理解「仅监管通道」事件形态。']
|
||||
},
|
||||
'ae-only-monthly': (ctx) => {
|
||||
const t = buildAeMonthlyByOccurrence(ctx)
|
||||
return [`AE 仅报告维度下,月度峰值仍在「${t.months[t.values.indexOf(Math.max(...t.values))]}」。`, '与投诉月度曲线对照时注意双时间轴。']
|
||||
},
|
||||
'ae-with-fault': (ctx) => {
|
||||
const sub = ctx.complaints.filter((c) => c.isAe)
|
||||
const m = new Map<string, number>()
|
||||
sub.forEach((c) => m.set(c.faultType, (m.get(c.faultType) ?? 0) + 1))
|
||||
const sorted = [...m.entries()].sort((a, b) => b[1] - a[1])
|
||||
return sorted.length
|
||||
? [`已升级投诉中最高频故障为「${sorted[0][0]}」。`, '可与 AE 伤害×故障矩阵叙事衔接。']
|
||||
: ['暂无已升级投诉。']
|
||||
},
|
||||
'ae-with-monthly': (ctx) => {
|
||||
const sub = ctx.complaints.filter((c) => c.isAe)
|
||||
return [`当前样本中已升级投诉共 ${sub.length} 条。`, '登记月趋势用于观察升级投诉是否与活动期/政策窗口重叠。']
|
||||
},
|
||||
'mkt-op-hospital': (ctx) => {
|
||||
const d = buildOpWrongHospitalTop(ctx, 14)
|
||||
return d.labels.length
|
||||
? [`操作不当投诉最多的医院为「${d.labels[0]}」(${d.values[0]} 条)。`, '适合作为院内操作培训与跟台加强的优先名单。']
|
||||
: ['无操作不当子集样本。']
|
||||
},
|
||||
'mkt-op-dealer-count': (ctx) => {
|
||||
const d = buildOpWrongDealerCounts(ctx, 14)
|
||||
return d.labels.length
|
||||
? [`操作不当投诉条数最多的经销商为「${d.labels[0]}」(${d.values[0]} 条)。`, '渠道侧可安排联合巡院与标准操作复核。']
|
||||
: ['无操作不当子集样本。']
|
||||
},
|
||||
'mkt-op-dealer-rate': (ctx) => {
|
||||
const d = buildDealerOpWrongRatePer1000(ctx, 14)
|
||||
return d.labels.length
|
||||
? [`操作不当率(件/千件)最高的经销商为「${d.labels[0]}」(${d.values[0]})。`, '率化结果提示相对销量下的使用风险,不等于经销商储运问题。']
|
||||
: ['无法计算经销商操作不当率(分母或分子不足)。']
|
||||
},
|
||||
'mkt-op-product-share': (ctx) => {
|
||||
const d = buildProductOpWrongSharePct(ctx, 12)
|
||||
return d.labels.length
|
||||
? [`操作不当占该产品全部投诉比例最高为「${d.labels[0]}」(${d.values[0]}%)。`, '说明书与数字化指导素材可优先迭代该产品。']
|
||||
: ['无操作不当占比数据。']
|
||||
},
|
||||
'mkt-rg-province-rate': (ctx) => {
|
||||
const d = buildProvinceComplaintRatePer1000(ctx, 16)
|
||||
return d.labels.length
|
||||
? [`省级投诉率(件/千件)最高为「${d.labels[0]}」(${d.values[0]})。`, '需与绝对量图对照,避免大省误判。']
|
||||
: ['暂无省级投诉率数据。']
|
||||
},
|
||||
'mkt-rg-dealer-rate': (ctx) => {
|
||||
const d = buildDealerComplaintRatePer1000(ctx, 16)
|
||||
return d.labels.length
|
||||
? [`经销商维度投诉率(件/千件)最高为「${d.labels[0]}」(${d.values[0]})。`, '可作为渠道辅导与考核的量化参考之一。']
|
||||
: ['暂无经销商投诉率数据。']
|
||||
},
|
||||
'mkt-rg-province-count': (ctx) => {
|
||||
const d = buildProvinceComplaintCounts(ctx, 14)
|
||||
return d.labels.length
|
||||
? [`省级投诉绝对量最高为「${d.labels[0]}」(${d.values[0]} 条)。`, '与省级率图并列阅读,区分总量大与相对率高。']
|
||||
: ['暂无省级投诉分布。']
|
||||
},
|
||||
'matrix-all': (ctx) => {
|
||||
const { cells } = buildInjuryDeviceMatrix(ctx, false)
|
||||
if (!cells.length) return ['暂无伤害×故障交叉数据。']
|
||||
const top = [...cells].sort((a, b) => b.value - a.value)[0]
|
||||
return [
|
||||
`全量 AE 下最高频组合为「${top.injury} × ${top.device}」(${top.value} 条)。`,
|
||||
'热力图用于 CAPA 优先级与医学叙述,不等同因果结论。',
|
||||
]
|
||||
},
|
||||
'matrix-sae': (ctx) => {
|
||||
const rows = ctx.ae.filter((r) => isSaeRecord(r))
|
||||
const { cells } = buildInjuryDeviceMatrix(ctx, true)
|
||||
if (!cells.length) return ['SAE 子集下暂无有效组合。']
|
||||
const top = [...cells].sort((a, b) => b.value - a.value)[0]
|
||||
return [
|
||||
`当前 SAE 子集共 ${rows.length} 条;最高频伤害-故障组合为「${top.injury} × ${top.device}」。`,
|
||||
'建议与严重医学评审及对外沟通材料要点对齐。',
|
||||
]
|
||||
},
|
||||
'seasonality-heatmap': (ctx) => {
|
||||
const { cells } = buildFaultByRegisterMonthMatrix(ctx)
|
||||
if (!cells.length) return ['暂无故障×月份数据。']
|
||||
const top = [...cells].sort((a, b) => b.value - a.value)[0]
|
||||
return [
|
||||
`投诉侧「故障×登记月」强度最高单元为「${top.fault} / ${top.month}」(${top.value} 条)。`,
|
||||
'探索性结论需结合气候/医院排班等外部变量再验证。',
|
||||
]
|
||||
},
|
||||
'seasonality-monthly-total': (ctx) => {
|
||||
const t = buildComplaintMonthly(ctx)
|
||||
const maxI = t.values.indexOf(Math.max(...t.values))
|
||||
return [`投诉总量峰值月份为 ${t.months[maxI]}(${t.values[maxI]} 条)。`, '与热力图行合计应对齐,用于读整体季节性背景。']
|
||||
},
|
||||
}
|
||||
|
||||
export function buildComprehensiveInsightRows(context: AnalysisContext): InsightRow[] {
|
||||
const rows: InsightRow[] = []
|
||||
for (const { key, dimension } of PAGE_ORDER) {
|
||||
const def = getAnalysisDefinition(key)
|
||||
if (!def) continue
|
||||
for (const sec of def.sections) {
|
||||
for (const chart of sec.charts) {
|
||||
if (chart.placeholder) continue
|
||||
if (!chart.optionBuilder) continue
|
||||
const builder = INSIGHTS[chart.id]
|
||||
const bullets = builder ? builder(context) : ['该图表已接入数据视图;结论规则尚未注册,可在 comprehensive-insights.ts 中补充。']
|
||||
rows.push({
|
||||
dimension,
|
||||
pageTitle: def.title,
|
||||
chartId: chart.id,
|
||||
chartTitle: chart.title,
|
||||
bullets,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function groupInsightsByDimension(rows: InsightRow[]): Record<InsightDimension, InsightRow[]> {
|
||||
const init: Record<InsightDimension, InsightRow[]> = { 合规: [], 质量: [], 营销: [], 事件实质: [] }
|
||||
for (const r of rows) init[r.dimension].push(r)
|
||||
return init
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import type { AnalysisContext } from '../types/analysis'
|
||||
import { buildHospitalProvinceMap } from './quality-aggregate'
|
||||
|
||||
const OP_MISCONDUCT = '操作不当'
|
||||
|
||||
/** 医院 → 经销商(入院量表众数) */
|
||||
export function buildHospitalDealerMap(context: AnalysisContext): Map<string, string> {
|
||||
const count = new Map<string, Map<string, number>>()
|
||||
for (const a of context.admissions) {
|
||||
if (!count.has(a.hospitalName)) count.set(a.hospitalName, new Map())
|
||||
const inner = count.get(a.hospitalName)!
|
||||
const d = a.dealerName || '(空)'
|
||||
inner.set(d, (inner.get(d) ?? 0) + 1)
|
||||
}
|
||||
const m = new Map<string, string>()
|
||||
for (const [h, dmap] of count) {
|
||||
const top = [...dmap.entries()].sort((x, y) => y[1] - x[1])[0]
|
||||
if (top) m.set(h, top[0])
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export function filterOperationMisconductComplaints(context: AnalysisContext) {
|
||||
return context.complaints.filter((c) => c.conclusion === OP_MISCONDUCT)
|
||||
}
|
||||
|
||||
/** MKT-OP-101 */
|
||||
export function buildOpWrongHospitalTop(context: AnalysisContext, topN = 15) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of filterOperationMisconductComplaints(context)) {
|
||||
counter.set(c.hospitalName, (counter.get(c.hospitalName) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
||||
/** MKT-OP-102 */
|
||||
export function buildOpWrongDealerCounts(context: AnalysisContext, topN = 15) {
|
||||
const dealerMap = buildHospitalDealerMap(context)
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of filterOperationMisconductComplaints(context)) {
|
||||
const d = dealerMap.get(c.hospitalName) ?? '(未映射经销商)'
|
||||
counter.set(d, (counter.get(d) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
||||
/** MKT-OP-103 */
|
||||
export function buildDealerOpWrongRatePer1000(context: AnalysisContext, topN = 15) {
|
||||
const dealerMap = buildHospitalDealerMap(context)
|
||||
const num = new Map<string, number>()
|
||||
for (const c of filterOperationMisconductComplaints(context)) {
|
||||
const d = dealerMap.get(c.hospitalName) ?? '(未映射经销商)'
|
||||
num.set(d, (num.get(d) ?? 0) + 1)
|
||||
}
|
||||
const den = new Map<string, number>()
|
||||
for (const a of context.admissions) {
|
||||
const d = a.dealerName || '(空)'
|
||||
den.set(d, (den.get(d) ?? 0) + a.cyQty)
|
||||
}
|
||||
const dealers = [...new Set([...num.keys(), ...den.keys()])]
|
||||
const rows = dealers
|
||||
.map((d) => {
|
||||
const n = num.get(d) ?? 0
|
||||
const q = den.get(d) ?? 0
|
||||
return { dealer: d, n, q, rate: q > 0 ? (n / q) * 1000 : 0 }
|
||||
})
|
||||
.filter((r) => r.n > 0 && r.q > 0)
|
||||
.sort((a, b) => b.rate - a.rate)
|
||||
.slice(0, topN)
|
||||
return { labels: rows.map((r) => r.dealer), values: rows.map((r) => Number(r.rate.toFixed(3))) }
|
||||
}
|
||||
|
||||
/** MKT-OP-104 */
|
||||
export function buildProductOpWrongSharePct(context: AnalysisContext, topN = 15) {
|
||||
const total = new Map<string, number>()
|
||||
const wrong = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const p = c.productName
|
||||
total.set(p, (total.get(p) ?? 0) + 1)
|
||||
if (c.conclusion === OP_MISCONDUCT) wrong.set(p, (wrong.get(p) ?? 0) + 1)
|
||||
}
|
||||
const rows = [...total.keys()]
|
||||
.map((p) => {
|
||||
const t = total.get(p) ?? 1
|
||||
const w = wrong.get(p) ?? 0
|
||||
return { p, pct: t > 0 ? (w / t) * 100 : 0, w }
|
||||
})
|
||||
.filter((r) => r.w > 0)
|
||||
.sort((a, b) => b.pct - a.pct)
|
||||
.slice(0, topN)
|
||||
return { labels: rows.map((r) => r.p), values: rows.map((r) => Number(r.pct.toFixed(1))) }
|
||||
}
|
||||
|
||||
/** MKT-RG-201 */
|
||||
export function buildProvinceComplaintRatePer1000(context: AnalysisContext, topN = 20) {
|
||||
const hp = buildHospitalProvinceMap(context)
|
||||
const num = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const p = hp.get(c.hospitalName) ?? '(未映射)'
|
||||
num.set(p, (num.get(p) ?? 0) + 1)
|
||||
}
|
||||
const den = new Map<string, number>()
|
||||
for (const a of context.admissions) {
|
||||
const p = a.province || '(空)'
|
||||
den.set(p, (den.get(p) ?? 0) + a.cyQty)
|
||||
}
|
||||
const provinces = [...new Set([...num.keys(), ...den.keys()])]
|
||||
const rows = provinces
|
||||
.map((p) => {
|
||||
const n = num.get(p) ?? 0
|
||||
const q = den.get(p) ?? 0
|
||||
return { province: p, n, q, rate: q > 0 ? (n / q) * 1000 : 0 }
|
||||
})
|
||||
.filter((r) => r.q > 0)
|
||||
.sort((a, b) => b.rate - a.rate)
|
||||
.slice(0, topN)
|
||||
return { labels: rows.map((r) => r.province), values: rows.map((r) => Number(r.rate.toFixed(3))) }
|
||||
}
|
||||
|
||||
/** MKT-RG-202 */
|
||||
export function buildDealerComplaintRatePer1000(context: AnalysisContext, topN = 20) {
|
||||
const hd = buildHospitalDealerMap(context)
|
||||
const num = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const d = hd.get(c.hospitalName) ?? '(未映射经销商)'
|
||||
num.set(d, (num.get(d) ?? 0) + 1)
|
||||
}
|
||||
const den = new Map<string, number>()
|
||||
for (const a of context.admissions) {
|
||||
const d = a.dealerName || '(空)'
|
||||
den.set(d, (den.get(d) ?? 0) + a.cyQty)
|
||||
}
|
||||
const dealers = [...new Set([...num.keys(), ...den.keys()])]
|
||||
const rows = dealers
|
||||
.map((d) => {
|
||||
const n = num.get(d) ?? 0
|
||||
const q = den.get(d) ?? 0
|
||||
return { dealer: d, n, q, rate: q > 0 ? (n / q) * 1000 : 0 }
|
||||
})
|
||||
.filter((r) => r.q > 0)
|
||||
.sort((a, b) => b.rate - a.rate)
|
||||
.slice(0, topN)
|
||||
return { labels: rows.map((r) => r.dealer), values: rows.map((r) => Number(r.rate.toFixed(3))) }
|
||||
}
|
||||
|
||||
/** MKT-RG-203:省级投诉条数(绝对量) */
|
||||
export function buildProvinceComplaintCounts(context: AnalysisContext, topN = 15) {
|
||||
const hp = buildHospitalProvinceMap(context)
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const p = hp.get(c.hospitalName) ?? '(未映射)'
|
||||
counter.set(p, (counter.get(p) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { AeRecord, AdmissionRecord, ComplaintRecord } from '../types/domain'
|
||||
import type { AnalysisContext } from '../types/analysis'
|
||||
|
||||
let cachedContext: AnalysisContext | null = null
|
||||
|
||||
/** 供页面刷新:下次重新 fetch mock JSON */
|
||||
export function invalidateAnalysisContextCache(): void {
|
||||
cachedContext = null
|
||||
}
|
||||
|
||||
async function loadJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${url}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function loadAnalysisContext(): Promise<AnalysisContext> {
|
||||
if (cachedContext) {
|
||||
return cachedContext
|
||||
}
|
||||
|
||||
const [ae, complaints, admissions] = await Promise.all([
|
||||
loadJson<AeRecord[]>('/mock/ae.json'),
|
||||
loadJson<ComplaintRecord[]>('/mock/complaint.json'),
|
||||
loadJson<AdmissionRecord[]>('/mock/admission.json'),
|
||||
])
|
||||
|
||||
cachedContext = { ae, complaints, admissions }
|
||||
return cachedContext
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import type { AnalysisContext } from '../types/analysis'
|
||||
import {
|
||||
buildAeMonthlyByOccurrence,
|
||||
buildComplianceMatching,
|
||||
buildProvinceAeCounts,
|
||||
buildSaeShareMonthly,
|
||||
} from './aggregate'
|
||||
import { buildInvestigationConclusionDist } from './quality-aggregate'
|
||||
import { buildOpWrongHospitalTop, buildProvinceComplaintRatePer1000 } from './marketing-aggregate'
|
||||
|
||||
function norm(q: string): string {
|
||||
return q.trim().toLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function monthCount(context: AnalysisContext, ym: string): number {
|
||||
const { months, values } = buildAeMonthlyByOccurrence(context)
|
||||
const i = months.indexOf(ym)
|
||||
return i >= 0 ? values[i] : 0
|
||||
}
|
||||
|
||||
export interface QaResult {
|
||||
matched: boolean
|
||||
/** preset | heuristic | none */
|
||||
source: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
/** 演示用:示例问题全文(下拉选项) */
|
||||
export const QA_SAMPLE_QUESTIONS: string[] = [
|
||||
'为什么11月的AE数量是10月的2倍还多?',
|
||||
'2025年3月和8月的AE报告条数各是多少?为什么差异大?',
|
||||
'哪个省份的AE报告条数最多?',
|
||||
'上报合规审计里,疑似漏报率是多少?',
|
||||
'操作不当投诉最多的医院是哪家?',
|
||||
'省级投诉率(每千件入院量)最高的省份是哪个?',
|
||||
'调查结论中占比最高的是哪一类?',
|
||||
'SAE占全部AE的比例大约多少?',
|
||||
'当前演示数据里有多少条投诉记录?',
|
||||
]
|
||||
|
||||
/**
|
||||
* Demo:根据问题关键词与当前 mock 聚合结果生成回答。
|
||||
* 正式上线可替换为调用大模型,并把 context 摘要与知识库片段作为 prompt。
|
||||
*/
|
||||
export function resolveQaAnswer(question: string, context: AnalysisContext): QaResult {
|
||||
const q = norm(question)
|
||||
if (!q) {
|
||||
return { matched: false, source: 'none', answer: '请输入问题后点击「获取答案」。' }
|
||||
}
|
||||
|
||||
// —— 与示例问题强相关 ——
|
||||
if (q.includes('11月') && q.includes('10月') && (q.includes('ae') || q.includes('不良'))) {
|
||||
const y2024_10 = monthCount(context, '2024-10')
|
||||
const y2024_11 = monthCount(context, '2024-11')
|
||||
const m3 = monthCount(context, '2025-03')
|
||||
const m8 = monthCount(context, '2025-08')
|
||||
const ratio = y2024_10 > 0 ? (y2024_11 / y2024_10).toFixed(2) : '—'
|
||||
return {
|
||||
matched: true,
|
||||
source: 'preset',
|
||||
answer: [
|
||||
'【数据口径】AE 条数按《合规方向分析》约定:统计单元为「报告条数」,时间轴为「发生日期」所在公历月。',
|
||||
`【您问的年份对比】当前数据集中 **2024-10** 为 **${y2024_10}** 条,**2024-11** 为 **${y2024_11}** 条,倍数约为 **${ratio} 倍**,并未达到「2 倍还多」。`,
|
||||
`【同数据集内反差更大的例子】**2025-03** 为 **${m3}** 条,**2025-08** 为 **${m8}** 条,相对落差更明显,通常与当月上报批次、产品结构与偶发聚集事件有关;正式分析建议按产品/省/注册证下钻并排除重复报告规则后再结论。`,
|
||||
'【Demo 说明】上线后由 AI 结合上述聚合与业务知识库生成自然语言解释,并可自动附带下钻图表链接。',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
if ((q.includes('2025') && q.includes('3月') && q.includes('8月')) || (q.includes('三月') && q.includes('八月'))) {
|
||||
const a = monthCount(context, '2025-03')
|
||||
const b = monthCount(context, '2025-08')
|
||||
return {
|
||||
matched: true,
|
||||
source: 'preset',
|
||||
answer: `按发生月聚合:**2025-03** 为 **${a}** 条 AE,**2025-08** 为 **${b}** 条。差异主要来自样本在月间的随机分布及产品结构;若需因果解释,应叠加产品、省份、是否 SAE 等切片(Demo 由规则直接读数)。`,
|
||||
}
|
||||
}
|
||||
|
||||
if ((q.includes('省份') || q.includes('省')) && (q.includes('ae') || q.includes('不良'))) {
|
||||
const p = buildProvinceAeCounts(context)
|
||||
return {
|
||||
matched: true,
|
||||
source: 'data',
|
||||
answer: `AE 按「发生日期」全量统计下,报告条数最多的省份为 **${p.labels[0]}**(**${p.values[0]}** 条)。第二名:${p.labels[1] ?? '—'}(${p.values[1] ?? '—'})。`,
|
||||
}
|
||||
}
|
||||
|
||||
if (q.includes('漏报') || (q.includes('合规') && q.includes('审计'))) {
|
||||
const m = buildComplianceMatching(context)
|
||||
return {
|
||||
matched: true,
|
||||
source: 'data',
|
||||
answer: `在「投诉标记为不良事件」的基准集合(共 **${m.flaggedTotal}** 条)上,疑似漏报 **${m.missed}** 条,**漏报率 ${m.leakRate}%**(匹配规则:医院+产品+型号一致且 AE 发生日与 C3 登记日相差≤30 天,详见合规审计页)。`,
|
||||
}
|
||||
}
|
||||
|
||||
if (q.includes('操作不当') && (q.includes('医院') || q.includes('哪家'))) {
|
||||
const h = buildOpWrongHospitalTop(context, 5)
|
||||
return {
|
||||
matched: true,
|
||||
source: 'data',
|
||||
answer: `调查结论为「操作不当」的投诉中,条数最多的医院是 **${h.labels[0]}**(**${h.values[0]}** 条)。可与营销「操作不当与培训」页图表交叉验证。`,
|
||||
}
|
||||
}
|
||||
|
||||
if (q.includes('投诉率') && (q.includes('省') || q.includes('省级'))) {
|
||||
const r = buildProvinceComplaintRatePer1000(context, 5)
|
||||
return {
|
||||
matched: true,
|
||||
source: 'data',
|
||||
answer: `按《营销方向分析》MKT-RG-201 口径(投诉经医院映射到省,分母为同省入院 CY Qty 汇总,率=条数/Qty×1000),**${r.labels[0]}** 的投诉率最高,为 **${r.values[0]}**(件/千件)。`,
|
||||
}
|
||||
}
|
||||
|
||||
if (q.includes('调查结论') || q.includes('结论占比')) {
|
||||
const d = buildInvestigationConclusionDist(context)
|
||||
const top = d[0]
|
||||
return {
|
||||
matched: true,
|
||||
source: 'data',
|
||||
answer: top
|
||||
? `当前投诉样本中,调查结论占比最高的是 **「${top[0]}」**(**${top[1]}** 条)。完整分布见质量方向「调查与赔付关系」页饼图。`
|
||||
: '暂无调查结论数据。',
|
||||
}
|
||||
}
|
||||
|
||||
if ((q.includes('sae') || q.includes('严重')) && (q.includes('比例') || q.includes('占比'))) {
|
||||
const s = buildSaeShareMonthly(context)
|
||||
const avg = (s.shares.reduce((a, b) => a + b, 0) / s.shares.length).toFixed(1)
|
||||
return {
|
||||
matched: true,
|
||||
source: 'data',
|
||||
answer: `SAE 占全部 AE 的月度比例(按发生月)在样本期内平均值约 **${avg}%**;具体曲线见合规 PSUR 页「SAE 占比」图。`,
|
||||
}
|
||||
}
|
||||
|
||||
if (q.includes('投诉') && (q.includes('多少条') || q.includes('几条') || q.includes('几条记录') || q.includes('记录'))) {
|
||||
const n = context.complaints.length
|
||||
return {
|
||||
matched: true,
|
||||
source: 'data',
|
||||
answer: `当前已加载的投诉(mock)共 **${n}** 条,与入院量、不良事件表一并用于本 Demo 各页图表与问答。`,
|
||||
}
|
||||
}
|
||||
|
||||
// 模糊:仅「投诉最多医院」
|
||||
if (q.includes('投诉') && q.includes('医院') && q.includes('最多')) {
|
||||
const hp = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
hp.set(c.hospitalName, (hp.get(c.hospitalName) ?? 0) + 1)
|
||||
}
|
||||
const top = [...hp.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||
return {
|
||||
matched: true,
|
||||
source: 'data',
|
||||
answer: `全部投诉(不区分调查结论)条数最多的医院是 **${top[0]}**(**${top[1]}** 条)。`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matched: false,
|
||||
source: 'none',
|
||||
answer: [
|
||||
'【Demo】未命中内置问题模板。请尝试上方「示例问题」一键填入,或提问中包含以下主题关键词:',
|
||||
'「10月」「11月」「AE」;「省份」「AE」;「漏报」「合规」;「操作不当」「医院」;「投诉率」「省」;「调查结论」等。',
|
||||
'【上线后】由 AI 读取数仓宽表、本项目的指标口径文档(合规/质量/营销/综合分析)及权限内历史结论,生成可追溯回答。',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
import type { AnalysisContext } from '../types/analysis'
|
||||
|
||||
const monthKey = (dateString: string): string => dateString.slice(0, 7)
|
||||
|
||||
/** 医院→省份:按入院量行频次取众数 */
|
||||
export function buildHospitalProvinceMap(context: AnalysisContext): Map<string, string> {
|
||||
const count = new Map<string, Map<string, number>>()
|
||||
for (const a of context.admissions) {
|
||||
if (!count.has(a.hospitalName)) count.set(a.hospitalName, new Map())
|
||||
const inner = count.get(a.hospitalName)!
|
||||
inner.set(a.province, (inner.get(a.province) ?? 0) + 1)
|
||||
}
|
||||
const m = new Map<string, string>()
|
||||
for (const [h, pmap] of count) {
|
||||
const top = [...pmap.entries()].sort((x, y) => y[1] - x[1])[0]
|
||||
if (top) m.set(h, top[0])
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/** QLT-BTH-101:批号投诉条数 Top */
|
||||
export function buildComplaintBatchTop(context: AnalysisContext, topN = 8) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const label = c.batchNo?.trim() ? c.batchNo : '(未填批号)'
|
||||
counter.set(label, (counter.get(label) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
||||
/** 投诉按登记月 */
|
||||
export function buildComplaintMonthly(context: AnalysisContext) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const k = monthKey(c.registerDate)
|
||||
counter.set(k, (counter.get(k) ?? 0) + 1)
|
||||
}
|
||||
const months = [...counter.keys()].sort()
|
||||
return { months, values: months.map((m) => counter.get(m) ?? 0) }
|
||||
}
|
||||
|
||||
/** QLT-CNC-301:医院投诉条数 Top */
|
||||
export function buildTopHospitalsComplaints(context: AnalysisContext, topN = 8) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
counter.set(c.hospitalName, (counter.get(c.hospitalName) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
||||
/** QLT-CNC-302:产品投诉条数 Top */
|
||||
export function buildTopProductsComplaints(context: AnalysisContext, topN = 8) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
counter.set(c.productName, (counter.get(c.productName) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
||||
/** QLT-CNC-303:故障类型 Top */
|
||||
export function buildTopFaultTypes(context: AnalysisContext, topN = 10) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const k = c.faultType || '(空)'
|
||||
counter.set(k, (counter.get(k) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
||||
/** QLT-INV-501:调查结论分布 */
|
||||
export function buildInvestigationConclusionDist(context: AnalysisContext) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const k = c.conclusion || '(空)'
|
||||
counter.set(k, (counter.get(k) ?? 0) + 1)
|
||||
}
|
||||
return [...counter.entries()].sort((a, b) => b[1] - a[1])
|
||||
}
|
||||
|
||||
/** QLT-CST-601:赔付结论分布 */
|
||||
export function buildCompensationDist(context: AnalysisContext) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const k = c.compensation || '(空)'
|
||||
counter.set(k, (counter.get(k) ?? 0) + 1)
|
||||
}
|
||||
return [...counter.entries()].sort((a, b) => b[1] - a[1])
|
||||
}
|
||||
|
||||
/** QLT-INV-502:产品缺陷成立 → 产品×故障 */
|
||||
export function buildDefectConfirmedProductFault(context: AnalysisContext) {
|
||||
const counter = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
if (c.conclusion !== '产品缺陷成立') continue
|
||||
const key = `${c.productName} / ${c.faultType}`
|
||||
counter.set(key, (counter.get(key) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||||
}
|
||||
|
||||
/** QLT-TRN-703 演示:按登记月份 H1(1–6月) vs H2(7–12月) 产品投诉增速 ≥20% */
|
||||
export function buildProductComplaintHalfYearGrowth(context: AnalysisContext) {
|
||||
const h1 = new Map<string, number>()
|
||||
const h2 = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const mo = Number.parseInt(c.registerDate.slice(5, 7), 10)
|
||||
const p = c.productName
|
||||
if (mo <= 6) h1.set(p, (h1.get(p) ?? 0) + 1)
|
||||
else h2.set(p, (h2.get(p) ?? 0) + 1)
|
||||
}
|
||||
const products = [...new Set([...h1.keys(), ...h2.keys()])]
|
||||
const rows: { product: string; firstHalf: number; secondHalf: number; growthPct: number | null }[] = []
|
||||
for (const p of products) {
|
||||
const a = h1.get(p) ?? 0
|
||||
const b = h2.get(p) ?? 0
|
||||
let growthPct: number | null = null
|
||||
if (a === 0 && b > 0) growthPct = null
|
||||
else if (a > 0) growthPct = Number((((b - a) / a) * 100).toFixed(1))
|
||||
if (a > 0 && (b - a) / a >= 0.2) rows.push({ product: p, firstHalf: a, secondHalf: b, growthPct })
|
||||
if (a === 0 && b >= 2) rows.push({ product: p, firstHalf: a, secondHalf: b, growthPct: null })
|
||||
}
|
||||
return rows.sort((x, y) => y.secondHalf - x.secondHalf)
|
||||
}
|
||||
|
||||
/** QLT-TRN-704 演示:省维度 H1 vs H2 */
|
||||
export function buildProvinceComplaintHalfYearGrowth(context: AnalysisContext) {
|
||||
const hp = buildHospitalProvinceMap(context)
|
||||
const h1 = new Map<string, number>()
|
||||
const h2 = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const prov = hp.get(c.hospitalName) ?? '(未映射省)'
|
||||
const mo = Number.parseInt(c.registerDate.slice(5, 7), 10)
|
||||
if (mo <= 6) h1.set(prov, (h1.get(prov) ?? 0) + 1)
|
||||
else h2.set(prov, (h2.get(prov) ?? 0) + 1)
|
||||
}
|
||||
const provinces = [...new Set([...h1.keys(), ...h2.keys()])]
|
||||
const rows: { province: string; firstHalf: number; secondHalf: number; hit: boolean }[] = []
|
||||
for (const prov of provinces) {
|
||||
const a = h1.get(prov) ?? 0
|
||||
const b = h2.get(prov) ?? 0
|
||||
const hit = a > 0 && (b - a) / a >= 0.2
|
||||
rows.push({ province: prov, firstHalf: a, secondHalf: b, hit })
|
||||
}
|
||||
return rows.sort((x, y) => y.secondHalf - x.secondHalf)
|
||||
}
|
||||
|
||||
/** QLT-RPT-802:关闭周期(天)= 关闭日期 − C3登记日期 */
|
||||
export function buildComplaintCloseCycleDays(context: AnalysisContext): number[] {
|
||||
return context.complaints
|
||||
.map((c) => {
|
||||
const ms = Date.parse(`${c.closeDate}T00:00:00`) - Date.parse(`${c.registerDate}T00:00:00`)
|
||||
return Math.round(ms / 86400000)
|
||||
})
|
||||
.filter((d) => Number.isFinite(d) && d >= 0)
|
||||
}
|
||||
|
||||
/** QLT-AE-1003:医院 AE 条数 vs 入院量 cyQty 汇总(AE 时间轴=发生日期) */
|
||||
export function buildHospitalAeVsAdmissionScatter(context: AnalysisContext) {
|
||||
const hospitals = new Set<string>()
|
||||
context.ae.forEach((a) => hospitals.add(a.unitName))
|
||||
context.admissions.forEach((a) => hospitals.add(a.hospitalName))
|
||||
const points: { hospital: string; aeCount: number; cyQty: number }[] = []
|
||||
for (const h of hospitals) {
|
||||
const aeCount = context.ae.filter((a) => a.unitName === h).length
|
||||
const cyQty = context.admissions.filter((a) => a.hospitalName === h).reduce((s, a) => s + a.cyQty, 0)
|
||||
if (cyQty > 0) points.push({ hospital: h, aeCount, cyQty })
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/** QLT-SAL-901:医院+产品 投诉率 = 投诉条数 / cyQty 汇总 */
|
||||
export function buildHospitalProductComplaintRate(context: AnalysisContext) {
|
||||
const cMap = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
const k = `${c.hospitalName}\t${c.productName}`
|
||||
cMap.set(k, (cMap.get(k) ?? 0) + 1)
|
||||
}
|
||||
const qMap = new Map<string, number>()
|
||||
for (const a of context.admissions) {
|
||||
const k = `${a.hospitalName}\t${a.productName}`
|
||||
qMap.set(k, (qMap.get(k) ?? 0) + a.cyQty)
|
||||
}
|
||||
const rows: { hospital: string; product: string; complaints: number; cyQty: number; ratePer1000: number }[] = []
|
||||
for (const [k, comp] of cMap) {
|
||||
const [hospital, product] = k.split('\t')
|
||||
const qty = qMap.get(k) ?? 0
|
||||
const ratePer1000 = qty > 0 ? Number(((comp / qty) * 1000).toFixed(3)) : 0
|
||||
rows.push({ hospital, product, complaints: comp, cyQty: qty, ratePer1000 })
|
||||
}
|
||||
return rows.sort((a, b) => b.ratePer1000 - a.ratePer1000)
|
||||
}
|
||||
|
||||
/** QLT-AE-1001:按产品维度的升级率(是否不良事件=是) */
|
||||
export function buildIsAeRateByProduct(context: AnalysisContext) {
|
||||
const tot = new Map<string, number>()
|
||||
const yes = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
tot.set(c.productName, (tot.get(c.productName) ?? 0) + 1)
|
||||
if (c.isAe) yes.set(c.productName, (yes.get(c.productName) ?? 0) + 1)
|
||||
}
|
||||
const products = [...tot.keys()].sort()
|
||||
return products.map((p) => ({
|
||||
product: p,
|
||||
ratePct: Number((((yes.get(p) ?? 0) / (tot.get(p) ?? 1)) * 100).toFixed(1)),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 故障类型 × 登记月 计数(热力图源数据) */
|
||||
export function buildFaultByRegisterMonthMatrix(context: AnalysisContext) {
|
||||
const faults = [...new Set(context.complaints.map((c) => c.faultType || '(空)'))].sort()
|
||||
const months = [...new Set(context.complaints.map((c) => monthKey(c.registerDate)))].sort()
|
||||
const idxF = new Map(faults.map((v, i) => [v, i]))
|
||||
const idxM = new Map(months.map((v, i) => [v, i]))
|
||||
const matrix = faults.map(() => months.map(() => 0))
|
||||
const cells: { fault: string; month: string; value: number }[] = []
|
||||
for (const c of context.complaints) {
|
||||
const f = c.faultType || '(空)'
|
||||
const m = monthKey(c.registerDate)
|
||||
const i = idxF.get(f) ?? 0
|
||||
const j = idxM.get(m) ?? 0
|
||||
matrix[i][j] += 1
|
||||
}
|
||||
for (let i = 0; i < faults.length; i++) {
|
||||
for (let j = 0; j < months.length; j++) {
|
||||
if (matrix[i][j] > 0) cells.push({ fault: faults[i], month: months[j], value: matrix[i][j] })
|
||||
}
|
||||
}
|
||||
return { faults, months, cells, matrix }
|
||||
}
|
||||
|
||||
/** 医院维度:投诉条数 vs 入院 cyQty 汇总(用于销售/入院关联散点) */
|
||||
export function buildHospitalComplaintVsAdmissionQty(context: AnalysisContext) {
|
||||
const cMap = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
cMap.set(c.hospitalName, (cMap.get(c.hospitalName) ?? 0) + 1)
|
||||
}
|
||||
const qMap = new Map<string, number>()
|
||||
for (const a of context.admissions) {
|
||||
qMap.set(a.hospitalName, (qMap.get(a.hospitalName) ?? 0) + a.cyQty)
|
||||
}
|
||||
const hospitals = [...new Set([...cMap.keys(), ...qMap.keys()])]
|
||||
return hospitals.map((h) => ({
|
||||
hospital: h,
|
||||
complaints: cMap.get(h) ?? 0,
|
||||
cyQty: qMap.get(h) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/** QLT-BTH-104 简化:批号在投诉侧与 AE 侧的条数对照(同批号字符串) */
|
||||
export function buildBatchComplaintVsAeCounts(context: AnalysisContext) {
|
||||
const cMap = new Map<string, number>()
|
||||
for (const c of context.complaints) {
|
||||
if (!c.batchNo?.trim() || c.batchNo === 'NA') continue
|
||||
cMap.set(c.batchNo, (cMap.get(c.batchNo) ?? 0) + 1)
|
||||
}
|
||||
const aMap = new Map<string, number>()
|
||||
for (const a of context.ae) {
|
||||
if (!a.batchNo?.trim()) continue
|
||||
aMap.set(a.batchNo, (aMap.get(a.batchNo) ?? 0) + 1)
|
||||
}
|
||||
const batches = [...new Set([...cMap.keys(), ...aMap.keys()])].sort()
|
||||
return batches.map((b) => ({
|
||||
batchNo: b,
|
||||
complaints: cMap.get(b) ?? 0,
|
||||
aeReports: aMap.get(b) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './assets/styles/index.scss'
|
||||
import './assets/styles/common.scss'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './stores/useAuth'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
useAuthStore(pinia).hydrateFromStorage()
|
||||
app.use(router).use(ElementPlus).mount('#app')
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AppLayout from '../layouts/AppLayout.vue'
|
||||
import AnalysisView from '../views/AnalysisView.vue'
|
||||
import ComprehensiveInsightsView from '../views/ComprehensiveInsightsView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import QaView from '../views/QaView.vue'
|
||||
import { allLeaves } from '../config/menu'
|
||||
import { useAuthStore } from '../stores/useAuth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: { public: true, title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true },
|
||||
redirect: '/qa/chat',
|
||||
children: allLeaves.map((leaf) => ({
|
||||
path: leaf.path.replace('/', ''),
|
||||
component:
|
||||
leaf.view === 'comprehensive' ? ComprehensiveInsightsView : leaf.view === 'qa' ? QaView : AnalysisView,
|
||||
meta: {
|
||||
title: leaf.title,
|
||||
analysisKey: leaf.analysisKey,
|
||||
strategyRef: leaf.strategyRef,
|
||||
pillar: leaf.pillar,
|
||||
requiresAuth: true,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.token) auth.hydrateFromStorage()
|
||||
|
||||
if (to.meta.public) {
|
||||
if (auth.isAuthenticated && to.name === 'login') {
|
||||
return { path: '/qa/chat', replace: true }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
const redirect = to.path !== '/login' && to.fullPath ? to.fullPath : undefined
|
||||
return redirect ? { path: '/login', query: { redirect } } : { path: '/login' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { defineStore } from 'pinia'
|
||||
|
||||
const STORAGE_KEY = 'bdae-demo-session'
|
||||
|
||||
/** 演示账号:用户名与密码均为 Demo(区分大小写) */
|
||||
export const DEMO_USERNAME = 'Demo'
|
||||
export const DEMO_PASSWORD = 'Demo'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
/** 非空即视为已登录(演示用固定令牌) */
|
||||
token: '' as string,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (s) => Boolean(s.token),
|
||||
},
|
||||
actions: {
|
||||
/** 从 sessionStorage 恢复(刷新页面保持登录) */
|
||||
hydrateFromStorage() {
|
||||
const v = sessionStorage.getItem(STORAGE_KEY)
|
||||
if (v) this.token = v
|
||||
},
|
||||
login(username: string, password: string): boolean {
|
||||
if (username === DEMO_USERNAME && password === DEMO_PASSWORD) {
|
||||
this.token = 'demo'
|
||||
sessionStorage.setItem(STORAGE_KEY, 'demo')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useGlobalFilters = defineStore('global-filters', {
|
||||
state: () => ({
|
||||
yearRange: [2024, 2026] as [number, number],
|
||||
businessUnit: '全部',
|
||||
keyword: '',
|
||||
}),
|
||||
actions: {
|
||||
reset() {
|
||||
this.yearRange = [2024, 2026]
|
||||
this.businessUnit = '全部'
|
||||
this.keyword = ''
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* body / #app 基础样式见 assets/styles/index.scss */
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { EChartsOption } from 'echarts'
|
||||
import type { AeRecord, AdmissionRecord, ComplaintRecord } from './domain'
|
||||
|
||||
export type PillarType = 'goal' | 'monitor' | 'diagnose' | 'act'
|
||||
|
||||
export interface AnalysisContext {
|
||||
ae: AeRecord[]
|
||||
complaints: ComplaintRecord[]
|
||||
admissions: AdmissionRecord[]
|
||||
}
|
||||
|
||||
/** 图表说明:取数范围 + 分析逻辑(质量方向等页面统一展示) */
|
||||
export interface ChartExplain {
|
||||
dataScope: string
|
||||
analysisLogic: string
|
||||
}
|
||||
|
||||
export interface ChartDefinition {
|
||||
id: string
|
||||
title: string
|
||||
/** 一句话摘要,可放在卡片标题行右侧 */
|
||||
description?: string
|
||||
/** 取数范围与分析逻辑(与《质量方向分析》等指标文档对齐) */
|
||||
chartExplain?: ChartExplain
|
||||
placeholder?: boolean
|
||||
optionBuilder?: (context: AnalysisContext) => EChartsOption
|
||||
}
|
||||
|
||||
export interface AnalysisSection {
|
||||
id: string
|
||||
title: string
|
||||
strategyRefs: string[]
|
||||
charts: ChartDefinition[]
|
||||
}
|
||||
|
||||
export interface AnalysisPageDefinition {
|
||||
key: string
|
||||
title: string
|
||||
pillar: PillarType
|
||||
strategyRef: string
|
||||
subtitle: string
|
||||
sections: AnalysisSection[]
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/** 不良事件报告(统计单元:报告条数,见合规方向分析 §1.4) */
|
||||
export interface AeRecord {
|
||||
reportCode: string
|
||||
/** 发生日期 — AE 主时间轴 */
|
||||
occurrenceDate: string
|
||||
/** 登记日期 — 获知日代理 */
|
||||
registrationDate: string
|
||||
unitName: string
|
||||
businessUnit: string
|
||||
productName: string
|
||||
registrationNo: string
|
||||
model: string
|
||||
batchNo: string
|
||||
injuryExpression: string
|
||||
deviceFailure: string
|
||||
/** 审核日期 — 演示中作为 T_end(报告闭环终点) */
|
||||
reviewDate: string
|
||||
province: string
|
||||
/** SAE 严重标准(任一为 true 即计入 SAE 子集) */
|
||||
saeDeath?: boolean
|
||||
saeLifeThreatening?: boolean
|
||||
saeDisability?: boolean
|
||||
saeHospitalization?: boolean
|
||||
}
|
||||
|
||||
export interface ComplaintRecord {
|
||||
c3Code: string
|
||||
model: string
|
||||
batchNo: string
|
||||
registrationNo: string
|
||||
productName: string
|
||||
hospitalName: string
|
||||
faultType: string
|
||||
registerDate: string
|
||||
isAe: boolean
|
||||
conclusion: string
|
||||
compensation: string
|
||||
closeDate: string
|
||||
}
|
||||
|
||||
export interface AdmissionRecord {
|
||||
year: number
|
||||
month: number
|
||||
hospitalName: string
|
||||
dealerName: string
|
||||
province: string
|
||||
bu: string
|
||||
productName: string
|
||||
cyQty: number
|
||||
lyQty: number
|
||||
growthQtyPct: number
|
||||
cyAmt: number
|
||||
growthAmtPct: number
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
import { DataBoard } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import ChartCard from '@/components/charts/ChartCard.vue'
|
||||
import PageContainer from '@/components/PageContainer.vue'
|
||||
import { useAnalysisContext } from '@/composables/useAnalysisContext'
|
||||
import { getAnalysisDefinition } from '@/config/analysis-config'
|
||||
import type { AnalysisContext } from '@/types/analysis'
|
||||
|
||||
const route = useRoute()
|
||||
const { context, loading, reload } = useAnalysisContext()
|
||||
|
||||
const definition = computed(() => getAnalysisDefinition(route.meta.analysisKey as string))
|
||||
|
||||
function resolveOption(builder?: (ctx: AnalysisContext) => EChartsOption) {
|
||||
if (!builder || !context.value) {
|
||||
return undefined
|
||||
}
|
||||
return builder(context.value)
|
||||
}
|
||||
|
||||
const pillarText: Record<string, string> = {
|
||||
goal: '目标层',
|
||||
monitor: '监测层',
|
||||
diagnose: '诊断层',
|
||||
act: '决策层',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer v-if="definition" @refresh="reload">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
{{ definition.title }}
|
||||
</h2>
|
||||
<p class="page-description">{{ definition.subtitle }}</p>
|
||||
</div>
|
||||
<div class="header-actions meta-tags">
|
||||
<el-tag effect="plain">{{ pillarText[definition.pillar] }}</el-tag>
|
||||
<el-tag type="info" effect="plain">策略锚点 {{ definition.strategyRef }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-for="section in definition.sections" :key="section.id" class="main-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="section-head">
|
||||
<span class="section-title">{{ section.title }}</span>
|
||||
<el-tag type="success" effect="plain">{{ section.strategyRefs.join(' / ') }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="module-content">
|
||||
<el-row :gutter="16">
|
||||
<el-col v-for="chart in section.charts" :key="chart.id" :xs="24" :sm="24" :md="12">
|
||||
<ChartCard
|
||||
:title="chart.title"
|
||||
:description="chart.description"
|
||||
:chart-explain="chart.chartExplain"
|
||||
:option="resolveOption(chart.optionBuilder)"
|
||||
:placeholder="chart.placeholder"
|
||||
:loading="loading"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-card>
|
||||
</PageContainer>
|
||||
|
||||
<PageContainer v-else :show-refresh="false">
|
||||
<el-empty description="页面配置未找到"></el-empty>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, var(--el-text-color-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<script setup lang="ts">
|
||||
import { DataAnalysis } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
import PageContainer from '@/components/PageContainer.vue'
|
||||
import { useAnalysisContext } from '@/composables/useAnalysisContext'
|
||||
import { buildComprehensiveInsightRows, groupInsightsByDimension, type InsightDimension } from '@/lib/comprehensive-insights'
|
||||
|
||||
const { context, loading, reload } = useAnalysisContext()
|
||||
|
||||
const rows = computed(() => (context.value ? buildComprehensiveInsightRows(context.value) : []))
|
||||
|
||||
const grouped = computed(() => (context.value ? groupInsightsByDimension(rows.value) : null))
|
||||
|
||||
const dimensionOrder: InsightDimension[] = ['合规', '质量', '营销', '事件实质']
|
||||
|
||||
const docHint =
|
||||
'结论由当前 mock 数据与规则自动生成,用于联席复盘;正式环境可接规则引擎与人工批注。口径详见《综合分析》与各维度落地文。'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer @refresh="reload">
|
||||
<div v-loading="loading" class="comp-inner">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
综合分析
|
||||
</h2>
|
||||
<p class="page-description">
|
||||
汇总合规、质量、营销、事件实质各页已实现图表的<strong>读数结论</strong>(列表)。{{ docHint }}
|
||||
</p>
|
||||
<p class="comp-meta">落地说明文档:仓库内《分析策略/综合分析.md》。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="comp-alert"
|
||||
title="与单图说明的关系"
|
||||
description="各图卡片内的「取数范围 / 分析逻辑」描述统计口径;本页列表给出基于同一聚合结果的要点摘要,二者配合使用。"
|
||||
/>
|
||||
|
||||
<template v-if="grouped">
|
||||
<section v-for="dim in dimensionOrder" :key="dim" class="comp-section">
|
||||
<h3 class="comp-dim-title">{{ dim }}维度</h3>
|
||||
<el-empty v-if="!grouped[dim].length" :description="`${dim} 下暂无已接图表`" />
|
||||
<el-card v-for="item in grouped[dim]" :key="item.chartId" class="main-card comp-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="comp-card-head">
|
||||
<span class="comp-page-tag">{{ item.pageTitle }}</span>
|
||||
<span class="comp-chart-title">{{ item.chartTitle }}</span>
|
||||
<el-tag size="small" type="info" effect="plain">{{ item.chartId }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<ul class="comp-bullets">
|
||||
<li v-for="(b, i) in item.bullets" :key="i">{{ b }}</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.comp-inner {
|
||||
max-width: 1100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.comp-meta {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-placeholder, var(--el-text-color-placeholder));
|
||||
}
|
||||
|
||||
.comp-alert {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.comp-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.comp-dim-title {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 17px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.comp-card {
|
||||
border-radius: var(--border-radius-md, 10px);
|
||||
}
|
||||
|
||||
.comp-card-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comp-page-tag {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, var(--el-text-color-secondary));
|
||||
}
|
||||
|
||||
.comp-chart-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.comp-bullets {
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
line-height: 1.65;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<script setup lang="ts">
|
||||
import { Key } from '@element-plus/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageContainer from '@/components/PageContainer.vue'
|
||||
import { useAuthStore, DEMO_USERNAME, DEMO_PASSWORD } from '@/stores/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
function submit() {
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const ok = auth.login(username.value.trim(), password.value)
|
||||
if (!ok) {
|
||||
errorMsg.value = '用户名或密码错误。演示环境请使用:Demo / Demo'
|
||||
return
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/qa/chat'
|
||||
router.replace(redirect || '/qa/chat')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer :show-refresh="false" class="login-page">
|
||||
<el-card class="login-card main-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="login-head">
|
||||
<h1 class="login-title">贝朗医疗数据分析</h1>
|
||||
<p class="login-sub">请登录以继续使用(演示环境)</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="login-tip"
|
||||
title="演示账号"
|
||||
:description="`用户名:${DEMO_USERNAME},密码:${DEMO_PASSWORD}(区分大小写)`"
|
||||
/>
|
||||
|
||||
<el-form class="login-form" label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="username" autocomplete="username" placeholder="Demo" @keyup.enter="submit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Demo"
|
||||
show-password
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="errorMsg">
|
||||
<el-alert type="error" :title="errorMsg" :closable="false" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" class="login-btn" :loading="loading" @click="submit">
|
||||
<el-icon class="el-icon--left"><Key /></el-icon>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-lg, 24px);
|
||||
background: linear-gradient(160deg, #0f172a 0%, #1e3a5f 45%, #334155 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-sub {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.login-tip {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
<script setup lang="ts">
|
||||
import { ChatDotRound } from '@element-plus/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
import PageContainer from '@/components/PageContainer.vue'
|
||||
import { useAnalysisContext } from '@/composables/useAnalysisContext'
|
||||
import { resolveQaAnswer, QA_SAMPLE_QUESTIONS } from '@/lib/qa-resolve'
|
||||
|
||||
const { context, loading, reload } = useAnalysisContext()
|
||||
const question = ref('')
|
||||
const answer = ref('')
|
||||
const answered = ref(false)
|
||||
const lastSource = ref('')
|
||||
const samplePicker = ref('')
|
||||
let applyingSample = false
|
||||
|
||||
function submitAnswer() {
|
||||
if (!context.value) return
|
||||
const r = resolveQaAnswer(question.value, context.value)
|
||||
answer.value = r.answer
|
||||
lastSource.value = r.source
|
||||
answered.value = true
|
||||
}
|
||||
|
||||
function onSampleChange(val: string) {
|
||||
if (!val) {
|
||||
answer.value = ''
|
||||
lastSource.value = ''
|
||||
answered.value = false
|
||||
return
|
||||
}
|
||||
applyingSample = true
|
||||
question.value = val
|
||||
applyingSample = false
|
||||
submitAnswer()
|
||||
}
|
||||
|
||||
function onQuestionManualInput() {
|
||||
if (applyingSample) return
|
||||
samplePicker.value = ''
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
question.value = ''
|
||||
answer.value = ''
|
||||
answered.value = false
|
||||
lastSource.value = ''
|
||||
samplePicker.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer @refresh="reload">
|
||||
<div v-loading="loading" class="qa-inner">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
智能问答
|
||||
</h2>
|
||||
<p class="page-description">
|
||||
左侧可<strong>输入问题</strong>并点击「获取答案」,或通过<strong>示例问题</strong>下拉框选择后,右侧即时展示回答。本页为
|
||||
<strong>Demo</strong>:答案由内置规则根据当前加载的 mock 数据即时计算;正式上线将改为调用 AI,并结合数仓与项目内指标口径文档作答。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qa-split">
|
||||
<el-card class="main-card qa-card qa-left" shadow="never">
|
||||
<div class="qa-field">
|
||||
<div class="qa-label">示例问题</div>
|
||||
<el-select
|
||||
v-model="samplePicker"
|
||||
class="qa-sample-select"
|
||||
placeholder="请选择示例问题…"
|
||||
filterable
|
||||
clearable
|
||||
:disabled="loading"
|
||||
@change="onSampleChange"
|
||||
>
|
||||
<el-option v-for="(s, i) in QA_SAMPLE_QUESTIONS" :key="i" :label="s" :value="s" />
|
||||
</el-select>
|
||||
<p class="qa-hint">选择后右侧立即显示答案;可清空后换一题。</p>
|
||||
</div>
|
||||
|
||||
<div class="qa-field qa-field-divider">
|
||||
<div class="qa-label">自定义提问</div>
|
||||
<el-input
|
||||
v-model="question"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="例如:为什么11月的AE数量是10月的2倍还多?"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
@input="onQuestionManualInput"
|
||||
/>
|
||||
</div>
|
||||
<div class="qa-actions">
|
||||
<el-button type="primary" :disabled="!question.trim() || loading" @click="submitAnswer">获取答案</el-button>
|
||||
<el-button text :disabled="loading" @click="clearAll">清空</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="main-card qa-card qa-answer-card qa-right" shadow="never">
|
||||
<template #header>
|
||||
<div class="qa-answer-head">
|
||||
<span>回答</span>
|
||||
<el-tag v-if="answered && lastSource" size="small" type="info" effect="plain">来源:{{ lastSource }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<pre v-if="answered" class="qa-answer-body">{{ answer }}</pre>
|
||||
<p v-else class="qa-answer-empty">请从左侧选择示例问题,或输入问题后点击「获取答案」。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qa-inner {
|
||||
max-width: 1120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.qa-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.qa-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.qa-sample-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qa-field-divider {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.qa-card {
|
||||
border-radius: var(--border-radius-md, 12px);
|
||||
}
|
||||
|
||||
.qa-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qa-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qa-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qa-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.qa-answer-card {
|
||||
border-left: 4px solid var(--customer-board-color, var(--el-color-primary));
|
||||
}
|
||||
|
||||
.qa-answer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qa-answer-body {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.qa-answer-empty {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.qa-left,
|
||||
.qa-right {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.qa-right :deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
"""
|
||||
将「数据」目录下三份 Excel(与 #数据与表结构.md 一致)转换为 analytics-demo-web/public/mock/*.json。
|
||||
派生字段(源表无列时):
|
||||
- AE occurrenceDate / registrationDate:由审核日回溯生成,保证 occurrence < registration < review(演示用)。
|
||||
- AE province:由入院量表按医院名称众数映射。
|
||||
- AE SAE 四布尔:由「伤害表现」关键词启发式判定。
|
||||
- 入院 productName:将 MaterialDesc 映射到 AE∪投诉 产品名称集合(最长前缀包含匹配),便于与投诉率联表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA_DIR = ROOT / "数据"
|
||||
OUT_DIR = ROOT / "analytics-demo-web" / "public" / "mock"
|
||||
|
||||
|
||||
def _d(v) -> pd.Timestamp | pd.NaTType:
|
||||
if pd.isna(v):
|
||||
return pd.NaT
|
||||
return pd.to_datetime(v)
|
||||
|
||||
|
||||
def _fmt(d: pd.Timestamp | pd.NaTType) -> str:
|
||||
if pd.isna(d):
|
||||
return ""
|
||||
return d.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _stable_int(s: str) -> int:
|
||||
return int(hashlib.md5(s.encode("utf-8")).hexdigest()[:8], 16)
|
||||
|
||||
|
||||
def infer_sae_flags(injury_text: str) -> dict[str, bool]:
|
||||
t = injury_text or ""
|
||||
return {
|
||||
"saeDeath": ("死亡" in t) and ("非死亡" not in t),
|
||||
"saeLifeThreatening": "危及生命" in t or "生命威胁" in t or "心跳骤停" in t,
|
||||
"saeDisability": "残疾" in t or "功能障碍" in t,
|
||||
"saeHospitalization": "住院" in t or "延长住院" in t or "住院时间延长" in t,
|
||||
}
|
||||
|
||||
|
||||
def map_material_to_product(material_desc: str, canonical_products: list[str]) -> str:
|
||||
if not isinstance(material_desc, str) or not material_desc.strip():
|
||||
return ""
|
||||
s = material_desc.strip()
|
||||
for p in canonical_products:
|
||||
if p and p in s:
|
||||
return p
|
||||
return s.split()[0] if " " in s else s
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ae_path = DATA_DIR / "不良事件数据-模拟1000条-20260414.xlsx"
|
||||
adm_path = DATA_DIR / "入院量数据-模拟1000条-20260414.xlsx"
|
||||
cmp_path = DATA_DIR / "质量投诉数据-模拟1000条-20260414.xlsx"
|
||||
|
||||
ae_df = pd.read_excel(ae_path, sheet_name=0)
|
||||
adm_df = pd.read_excel(adm_path, sheet_name=0)
|
||||
cmp_df = pd.read_excel(cmp_path, sheet_name=0)
|
||||
cmp_df.columns = [str(c).replace("\n", "").strip() for c in cmp_df.columns]
|
||||
|
||||
hosp_prov = (
|
||||
adm_df.groupby("HospitalName")["Province"]
|
||||
.agg(lambda s: s.mode().iloc[0] if len(s.mode()) else (s.iloc[0] if len(s) else ""))
|
||||
.to_dict()
|
||||
)
|
||||
hospital_keys = sorted(hosp_prov.keys(), key=len, reverse=True)
|
||||
# 入院量表仅覆盖部分医院;其余 AE 单位按常识补省(仅用于演示地图/省聚合)
|
||||
province_override: dict[str, str] = {
|
||||
"中国医科大学附属第一医院": "辽宁省",
|
||||
"华中科技大学同济医学院附属协和医院": "湖北省",
|
||||
"昆明医科大学第一附属医院": "云南省",
|
||||
"天津市肿瘤医院": "天津市",
|
||||
"重庆医科大学附属第一医院": "重庆市",
|
||||
}
|
||||
|
||||
def resolve_province(unit: str) -> str:
|
||||
if unit in province_override:
|
||||
return province_override[unit]
|
||||
if unit in hosp_prov:
|
||||
return str(hosp_prov[unit]).strip()
|
||||
for h in hospital_keys:
|
||||
if not h:
|
||||
continue
|
||||
if h in unit or unit in h:
|
||||
return str(hosp_prov[h]).strip()
|
||||
return "(未映射)"
|
||||
|
||||
ae_products = set(ae_df["产品名称"].dropna().astype(str).unique())
|
||||
cmp_products = set(cmp_df["产品名称"].dropna().astype(str).unique())
|
||||
canonical_products = sorted(ae_products | cmp_products, key=len, reverse=True)
|
||||
|
||||
ae_rows: list[dict] = []
|
||||
for _, row in ae_df.iterrows():
|
||||
code = str(row.get("报告编码", "")).strip()
|
||||
review = _d(row.get("审核日期"))
|
||||
if pd.isna(review):
|
||||
continue
|
||||
h = _stable_int(code) % 10000
|
||||
reg = review - pd.Timedelta(days=3 + (h % 6))
|
||||
occ = reg - pd.Timedelta(days=1 + (h % 12))
|
||||
unit = str(row.get("单位名称", "")).strip()
|
||||
injury = str(row.get("伤害表现", "") or "").strip()
|
||||
flags = infer_sae_flags(injury)
|
||||
ae_rows.append(
|
||||
{
|
||||
"reportCode": code,
|
||||
"occurrenceDate": _fmt(occ),
|
||||
"registrationDate": _fmt(reg),
|
||||
"unitName": unit,
|
||||
"businessUnit": str(row.get("事业线", "") or "").strip(),
|
||||
"productName": str(row.get("产品名称", "") or "").strip(),
|
||||
"registrationNo": str(row.get("注册证编号/曾用注册证编号", "") or "").strip(),
|
||||
"model": str(row.get("型号", "") or "").strip(),
|
||||
"batchNo": str(row.get("产品批号", "") or "").strip(),
|
||||
"injuryExpression": injury,
|
||||
"deviceFailure": str(row.get("器械故障表现", "") or "").strip(),
|
||||
"reviewDate": _fmt(review),
|
||||
"province": resolve_province(unit) or "(未映射)",
|
||||
**flags,
|
||||
}
|
||||
)
|
||||
|
||||
adm_rows: list[dict] = []
|
||||
for _, row in adm_df.iterrows():
|
||||
md = row.get("MaterialDesc")
|
||||
pname = map_material_to_product(str(md) if md == md else "", canonical_products)
|
||||
adm_rows.append(
|
||||
{
|
||||
"year": int(row["Year"]),
|
||||
"month": int(row["Month"]),
|
||||
"hospitalName": str(row.get("HospitalName", "") or "").strip(),
|
||||
"dealerName": str(row.get("DealerName", "") or "").strip(),
|
||||
"province": str(row.get("Province", "") or "").strip(),
|
||||
"bu": str(row.get("BU", "") or "").strip(),
|
||||
"productName": pname,
|
||||
"cyQty": float(row.get("CY Qty", 0) or 0),
|
||||
"lyQty": float(row.get("LY Qty", 0) or 0),
|
||||
"growthQtyPct": round(float(row.get("Growth% Qty", 0) or 0) * 100, 4),
|
||||
"cyAmt": float(row.get("CY Amt", 0) or 0),
|
||||
"growthAmtPct": round(float(row.get("Growth% Amt", 0) or 0) * 100, 4),
|
||||
}
|
||||
)
|
||||
|
||||
def yn_to_bool(v) -> bool:
|
||||
s = str(v).strip()
|
||||
return s == "是" or s.lower() == "true" or s == "1"
|
||||
|
||||
cmp_rows: list[dict] = []
|
||||
for _, row in cmp_df.iterrows():
|
||||
reg = _d(row.get("C3登记日期"))
|
||||
if pd.isna(reg):
|
||||
continue
|
||||
close = _d(row.get("关闭日期"))
|
||||
survey = _d(row.get("调查报告完成日期"))
|
||||
if pd.isna(close):
|
||||
close = survey if not pd.isna(survey) else reg
|
||||
cmp_rows.append(
|
||||
{
|
||||
"c3Code": str(row.get("C3编号", "")).strip(),
|
||||
"model": str(row.get("型号", "") or "").strip(),
|
||||
"batchNo": str(row.get("批号", "") or "").strip(),
|
||||
"registrationNo": str(row.get("注册证号", "") or "").strip(),
|
||||
"productName": str(row.get("产品名称", "") or "").strip(),
|
||||
"hospitalName": str(row.get("医院名称", "") or "").strip(),
|
||||
"faultType": str(row.get("故障类型", "") or "").strip(),
|
||||
"registerDate": _fmt(reg),
|
||||
"isAe": yn_to_bool(row.get("是否不良事件")),
|
||||
"conclusion": str(row.get("调查结论(处理结果)", "") or "").strip(),
|
||||
"compensation": str(row.get("赔付结论", "") or "").strip(),
|
||||
"closeDate": _fmt(close),
|
||||
}
|
||||
)
|
||||
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(OUT_DIR / "ae.json").write_text(
|
||||
json.dumps(ae_rows, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(OUT_DIR / "admission.json").write_text(
|
||||
json.dumps(adm_rows, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(OUT_DIR / "complaint.json").write_text(
|
||||
json.dumps(cmp_rows, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print("Wrote", len(ae_rows), "ae,", len(adm_rows), "admission,", len(cmp_rows), "complaint ->", OUT_DIR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path, { resolve } from 'path'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: JSON.parse(readFileSync('./public/manifest.json', 'utf-8')),
|
||||
workbox: {
|
||||
// 增加文件大小限制以处理大文件
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
|
||||
// 自定义缓存策略
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-cache',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^http:\/\/127\.0\.0\.1:8091\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 // 24 hours
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true // 开发环境也启用 PWA
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8091',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
},
|
||||
// 添加日志以查看代理是否生效
|
||||
onProxyReq(proxyReq, req, res) {
|
||||
console.log('代理请求:', req.method, req.url);
|
||||
},
|
||||
onProxyRes(proxyRes, req, res) {
|
||||
console.log('代理响应:', proxyRes.statusCode, req.url);
|
||||
}
|
||||
},
|
||||
fs: {
|
||||
strict: true,
|
||||
allow: ['..']
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
'pinia',
|
||||
'@element-plus/icons-vue',
|
||||
'element-plus'
|
||||
]
|
||||
},
|
||||
build: {
|
||||
// 生产环境禁用 sourcemap 以节省内存
|
||||
sourcemap: mode === 'development',
|
||||
chunkSizeWarningLimit: 3000,
|
||||
// 启用压缩,减少输出文件大小
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
// 移除 console 和 debugger
|
||||
drop_console: mode === 'production',
|
||||
drop_debugger: true
|
||||
}
|
||||
},
|
||||
rollupOptions: {
|
||||
// 配置 Rollup 以处理大型项目
|
||||
maxParallelFileOps: 2, // 限制并行文件操作数量
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
// 为每个模块生成独立的HTML入口
|
||||
dashboard: resolve(__dirname, 'public/dashboard.html'),
|
||||
crm: resolve(__dirname, 'public/crm.html'),
|
||||
org: resolve(__dirname, 'public/org.html'),
|
||||
task: resolve(__dirname, 'public/task.html'),
|
||||
knowledge: resolve(__dirname, 'public/knowledge.html'),
|
||||
email: resolve(__dirname, 'public/email.html'),
|
||||
manager: resolve(__dirname, 'public/manager.html')
|
||||
},
|
||||
output: {
|
||||
// 确保文件名包含 hash,实现自动缓存失效
|
||||
entryFileNames: 'assets/[name]-[hash].js',
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||
// 优化 chunk 分割策略
|
||||
manualChunks: {
|
||||
// 框架相关
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
// UI 组件库
|
||||
'element-plus': ['element-plus', '@element-plus/icons-vue'],
|
||||
// 图表库
|
||||
'echarts': ['echarts'],
|
||||
// 表格组件
|
||||
'vxe-table': ['vxe-table'],
|
||||
// 编辑器相关
|
||||
'editor': ['@umoteam/editor'],
|
||||
// 其他工具库
|
||||
'utils': ['crypto-js', 'axios']
|
||||
}
|
||||
},
|
||||
// 外部化一些大型依赖(如果可能)
|
||||
external: []
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Binary file not shown.
|
|
@ -0,0 +1,57 @@
|
|||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
|
||||
|
||||
def convert_md_to_docx(md_path: Path, docx_path: Path) -> None:
|
||||
doc = Document()
|
||||
|
||||
lines = md_path.read_text(encoding="utf-8").splitlines()
|
||||
in_code = False
|
||||
|
||||
for raw in lines:
|
||||
line = raw.rstrip("\n")
|
||||
|
||||
if line.strip().startswith("```"):
|
||||
in_code = not in_code
|
||||
continue
|
||||
|
||||
if in_code:
|
||||
p = doc.add_paragraph()
|
||||
p.add_run(line)
|
||||
continue
|
||||
|
||||
if not line.strip():
|
||||
doc.add_paragraph("")
|
||||
continue
|
||||
|
||||
if line.startswith("# "):
|
||||
doc.add_heading(line[2:].strip(), level=1)
|
||||
continue
|
||||
if line.startswith("## "):
|
||||
doc.add_heading(line[3:].strip(), level=2)
|
||||
continue
|
||||
if line.startswith("### "):
|
||||
doc.add_heading(line[4:].strip(), level=3)
|
||||
continue
|
||||
|
||||
if line.startswith("- "):
|
||||
doc.add_paragraph(line[2:].strip(), style="List Bullet")
|
||||
continue
|
||||
|
||||
s = line.lstrip()
|
||||
if len(s) > 2 and s[0].isdigit() and s[1] == "." and s[2] == " ":
|
||||
doc.add_paragraph(s[3:].strip(), style="List Number")
|
||||
continue
|
||||
|
||||
# Markdown table rows are preserved as plain text lines for compatibility.
|
||||
doc.add_paragraph(line)
|
||||
|
||||
doc.save(str(docx_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
src = Path(r"d:\数据分析业务\ixa\药物警戒价值分析策略-正文.md")
|
||||
dst = Path(r"d:\数据分析业务\ixa\药物警戒价值分析策略-正文.docx")
|
||||
convert_md_to_docx(src, dst)
|
||||
print(f"Written: {dst}")
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
# 合规方向分析(落地说明)
|
||||
|
||||
> 本文档由《贝朗医疗数据分析策略》**合规维度**拆分而来,面向药物警戒/器械警戒与合规团队,用于指标口径统一、开发实现与页面文案设计。
|
||||
> 质量、营销方向落地文件见 [质量方向分析.md](./质量方向分析.md)、[营销方向分析.md](./营销方向分析.md);确认本文件结构与深度后,可按相同模板扩展。
|
||||
|
||||
---
|
||||
|
||||
## 0 分析指标文档化的最佳实践(摘要)
|
||||
|
||||
在业界 KPI/指标管理实践中,单条指标建议至少具备以下要素,以保证「可复用、可对账、可演进」:
|
||||
|
||||
| 要素 | 说明 |
|
||||
|------|------|
|
||||
| **业务定义** | 指标回答什么问题、与哪条法规/内控要求对齐(避免仅写图表名)。 |
|
||||
| **可计算定义** | 分子/分母、集合判定规则、是否去重(事件级/病例级/报告级)。 |
|
||||
| **维度与粒度** | 时间桶(日/周/月/季/年)、地理、产品、注册证、BU、严重度等。 |
|
||||
| **过滤条件** | 纳入/排除规则(如草稿、重复报告、测试数据)。 |
|
||||
| **数据来源与字段** | 主表、关联键、字段映射;多源时明确主从与优先级。 |
|
||||
| **实现注意点** | 空值、时区、工作日与自然日、匹配窗口、分母缺失时的处理。 |
|
||||
| **输出形态** | 图/表/卡片/地图/阈值告警;建议刷新频率与受众。 |
|
||||
| **页面呈现用途** | 给产品经理/前端的一句话:该模块帮助用户完成什么决策或动作。 |
|
||||
|
||||
书写**计算逻辑**时的建议:
|
||||
|
||||
1. **先定义统计单元**:例如「一条不良事件报告」「一例患者伤害」「一次投诉闭环」——同一屏上不要混用。
|
||||
2. **公式显式化**:比例类写清分子分母;率类注明分母来源(销量、入院量、装机量等)及**口径版本**。
|
||||
3. **匹配类规则写清算法**:键字段、模糊匹配策略、时间窗(±N 天)、一对多时的择优规则。
|
||||
4. **与法规时限对齐**:将「15 天/45 天」等映射为可计算的日期差分规则,并单独配置节假日是否顺延(若业务需要)。
|
||||
5. **版本化**:口径变更时保留指标编码(如 `CMP-AE-001`),避免历史报表不可比。
|
||||
|
||||
以下各节按「分析主题 → 指标清单」组织;每条指标均含**计算逻辑/方法**与**页面呈现用途**。
|
||||
|
||||
---
|
||||
|
||||
## 1 策略定位与数据范围
|
||||
|
||||
### 1.1 业务目标
|
||||
|
||||
满足中国医疗器械不良事件监测相关法规与指南要求,支撑 **PSUR/定期风险评估**、**上报合规审计** 与 **趋势预警**;输出可被监管沟通引用的图、表与结构化指标。
|
||||
|
||||
### 1.2 适用对象
|
||||
|
||||
药物警戒(PV)/器械警戒、注册与合规、医学安全、必要时联动质量与风险管理。
|
||||
|
||||
### 1.3 主数据对象(与策略原文一致)
|
||||
|
||||
| 对象 | 典型用途 |
|
||||
|------|----------|
|
||||
| 不良事件(AE)表 | 监管侧事件统计、严重度、伤害/故障分布、区域与趋势 |
|
||||
| 投诉表 | 与 AE 交叉验证、上报时效起点、是否标记为不良事件 |
|
||||
| 入院量/销售辅助数据 | **PPM 等发生率类指标的分母(暴露量)**,与 AE 产品在统一物料/产品维度对齐后汇总 |
|
||||
|
||||
### 1.4 已定口径(全局默认,已确认)
|
||||
|
||||
以下口径适用于本文件全部指标,除非在单条指标中另行标注「例外」。
|
||||
|
||||
| 项目 | 已定口径 |
|
||||
|------|----------|
|
||||
| **AE 主时间轴** | 以 **发生日期** 为准;年/季/月等时间桶均由该字段派生。 |
|
||||
| **AE 统计单元** | **报告条数**(每条 AE 记录计 1,不做病例/患者去重,除非另建专项指标)。 |
|
||||
| **PPM 分母** | **入院量**(与 AE 在同一产品对齐键上汇总同期入院量;汇总用 **Qty 或 Amt** 由数据模型择一固化并在报表脚注声明,全公司一致)。 |
|
||||
| **获知日** | 采用 **登记日期**(作为法规/内控语境下「获知」在系统中的落库字段;用于上报时效类计算的起点,字段路径实现为 `不良事件.登记日期` 或主数据统一编码)。 |
|
||||
| **SAE(严重不良事件)** | 按 **SAE 严重标准**判定,满足以下 **任一** 即归入 SAE 子集:**死亡**、**危及生命**、**导致残疾**、**导致住院或住院时间延长**(以 AE 表编码/多选字段 OR 逻辑汇总;底层码表由医学/PV 维护并与国家分类映射)。 |
|
||||
|
||||
**说明**:主题 G「上报时效」在可配对样本上,计算 **`Lag = T_end − 登记日期`**,其中 `T_end` 为企业认定的**报告闭环终点日**(建议配置为 `审核日期` 或「首次提交监管/国家系统日期」,二选一全司统一);法规 15 天/45 天等阈值对该 `Lag` 适用,事件是否适用「紧急/死亡」更短时限仍由企业规则表配置。
|
||||
|
||||
---
|
||||
|
||||
## 2 监管报告类分析(PSUR 与定期安全更新)
|
||||
|
||||
### 2.1 主题 A:总体事件趋势
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-101** | 不良事件报告数(按时间桶) | **分子**:时间过滤落在各时间桶内、符合纳入规则的 AE **报告条数**。**时间过滤字段**:**发生日期**。**分母**:无。**维度**:年/季/月。**过滤**:排除测试/重复标记(若有字段)。 | **趋势总览卡片/折线图**:回答「事件量是否异常上升」,作为 PSUR「总体趋势」章节的封面指标。 |
|
||||
| **CMP-AE-102** | 不良事件报告数(按注册证号 × 年) | 对 AE 按 `注册证编号` 与 **`发生日期` 所在公历年**(发生年)分组计数。 | **堆叠或分面折线**:识别「哪一个注册证贡献主要增量」,支撑注册证分级监管与再评价讨论。 |
|
||||
| **CMP-AE-103** | 不良事件报告数(按主要产品/BU × 时间桶) | 通过产品主数据将 AE 产品映射到 **产品线/BU**,按 **发生日期** 落入的时间桶计数 **报告条数**。 | **管理驾驶舱分 BU 视图**:向事业部同步安全负荷,用于资源与对外沟通优先级。 |
|
||||
|
||||
**实现注意**:监管报送若需「报告期」切片展示,可另建**辅助看板**按 `审核日期`/`录入日期` 复算,但与本文 **CMP-AE-*** 主口径不一致时须在页面显著标注,避免与按发生日的安全信号混读。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 主题 B:发生率 / PPM 与过程控制图(SPC)
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-201** | 产品级不良事件 PPM | **公式**:`PPM = (AE 报告数 / 同期入院量) × 1,000,000`。**分子**:按 **发生日期** 落入统计期的 AE **报告条数**。**分母**:同一统计期、与 AE 经 **产品对齐键** 匹配后的 **入院量** 汇总值(Qty 或 Amt 由模型固化,全司一致)。无分母或分母为 0 时:仅展示分子并灰显「率」或显示「N/A」。**维度**:产品/SKU/注册证。 | **PPM 趋势折线 + 脚注声明分母为入院量及字段**:支撑 PSUR「发生率」叙述。 |
|
||||
| **CMP-AE-202** | BU/产品线分组 PPM | 在 CMP-AE-201 基础上,分子为 BU 内 AE **报告条数**之和,分母为 BU 内 **入院量**之和,再计算 PPM(禁止先算 SKU PPM 再简单平均)。 | **事业部对比条形图**:同一尺度下比较不同 BU 的安全负荷是否正常。 |
|
||||
| **CMP-AE-203** | PPM 的 SPC 控制图(规则可选 Western Electric/Nelson) | 以连续时间序列为子组(如按月):子组内 AE 数与入院量与 CMP-AE-201 一致;对 PPM 序列计算中心线、控制限及判异标注。分母波动大时优先 **U 图/P 图**(按子组入院量加权)。 | **预警型折线控件**:当点出界或连续同侧时高亮,服务「趋势预警」与管理层简报。 |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 主题 C:器械故障类型与伤害表现分布
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-301** | TOP10 器械故障表现占比 | 对 AE 的「器械故障表现」字段规范化编码后计数,取 Top10;**占比** = 该类型计数 / 当期有效 AE 总数 ×100%。 | **横向柱状图**:PSUR「常见原因/故障」小节,快速列出需工程评估的故障类型。 |
|
||||
| **CMP-AE-302** | 伤害表现频次表 | 对「伤害表现」字段计数;可按 **是否属于 SAE 严重标准**(见 §1.4)分层小计。 | **表格 + 导出**:与监管分类对齐,用于医学评审与说明书/IFU 风险提示更新。 |
|
||||
|
||||
---
|
||||
|
||||
### 2.4 主题 D:伤害表现 × 器械故障表现 交叉矩阵(热力图)
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-401** | 伤害-故障组合计数矩阵 | 构建二维表:行=伤害表现,列=器械故障表现,单元格 = 满足 `(伤害,故障)` 组合的 **AE 报告条数**(默认以 **发生日期** 落入统计期过滤)。**可选标准化**:单元格显示占行百分比或占总量百分比。 | **热力图**:一眼识别「哪类故障最常伴随哪类伤害」,支撑 CAPA 与临床风险沟通。 |
|
||||
| **CMP-AE-402** | SAE 子集下的伤害-故障矩阵 | 在 CMP-AE-401 上仅保留满足 **§1.4 SAE 严重标准**(死亡 / 危及生命 / 导致残疾 / 导致住院或住院时间延长 **之一**)的 AE **报告条数**。 | **风险聚焦视图**:对接严重结局组合模式,用于紧急医学评估与对外报告要点。 |
|
||||
| **CMP-AE-403** | 按产品分层的典型组合 | 对每个主要产品复制 CMP-AE-401/402,或提供产品筛选器后重算。 | **产品安全档案内嵌模块**:说明「该产品典型故障-伤害模式」,与 PSUR 产品章节一致。 |
|
||||
|
||||
*策略原文第 5.1 节与本主题同一分析主线;实现时建议共用同一语义指标,避免两套口径。*
|
||||
|
||||
---
|
||||
|
||||
### 2.5 主题 E:严重不良事件(SAE)专项
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-501** | SAE 报告数(年/季) | 筛选满足 **§1.4 SAE 严重标准** 的 AE,按 **发生日期** 落入的年/季时间桶对 **报告条数** 计数。 | **SAE 趋势折线**:PSUR/风险管理会「严重事件」专段。 |
|
||||
| **CMP-AE-502** | SAE 占全部 AE 比例 | `SAE 报告条数 / 当期全部 AE 报告条数 ×100%`;当期与桶边界均以 **发生日期** 为准。若 AE 表含非器械事件,先按业务规则限定器械范围再算。 | **占比卡片 + 趋势**:观察严重事件是否「量增」同时「占比增」。 |
|
||||
| **CMP-AE-503** | SAE 分布(产品/医院/地区) | 在 SAE 子集(定义同 §1.4)上按维度聚合计数;可选 **SAE 率** = SAE 数 / 同期入院量(对齐键同 CMP-AE-201)。 | **地图/条形图**:定位高风险聚集点,支持现场调查与沟通名单。 |
|
||||
|
||||
---
|
||||
|
||||
### 2.6 主题 F:分区域分布
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-601** | 省级 AE 报告数 | 按 **发生日期** 落入统计期的 AE,经省份(或医院→省份映射)聚合计 **报告条数**。 | **中国地图着色**:展示地理聚集,辅助供应链与批次假设。 |
|
||||
| **CMP-AE-602** | 省级 AE 发生率(入院量分母) | `省 AE 报告条数 / 省入院量`;分子时间口径为 **发生日期**,分母为同期 **入院量** 按省汇总(字段 Qty/Amt 与产品对齐规则同 CMP-AE-201)。 | **地图 + 表格双视图**:区分「绝对量大」与「相对率高」,减少误判人口大省。 |
|
||||
| **CMP-AE-603** | 区域 TOP 事件类型 | 对每个省,在该省当期 AE(**发生日期**)中取事件名称或故障类型的 TopN。 | **下钻表格**:从省到事件类型的快速穿透,用于区域医学联络准备材料。 |
|
||||
|
||||
---
|
||||
|
||||
### 2.7 主题 G:投诉/不良事件上报时效性
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-701** | 单条 AE 上报滞后天数(获知→闭环) | **定义**:`Lag = T_end − 登记日期`。其中 **获知日** 已确认为 **`登记日期`**;`T_end` 为企业统一配置的**报告闭环终点日**(`审核日期` 或「首次提交监管/国家系统日期」二选一)。仅统计两日期均非空的 AE **报告条数**;若 `登记日期 > T_end` 则标为数据质量异常单。 | **直方图/箱线图**:展示从获知登记到闭环的滞后分布。 |
|
||||
| **CMP-AE-702** | 法规时限合规率(15 天/45 天等) | 在 CMP-AE-701 可判定集合上,按事件分类(是否适用死亡/危及生命等**更短时限**,以企业规则表配置)分别比较 `Lag ≤ T`。**合规率** = 合规条数 / 可判定条数。 | **仪表盘阈值灯**:内控与审计应答「是否满足报告时限」。 |
|
||||
| **CMP-AE-703** | 超窗案例清单 | 列出 `Lag > T` 的 AE **报告**及超窗天数、责任单元(若可解析)。 | **整改任务列表**:支持 CAPA 与流程 IT 改进闭环。 |
|
||||
|
||||
*注:「15/45 天」是否顺延节假日可作为**可配置策略**,须在报表脚注固定输出;**与投诉 C3 登记的跨表时效**见 **CMP-CV-104**。*
|
||||
|
||||
**投诉–AE 配对场景的补充口径(可选)**:若需保留「投诉登记 → AE 闭环」视角,可单列 `Lag₂ = T_end − 投诉.C3登记日期`(仅匹配成功子集),与 CMP-AE-701 **并存展示**,避免与获知日=登记日期口径混淆。
|
||||
|
||||
---
|
||||
|
||||
### 2.8 主题 H:注册证号安全档案(产品安全一览)
|
||||
|
||||
对每一 `注册证编号`,输出一组**并列指标**(适合「一行一证」表 + 详情下钻):
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-801** | 注册证维度 AE 累计报告数 | 按注册证汇总 AE **报告条数**;时间窗默认按 **发生日期**(如近 12 个月或上市至今可选)。 | **安全档案主列**:快速对比各证事件量。 |
|
||||
| **CMP-AE-802** | 注册证维度关联投诉数 | 投诉表按 `注册证号` 与所选时间窗计数(若投诉侧主时间轴与 AE **发生日期** 不一致,须在档案页脚注分别说明两条时间轴)。 | **档案并列列**:同一证下市场端反馈量,辅助「市场-监管」一体叙事。 |
|
||||
| **CMP-AE-803** | 注册证维度 PPM | 同 CMP-AE-201,在注册证粒度聚合。 | **排序列**:识别相对暴露下事件偏高的证。 |
|
||||
| **CMP-AE-804** | 注册证维度 SAE 占比 | 该证 SAE 数 / 该证 AE 总数。 | **风险标签**:触发再评价/说明书更新讨论的候选证。 |
|
||||
| **CMP-AE-805** | 注册证维度主要故障/伤害文本(Top3) | 对子集取故障表现、伤害表现各 Top3 及占比。 | **档案摘要区**:供注册与医学在会议中口述「典型模式」。 |
|
||||
|
||||
**关联字段(与策略原文一致)**:不良事件.`注册证编号` ↔ 投诉.`注册证号`(需处理格式统一与历史别名映射)。
|
||||
|
||||
---
|
||||
|
||||
### 2.9 主题 I:补充分析(可选数据源)
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-AE-901** | 新产品上市后 AE/PPM 与对照 | 定义「新产品」列表(获批/首销日期),上市后按周或月统计:AE 数以 **发生日期** 落入窗口的 **报告条数** 为准;PPM 分母为同期 **入院量**(同 CMP-AE-201)。与同类老产品或全局基准对照(可选置信区间)。 | **上市安全监测专页**:支持 PMCF/上市后研究计划的证据展示。 |
|
||||
| **CMP-AE-902** | CAPA 实施前后 AE 趋势对比 | 以 `实施完成日` 为断点,对比前后等长窗口的 AE **报告条数**或 PPM(AE 时间轴 **发生日期**;注意外部混杂,页面提示「关联非因果」)。 | **改进叙事图**:对内对外说明「已采取控制措施」的佐证材料。 |
|
||||
|
||||
---
|
||||
|
||||
## 3 合规行为分析:不良事件上报合规性审计
|
||||
|
||||
### 3.1 主题 J:投诉标记不良事件的「应报尽报」核对
|
||||
|
||||
**业务规则(与策略原文对齐)**:以投诉表中 `是否不良事件 = 是` 的记录为基准,检查在不良事件表中是否存在**可认定为同一事件**的对应记录。
|
||||
|
||||
**匹配逻辑建议(可配置)**:
|
||||
|
||||
1. **强键**:`医院名称` ≈ `单位名称`(建议标准化:去空格、统一简称库)+ `产品名称` 一致 + `型号` 一致(若有)。
|
||||
2. **时间窗**:`投诉.C3登记日期` 与 **`不良事件.发生日期`** 之差在 **±W 天**内(W 默认如 30,可配置),与全篇 AE **主时间轴**一致。
|
||||
3. **一对多处理**:若多条 AE 命中,取 **`发生日期` 与投诉 C3 登记日期最接近** 的一条为「主匹配」;其余记为「重复报告」供人工复核。
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **CMP-CV-101** | 疑似漏报条数 | 基准集合中,**无任何**满足匹配规则的 AE 记录的投诉条数。 | **审计高亮列表**:逐条进入调查与补报流程。 |
|
||||
| **CMP-CV-102** | 漏报率 | `疑似漏报条数 / 基准集合总条数 ×100%`。 | **合规 KPI 卡片**:季度汇报内控与管理层。 |
|
||||
| **CMP-CV-103** | 匹配成功条数 | 基准集合中存在 ≥1 条 AE 匹配的投诉数。 | **过程能力参考**:与漏报率互补,用于评估匹配规则是否过严。 |
|
||||
| **CMP-CV-104** | 上报时效合规率(投诉-AE 配对子集) | 在匹配成功子集上:**优先**对 AE 使用 **CMP-AE-701/702**(`Lag = T_end − 登记日期`)计算合规率;若业务需展示「投诉通道起点」则并列计算 `Lag₂ = T_end − 投诉.C3登记日期` 的合规率并在 UI 标注双口径。 | **联接视图**:同时看「有没有报」与「报得是否及时」,并区分获知登记 vs 投诉登记起点。 |
|
||||
|
||||
**页面呈现用途(主题级)**:本分析模块整体用于回应《医疗器械不良事件监测和再评价管理办法》等法规中的**上报义务**内控证据,并可作为外审时的抽样框架说明。
|
||||
|
||||
---
|
||||
|
||||
## 4 与主策略文档的对应关系
|
||||
|
||||
| 主策略章节 | 本文档章节 |
|
||||
|------------|------------|
|
||||
| §2.1 用于监管报告(PSUR 各图表明细) | §2.1–§2.9 |
|
||||
| §2.2 不良事件上报合规性审计 | §3.1 |
|
||||
| §5.1 伤害表现 × 器械故障(与 §2.1.1 d 同一主线) | §2.4 |
|
||||
|
||||
---
|
||||
|
||||
## 5 仍建议在数据模型层固化的细节(非口径争议)
|
||||
|
||||
以下不影响 §1.4 已定业务口径,但需在 ETL/主数据 **一次拍板**,以保证全表可对账:
|
||||
|
||||
1. **入院量汇总字段**:`Qty` 与 `Amt` 二选一作为 PPM 分母时的正式字段名及是否与财务口径一致。
|
||||
2. **AE–入院量产品对齐键**:物料号、本地产品码或注册证映射层级,及无法匹配时的归属规则(入「未匹配」桶或排除)。
|
||||
3. **SAE 判定实现**:AE 表上四个严重结局在字段层是四布尔、单选多级码表还是国家系统导出码;与 §1.4 四类的 SQL/规则表达式版本化存档。
|
||||
4. **`T_end` 字段**:审核完成日与「首次上传国家系统日」择一后的系统字段名及跨系统对时规则。
|
||||
|
||||
---
|
||||
|
||||
*文档版本:已纳入 §1.4 口径确认(发生日、报告条数、入院量分母、获知日=登记日期、SAE 四标准);数仓命名与刷新 SLA 可另页维护。*
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# 综合分析(落地说明)
|
||||
|
||||
> 本文档由《贝朗医疗数据分析策略》**画像与综合(§6–§7)**及跨维度复盘需求拆分,面向管理层、PMO、PV/质量/市场联席例会,用于**把合规、质量、营销各页图表的结论集中呈现**,形成一页式「数据读法」清单。
|
||||
> 维度落地文:[合规方向分析.md](./合规方向分析.md)、[质量方向分析.md](./质量方向分析.md)、[营销方向分析.md](./营销方向分析.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1 目标与边界
|
||||
|
||||
### 1.1 业务目标
|
||||
|
||||
- **汇总读数**:对已实现图表的输出做**同一数据源上的二次解读**,避免各业务线只带走各自截图、口径不一致。
|
||||
- **支撑 §6–§7 演进**:当前版本以 **§7 综合联动**中的「多维度同屏复盘」为落点;**§6 客户画像**(高投诉高复购、赔付与增长)待数据字段齐备后接入同一汇总框架。
|
||||
|
||||
### 1.2 边界(当前 Demo)
|
||||
|
||||
- 结论文案由前端 **`comprehensive-insights`** 规则基于 **`public/mock`** 聚合结果**自动生成**,属于**辅助阅读**而非审计结论;正式环境应可由规则引擎 + 人工批注替换。
|
||||
- **占位图**(无 `optionBuilder`)不参与汇总列表。
|
||||
- 每条结论建议控制在 **2~4 个要点**,与单图 `chartExplain`(取数范围/分析逻辑)互补:**后者讲口径,前者讲读法**。
|
||||
|
||||
---
|
||||
|
||||
## 2 汇总列表的信息结构
|
||||
|
||||
综合页每条对应「一张已实现图」,列表字段建议固定为:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| **维度** | 合规 / 质量 / 营销 / 事件实质 |
|
||||
| **所属分析页** | 与左侧菜单标题一致 |
|
||||
| **图表名称** | 与页面卡片标题一致 |
|
||||
| **图表 ID** | 与 `analysis-config` 中 `id` 一致,便于缺陷跟踪与版本对比 |
|
||||
| **结论要点** | 无序列表,基于该图当前聚合结果的**规则化摘要**(见 §3) |
|
||||
|
||||
---
|
||||
|
||||
## 3 结论生成规则(原则)
|
||||
|
||||
1. **一数一结论**:优先输出「总量/极值/占比/斜率」中业务最敏感的一类,避免复述图表标题。
|
||||
2. **极值必报**:时间序列报峰值月份与数值;Top 图报 Top1 及与均值的倍数关系(若可算)。
|
||||
3. **率类脚注**:凡含入院量分母,结论中点明「千件」或「占比」与 **CY Qty** 口径,与《质量/营销方向分析》一致。
|
||||
4. **合规与质量分读**:AE 用发生日期、投诉用 C3 登记日期的图,结论中**不得合并**为同一句话的时间轴。
|
||||
5. **弱数据提示**:样本量过小或分母为 0 时,结论单条提示「不宜过度解读」。
|
||||
|
||||
各 `chartId` 与具体规则表达式见源码 `analytics-demo-web/src/lib/comprehensive-insights.ts`(随图表迭代同步维护)。
|
||||
|
||||
---
|
||||
|
||||
## 4 与主策略章节的对应关系
|
||||
|
||||
| 主策略 | 本文档 / 前端 |
|
||||
|--------|----------------|
|
||||
| §6 安全维度客户画像 | 预留;待复购/赔付与入院联动字段稳定后接入汇总列表 |
|
||||
| §7 综合联动分析 | **综合分析**页:多维度图表结论列表;后续可扩展双轴矩阵结论块 |
|
||||
|
||||
---
|
||||
|
||||
## 5 维护说明
|
||||
|
||||
- 新增图表时:在 `analysis-config.ts` 增加 `id` 后,于 **`comprehensive-insights.ts`** 注册同名 `chartId` 的结论生成函数(或显式标记「人工结论」占位)。
|
||||
- 更换 **Excel→mock** 数据后,结论随聚合结果变化,**无需改文案模板**(阈值类除外)。
|
||||
- 若需输出 Word/PPT,可由本列表导出 Markdown/CSV 再排版。
|
||||
|
||||
---
|
||||
|
||||
*文档版本:与前端「画像与综合 → 综合分析」菜单一致。*
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# 营销方向分析(落地说明)
|
||||
|
||||
> 本文档由《贝朗医疗数据分析策略》**营销维度(§4)**拆分而来,面向市场、渠道、培训与客户成功团队,用于指标口径统一、开发实现与页面文案设计。
|
||||
> 合规与质量维度分别见 [合规方向分析.md](./合规方向分析.md)、[质量方向分析.md](./质量方向分析.md)。
|
||||
|
||||
指标文档化要素(业务定义、可计算定义、维度、过滤、数据来源、实现注意点、输出形态、页面用途)建议与 [合规方向分析.md](./合规方向分析.md) **§0** 保持一致;下文各节按「分析主题 → 指标清单」展开,指标编码前缀 **`MKT-`**。
|
||||
|
||||
---
|
||||
|
||||
## 1 策略定位与数据范围
|
||||
|
||||
### 1.1 业务目标
|
||||
|
||||
从**投诉**数据中识别可能与**使用培训、推广沟通、渠道服务**相关的信号(如「操作不当」集中、区域/经销商维度投诉率异常),支撑**培训计划、经销商辅导、产品说明书与数字化推广素材**的迭代;与质量方向的根因池(产品缺陷)**并列阅读**,避免将营销类信号误归为质量问题。
|
||||
|
||||
### 1.2 适用对象
|
||||
|
||||
市场部、渠道/经销商管理、应用培训、医学教育、KA/区域销售、客户成功。
|
||||
|
||||
### 1.3 主数据对象
|
||||
|
||||
| 对象 | 典型用途 |
|
||||
|------|----------|
|
||||
| **质量投诉表** | 调查结论、医院、产品、故障类型、C3登记日期;筛选「操作不当」子集 |
|
||||
| **入院量表** | 将医院映射到 **Province、DealerName**;提供投诉率分母 **CY Qty**(或企业固化为 Amt) |
|
||||
|
||||
字段级清单与关联见 `#数据与表结构.md`。
|
||||
|
||||
### 1.4 已定口径(全局默认)
|
||||
|
||||
| 项目 | 建议口径 |
|
||||
|------|----------|
|
||||
| **投诉主时间轴** | **`C3登记日期`**(`registerDate`);全篇营销投诉类指标默认一致。 |
|
||||
| **投诉统计单元** | **投诉条数**(`c3Code` 唯一)。 |
|
||||
| **「操作不当」子集** | 调查结论字段精确等于 **`操作不当`**(与主策略 §4.1 一致;若存在中英文混排需在 ETL 归一)。 |
|
||||
| **医院 → 省 / 经销商** | 由入院量表按 **`HospitalName`** 聚合:对 **Province**、**DealerName** 取**众数**(出现频次最高);一对多时在元数据层版本化存档。 |
|
||||
| **率类分母** | **入院量 CY Qty** 在相同地理或经销商维度上汇总;演示与质量方向一致可用「件/千件」:**投诉条数 / CY Qty × 1000**;分母为 0 时该桶不计算率或标 N/A。 |
|
||||
| **占比类** | 「操作不当占该产品全部投诉比例」= 该产品操作不当条数 / 该产品投诉总条数 ×100%。 |
|
||||
|
||||
---
|
||||
|
||||
## 2 主题 A:操作不当与培训(主策略 §4.1)
|
||||
|
||||
> 回答:哪些医院、哪些经销商覆盖区域、哪些产品更需要**培训与使用支持**,而非单纯质量缺陷。
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **MKT-OP-101** | 操作不当投诉医院 TopN | 子集:`调查结论 = 操作不当`;按 **`医院名称`** 计数投诉条数,降序取 TopN。 | **条形图**:培训现场名单与院内教育优先级。 |
|
||||
| **MKT-OP-102** | 操作不当投诉经销商分布(条数) | 同上子集;每条投诉经 **`医院名称` → 入院量众数 DealerName`** 归入经销商后计数。 | **条形图**:渠道侧辅导与考核对象排序。 |
|
||||
| **MKT-OP-103** | 经销商操作不当率(入院量分母) | 子集同上;**分子**=该经销商名下医院映射后的操作不当条数;**分母**=入院量表 **`DealerName`** 等于该经销商的 **CY Qty 汇总**;**率** = 分子/分母×1000(件/千件,与质量 QLT-SAL-901 量级一致便于对照)。 | **排序条形图**:识别「相对销量操作不当偏多」的经销商。 |
|
||||
| **MKT-OP-104** | 产品操作不当占比 | 按 **`产品名称`**:`操作不当条数 / 该产品全部投诉条数 ×100%`;可设最小投诉量阈值再展示避免小样本。 | **条形图**:说明书/视频/数字化指导优先级。 |
|
||||
| **MKT-OP-105**(可选) | 操作不当 × 故障类型 | 在操作不当子集上按 **`故障类型`** 计数 Top。 | **表格/条形**:判断是「通用操作」还是某类故障话术不清。 |
|
||||
|
||||
---
|
||||
|
||||
## 3 主题 B:区域与经销商投诉率(主策略 §4.2)
|
||||
|
||||
> 回答:哪些**省**、哪些**经销商**在「相对入院量」下投诉偏多,用于渠道储运、覆盖密度与客户支持资源的再分配(不等同于质量结论)。
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **MKT-RG-201** | 省级投诉率(入院量分母) | **分子**:投诉经医院→**省**映射后的条数(可全量投诉或限定时间窗)。**分母**:入院量表同一 **Province** 的 **CY Qty** 汇总。**率** = 分子/分母×1000。 | **省级排名条形图/地图**:区域资源与活动排期。 |
|
||||
| **MKT-RG-202** | 经销商投诉率(入院量分母) | **分子**:投诉经医院→**DealerName(众数)** 归类计数。**分母**:入院量表 **DealerName** 维度 **CY Qty** 汇总。**率** = 分子/分母×1000。 | **经销商排名**:辅导、仓储、配送假设的优先验证名单。 |
|
||||
| **MKT-RG-203**(可选) | 省级投诉条数(不分母) | 仅计数,用于与 MKT-RG-201 对照区分「绝对量大」与「相对率高」。 | **并列卡片或表**:避免人口大省误判。 |
|
||||
|
||||
**实现注意**:同一医院在多经销商、多 BU 行中共存时,**众数规则**可能随月份切片变化,须在数据字典中固定;与质量方向 **医院×产品** 对齐键若不一致,应在页面脚注声明。
|
||||
|
||||
---
|
||||
|
||||
## 4 与主策略文档的对应关系
|
||||
|
||||
| 主策略章节 | 本文档章节 |
|
||||
|------------|------------|
|
||||
| §4.1 操作不当与投诉 | §2 |
|
||||
| §4.2 区域与经销商投诉率 | §3 |
|
||||
|
||||
---
|
||||
|
||||
## 5 仍建议在数据模型层固化的细节
|
||||
|
||||
1. **医院 → 经销商/省**:众数冲突时的优先级(如按最近业务月、按销量最大 BU)。
|
||||
2. **City 维度**:主策略提及 City,可在二期将投诉经入院量映射到 **City** 复用 MKT-RG-201 公式。
|
||||
3. **时间窗**:营销看板是否仅看「近 12 个月 C3登记」与入院同期对齐,需与质量方向默认窗一致或可配置。
|
||||
4. **操作不当同义词**:培训不足、使用错误等是否合并入「操作不当」码表。
|
||||
|
||||
---
|
||||
|
||||
*文档版本:与主策略 §4 及 `#数据与表结构.md` 字段对齐;前端演示见 `analytics-demo-web` 营销方向菜单。*
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
# 贝朗医疗数据分析策略
|
||||
|
||||
## 1 策略结构总览
|
||||
|
||||
- 从合规、质量、营销角度进行分析,探索数据背后的“故事”;
|
||||
- 基于分析形成一些画像:
|
||||
a. “投诉行为”
|
||||
b. “投诉人”
|
||||
- 未来当收到一个新的投诉报告时,将会与画像匹配,以推动后续的处理过程更高效、准确、控制风险。
|
||||
|
||||
|
||||
## 2 合规方向:药物警戒监管分析
|
||||
|
||||
> 目标:满足中国医疗器械不良事件监测法规要求,确保合规上报、趋势预警,支撑定期风险评估报告。
|
||||
|
||||
适用于药物警戒/器械警戒部门。
|
||||
|
||||
> **指标落地文档**:[合规方向分析.md](./合规方向分析.md)(各分析主题的指标编码、计算逻辑/方法与页面呈现用途说明)。
|
||||
|
||||
### 2.1 用于监管报告
|
||||
|
||||
#### 2.1.1 PSUR撰写(符合中国医疗器械 PSUR 报告要求)
|
||||
|
||||
a. **总体趋势图**
|
||||
- 年度/季度不良事件发生数趋势折线图
|
||||
- 各主要产品/注册证号的年度事件数变化
|
||||
|
||||
b. **发生率/PPM 走势图**
|
||||
- 产品级不良事件发生率(百万分之事件率,PPM)趋势图
|
||||
- 按 BU/产品线分组的发生率控制图(SPC控制图)
|
||||
|
||||
c. **器械故障类型与伤害表现分布表**
|
||||
- TOP 10 器械故障类型分布柱状图
|
||||
- 各主要伤害表现(如出血、感染、空气栓塞等)频次表
|
||||
|
||||
d. **典型组合矩阵/热力图**
|
||||
- 伤害表现 × 器械故障表现交叉热力图(字段、分层与监管对照等深化分析见 **5.1**)
|
||||
|
||||
e. **严重不良事件(SAE)专项分析**
|
||||
- 严重伤害或死亡事件年度/季度趋势图
|
||||
- 典型 SAEs 分布(按产品/医院/地区等维度)
|
||||
|
||||
f. **分区域分布地图/表**
|
||||
- 按省份/城市的不良事件发生率地图
|
||||
- 各区域 TOP 事件类型分布表
|
||||
|
||||
g. **投诉/不良事件上报时效性分析图**
|
||||
- 上报时长分布箱线图/直方图(分 15天/45天等规限)
|
||||
|
||||
h. **注册证号安全档案表(产品安全一览)**
|
||||
- 以注册证号为维度,汇总不良事件数、投诉数、发生率、严重事件占比、主要故障表现
|
||||
- 关联字段:不良事件.注册证编号、投诉.注册证号
|
||||
- 产出:每个注册证号的“安全性概况”一览;识别需重点监测或再评价的注册证
|
||||
- 合规价值:对接 PSUR 与注册证维护、再评价要求
|
||||
|
||||
i. **补充说明类**
|
||||
- 重点新产品上市后不良事件与发生率趋势对比图
|
||||
- CAPA(纠正和预防措施)执行后的不良事件变化趋势图(可选,若有整改措施)
|
||||
|
||||
> 以上图/表建议结合主要产品线、监管重点,以及年度/季度时序变化展示,覆盖 PSUR 报告“总体趋势”、“主要风险”、“区域分布”、“常见原因”、“严重事件”等核心章节需求。
|
||||
|
||||
### 2.2 合规行为分析
|
||||
|
||||
#### 2.2.1 不良事件上报合规性审计
|
||||
|
||||
- **分析内容**:以投诉表的"是否不良事件=是"的记录为基准,验证是否每一条都能在不良事件表中找到对应记录(通过 医院+产品+型号+时间窗口 进行匹配)
|
||||
- **关联字段**:投诉.医院名称 → 不良事件.单位名称 | 投诉.产品名称 → 不良事件.产品名称 | 投诉.C3登记日期 → 不良事件.审核日期
|
||||
- **分析产出**:
|
||||
- 漏报率(投诉标记为不良事件但在 AE 表中找不到对应记录)
|
||||
- 上报时效(C3登记日期 vs 审核日期的时间差是否符合 15 天/紧急/45 天报告时限)
|
||||
- **合规价值**:直接回应 NMPA《医疗器械不良事件监测和再评价管理办法》中的上报义务
|
||||
|
||||
## 3 质量方向:产品质量提升分析
|
||||
|
||||
> 目标:通过数据驱动发现产品质量薄弱环节,指导 CAPA(纠正预防措施),提升产品可靠性。
|
||||
|
||||
> **指标落地文档**:[质量方向分析.md](./质量方向分析.md)(质量方向三层监测:批次溯源、产品/故障模式、趋势与升级路径;含指标编码、计算逻辑与页面用途)。
|
||||
|
||||
### 3.1 投诉集中性分析
|
||||
|
||||
基于质量投诉表。
|
||||
|
||||
a. 质量投诉的批号集中
|
||||
b. 质量投诉的医院集中性
|
||||
c. 质量投诉的产品集中性
|
||||
d. 质量投诉的事件集中性
|
||||
|
||||
**e. 产品 × 故障类型 Pareto 分析**
|
||||
|
||||
- **分析内容**:对投诉数据按 **产品 × 故障类型** 进行 Pareto(二八)分析
|
||||
- **关联字段**:投诉.产品名称 × 投诉.故障类型
|
||||
- **分析产出**:
|
||||
- 各产品 Top 3 故障类型及占比
|
||||
- 全局 Pareto 图:哪些产品-故障组合贡献了 80% 的投诉量
|
||||
- 聚焦 "渗漏""断裂""流速异常" 等高频故障的深层原因
|
||||
- **质量价值**:指导质量改进优先级——把资源集中在贡献最大的缺陷类型上
|
||||
|
||||
**f. 批号维度的集中性**
|
||||
|
||||
- **分析内容**:按批号统计投诉数量和不良事件数量,识别"问题批次"
|
||||
- **关联字段**:投诉.批号、不良事件.产品批号
|
||||
- **分析产出**:
|
||||
- 单批次投诉集中度分析(同一批号出现多次投诉)
|
||||
- 问题批次的故障类型集中度
|
||||
- **质量价值**:辅助生产过程质量回溯,触发批次调查
|
||||
|
||||
### 3.2 趋势分析
|
||||
|
||||
a. 首次出现报告的医院
|
||||
b. 首次出现报告的产品
|
||||
c. 过去一段时间内报告数量增长超过 20% 的产品
|
||||
d. 过去一段时间内报告数量增长超过 20% 的区域
|
||||
e. 批号维度的趋势:与上月对比,增长趋势超过 20% 的批号
|
||||
f. 批号时间分布:是否某些时间段生产的批次质量波动更大
|
||||
|
||||
### 3.3 投诉行为分析
|
||||
|
||||
a. 按医院投诉量与销售量关系
|
||||
|
||||
b. 按医院投诉量与首次入院时间的关系(新市场 / 新入院产品的投诉预警)
|
||||
|
||||
- **分析内容**:利用入院量数据中的 LY Qty(去年数量)字段,识别新入院(去年销量为 0)的产品-医院组合,关联其投诉发生率
|
||||
- **关联字段**:入院量(LY Qty=0 的记录 → HospitalName、MaterialDesc)× 投诉(医院名称、产品名称)
|
||||
- **分析产出**:
|
||||
- 新入院产品组合列表
|
||||
- 新入院 vs 存量产品的投诉率对比
|
||||
- 新入院后首次投诉的时间分析——是否在入院初期投诉集中
|
||||
- **营销价值**:为新产品入院制定标准化的培训与跟进计划,减少磨合期投诉
|
||||
|
||||
c. 医院投诉热点与销售增长的关联分析
|
||||
|
||||
- **分析内容**:分析投诉集中的医院,其销售增长趋势是否受到影响
|
||||
- **关联字段**:投诉(医院、时间)× 入院量(HospitalName、Growth% Amt、Growth% Qty)
|
||||
- **分析产出**:
|
||||
- 高投诉医院的销售增长率 vs 低投诉医院的销售增长率对比
|
||||
- 投诉率高的医院是否出现采购量下滑信号
|
||||
- 投诉后的销量变化趋势(投诉发生后 3-6 个月的入院量变化)
|
||||
- **营销价值**:量化投诉对客户关系和业务的影响,驱动客户关系修复
|
||||
|
||||
d. 投诉行为与处理结果的关系:是否赔偿与投诉动因关联
|
||||
|
||||
**e. 调查结论与产品/故障类型关系**
|
||||
|
||||
- **分析内容**:分析投诉调查结论的分布及其与产品、故障类型的关系
|
||||
- **关联字段**:投诉.调查结论 × 投诉.产品名称 × 投诉.故障类型
|
||||
- **分析产出**:
|
||||
- 调查结论分布:产品缺陷成立 vs 未复现 vs 操作不当 vs 运输损伤 vs 资料不足
|
||||
- **"产品缺陷成立"** 的投诉进一步按产品和故障类型细分,作为确认的质量问题池
|
||||
- **"操作不当"** 的投诉识别培训需求
|
||||
- **"运输损伤"** 的投诉识别供应链薄弱环节
|
||||
- **"资料不足"** 的投诉审视样品返回与信息收集流程的改进空间
|
||||
- **质量价值**:将模糊的"投诉"转化为分类明确的"质量改进项"
|
||||
|
||||
**f. 投诉处理成本分析**
|
||||
|
||||
- **分析内容**:分析赔付结论的分布及与产品、故障类型的关系
|
||||
- **关联字段**:投诉.赔付结论 × 投诉.产品名称 × 投诉.调查结论
|
||||
- **分析产出**:
|
||||
- 各赔付类型(换货/折让/退款/无赔付/其他)占比
|
||||
- "产品缺陷成立"结论下赔付方式分布
|
||||
- 按产品的赔付频率排名——质量成本最高的产品
|
||||
- **质量价值**:量化质量失败成本(COQ),驱动管理层投入改进资源
|
||||
|
||||
g. 高增长产品的质量风险前置预警(增长率与投诉率联动)
|
||||
h. 投诉关闭后重复投诉率(同医院同产品)分析
|
||||
i. 故障类型“首发时间”与“集中爆发时间”窗口识别
|
||||
j. CY LE AMT 与投诉率偏差分析(销量预估与质量压力)
|
||||
k. 样品返回数量与“产品缺陷成立”概率关系分析
|
||||
l. 故障类型与赔付方式联动矩阵分析
|
||||
m. 同产品不同型号的质量稳定性排名
|
||||
n. 运输损伤类投诉在不同经销商间差异对比
|
||||
o. 资料不足类投诉的信息缺口画像(缺失字段模式)
|
||||
p. 投诉联系人角色(护士/工程师/设备科)与故障类型偏好分析
|
||||
q. 多故障并发表现识别(同条投诉多症状共现)
|
||||
r. 投诉发生时间与法定节假日/医疗高峰期(如春节、国庆、住院高峰)关联分析
|
||||
s. 投诉描述文本中的潜在“批次/供应链异常”信号自动识别
|
||||
t. 历史投诉中“同产品同批次”发生多起投诉的聚类与溯源分析
|
||||
u. 投诉归口及流转效率分析——多节点审批/结案周期分布特征
|
||||
v. 投诉中客户反馈新需求/改进建议的文本挖掘与分类
|
||||
w. 投诉中的“医生/护士/患者”三方主诉分析,识别核心痛点归属
|
||||
x. 投诉样本照片/附件的结构化分析(如照片缺失率、图片内容自动标签)
|
||||
y. 同一医院内多品牌产品的投诉率交叉对比,识别品牌优势与短板
|
||||
|
||||
### 3.4 AE报告行为分析
|
||||
|
||||
#### 3.4.1 AE报告原因推测
|
||||
|
||||
a. 按医院报告量与销售量关系
|
||||
b. 按医院报告量与首次入院时间的关系
|
||||
c. 医院 AE 报告热点与销售增长的关联分析
|
||||
d. 医院报告与处理结果的关系:是否赔偿与报告动因关联
|
||||
e. 医院报告时间习惯分析:是否年底(如是,打上标签:为满足任务)
|
||||
f. 不同事业线不良事件升级率对比(投诉合并不良事件的概率)
|
||||
g. 不良事件与样品返回比例的相关性分析(证据链完整性)
|
||||
|
||||
#### 3.4.2 报告而不发起投诉原因分析
|
||||
|
||||
a. 事件名称
|
||||
b. 事件结局
|
||||
c. 报告时间
|
||||
d. 医院、区域
|
||||
|
||||
#### 3.4.3 报告且同时投诉事件分析
|
||||
|
||||
a. 事件名称
|
||||
b. 事件结局
|
||||
c. 调查原因
|
||||
|
||||
## 4 营销方向:市场沟通与推广分析
|
||||
|
||||
> 目标:从投诉和不良事件数据中发现可能由营销(使用培训不到位、推广方式、客户沟通)等因素导致的问题,反哺营销策略优化。
|
||||
|
||||
> **指标落地文档**:[营销方向分析.md](./营销方向分析.md)(MKT-OP / MKT-RG 指标编码、计算逻辑与页面呈现用途)。
|
||||
|
||||
### 4.1 操作不当与投诉
|
||||
|
||||
- **分析内容**:聚焦调查结论为 **"操作不当"** 的投诉,分析其在不同医院、不同经销商覆盖区域的分布
|
||||
- **关联字段**:投诉(调查结论=操作不当、医院名称)× 入院量(HospitalName、DealerName、Province)
|
||||
- **分析产出**:
|
||||
- 操作不当投诉的医院分布——哪些医院使用培训可能不到位
|
||||
- 通过入院量表关联经销商,识别哪些经销商区域操作不当投诉率较高
|
||||
- 操作不当投诉占比高的产品,可能存在产品使用说明不清晰或培训支持不足
|
||||
- **营销价值**:定向加强培训支持,优化产品操作培训材料,指导经销商管理
|
||||
|
||||
### 4.2 区域与经销商维度的投诉率比较
|
||||
|
||||
- **分析内容**:通过入院量数据中的省份和经销商信息,将投诉数据映射到区域和经销商维度
|
||||
- **关联字段**:投诉.医院名称 → 入院量.Province / City / DealerName
|
||||
- **分析产出**:
|
||||
- 各省份投诉率排名(投诉数/入院量)
|
||||
- 各经销商覆盖区域的投诉率排名
|
||||
- 识别投诉率异常高的区域——可能存在经销商储运不当或客户支持不足
|
||||
- **营销价值**:优化渠道管理,对高投诉率经销商进行专项辅导或考核
|
||||
|
||||
## 5 事件实质分析
|
||||
|
||||
### 5.1 伤害表现与器械故障的关联矩阵
|
||||
|
||||
- **分析内容**:构建 **伤害表现 × 器械故障表现** 的交叉矩阵(热力图),识别高频的伤害-故障组合;与 2.1.1 d 为同一分析主线,本节给出完整字段与产出口径
|
||||
- **关联字段**:不良事件.伤害表现 × 不良事件.器械故障表现
|
||||
- **分析产出**:
|
||||
- 哪些器械故障最容易导致严重伤害
|
||||
- 按产品分层后,各产品的典型故障-伤害模式
|
||||
- 对照监管关注的伤害类型(感染、出血、空气栓塞等)进行重点标注
|
||||
- **合规价值**:为不良事件风险评估提供量化依据,支撑 CAPA 优先级排序
|
||||
|
||||
### 5.2 故障类型季节性分析
|
||||
|
||||
高温/潮湿季节是否更易渗漏或包装问题等季节性模式分析。
|
||||
|
||||
## 6 安全维度客户画像
|
||||
|
||||
> 与跨维度结论汇总的衔接见 [综合分析.md](./综合分析.md) §1.2(§6 画像能力预留说明)。
|
||||
|
||||
- 高投诉但高复购医院的客户忠诚画像分析
|
||||
- 投诉赔付方式对后续采购增长的影响分析
|
||||
|
||||
## 7 综合联动分析
|
||||
|
||||
> **指标与结论文档**:[综合分析.md](./综合分析.md)(跨维度图表结论列表汇总、与 §6 画像扩展说明)。
|
||||
|
||||
- 构建“事件严重度-业务影响度”双轴优先级矩阵
|
||||
- 构建“医院分层经营策略”建议(高风险治理型/高潜力增长型)
|
||||
- 构建“质量成本-业务收益”平衡模型(赔付、退换、增长)
|
||||
- 构建“监管风险热区地图”(按产品、区域、时间)
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
# 质量方向分析(落地说明)
|
||||
|
||||
> 本文档由《贝朗医疗数据分析策略》**质量维度**拆分而来,面向质量、工程、供应链与 CAPA 负责人,用于指标口径统一、开发实现与页面文案设计。
|
||||
> 「质量方向」三层监测链路指:**批次溯源 → 产品与故障模式监测 → 趋势与行为预警**,将投诉与(必要时)不良事件、入院量联动,支撑缺陷优先级、批次调查与改进闭环。
|
||||
> 合规维度见 [合规方向分析.md](./合规方向分析.md);营销维度见 [营销方向分析.md](./营销方向分析.md)(主策略 §4)。
|
||||
|
||||
指标文档化要素(业务定义、可计算定义、维度、过滤、数据源、实现注意点、输出形态、页面用途)建议与 [合规方向分析.md](./合规方向分析.md) **§0** 保持一致,下文各表按「分析主题 → 指标清单」展开。
|
||||
|
||||
---
|
||||
|
||||
## 1 策略定位与数据范围
|
||||
|
||||
### 1.1 业务目标
|
||||
|
||||
通过数据驱动识别**产品质量薄弱环节**与**过程异常**(批号、产线、医院聚集等),指导 **CAPA** 与资源投入;输出可下钻至单条投诉/单批次的清单与趋势,并与(可选)不良事件交叉验证「市场反馈—监管报告」一致性。
|
||||
|
||||
### 1.2 适用对象
|
||||
|
||||
质量部、产品安全/可靠性工程、生产与供应链质量、医学事务(根因评审)、必要时联合 PV/合规做升级与漏报核对。
|
||||
|
||||
### 1.3 主数据对象
|
||||
|
||||
| 对象 | 典型用途 |
|
||||
|------|----------|
|
||||
| **质量投诉表** | 批号/医院/产品/故障类型集中性、调查结论与赔付、处理周期、重复投诉 |
|
||||
| **入院量表** | 投诉率分母(CY Qty / CY Amt 等)、新入院识别(LY Qty)、区域与经销商映射、销量增长 |
|
||||
| **不良事件表** | 批号维度与投诉/AE 双计数、升级路径佐证、与合规文档中矩阵类分析衔接 |
|
||||
|
||||
字段级清单与三表关联见工作区 `#数据与表结构.md`。
|
||||
|
||||
### 1.4 已定口径(全局默认,可与合规文档对齐)
|
||||
|
||||
以下适用于本文件全部指标,除非单条指标标注「例外」。
|
||||
|
||||
| 项目 | 建议口径 |
|
||||
|------|----------|
|
||||
| **投诉主时间轴** | 以 **`C3登记日期`** 作为趋势、集中性、率类指标的默认时间过滤字段;结案效率类指标可并列使用 **`关闭日期`** / **`调查报告完成日期`**,并在 UI 脚注区分。 |
|
||||
| **投诉统计单元** | **投诉条数**(以 **`C3编号`** 唯一;同屏不混用「例数」汇总除非单独定义)。 |
|
||||
| **集中性分析的有效集合** | 默认排除测试数据(若有标记字段);**投诉状态**可按业务选择「含调查中」或「仅已关闭」,全公司一致并在报表声明。 |
|
||||
| **投诉率分母** | 与入院量关联时,分母为同期、与投诉在 **医院 + 产品(或物料)** 对齐键上汇总的 **CY Qty 或 CY Amt**(二选一固化,与合规侧 PPM 分母策略协调,避免同一管理层看板两套分母字段)。 |
|
||||
| **新入院组合** | 入院量表中 **`LY Qty = 0`** 且当年 **`CY Qty > 0`** 的 **HospitalName + MaterialDesc(或产品名称映射)** 记为「新入院产品-医院组合」;与策略 §3.3 b 一致。 |
|
||||
| **环比增长阈值** | 策略原文「增长超过 20%」落地为:`(当期计数 − 对比期计数) / NULLIF(对比期计数, 0) ≥ 0.2`;**对比期**默认取「等长上一时间窗」(如本月 vs 上月、本季 vs 上季),可配置为同比。 |
|
||||
|
||||
**不良事件在质量方向分析中的角色**:用于 **批号双源对照**、**升级率** 及与合规侧 **伤害×故障矩阵**(见合规文档 §2.4 / 主策略 §5.1)的叙事衔接;AE 主时间轴仍以合规文件 §1.4 为准(**发生日期**),与投诉的 **C3登记日期** 混用须在页面标注双时间轴。
|
||||
|
||||
---
|
||||
|
||||
## 2 层级一:批次与供应链溯源
|
||||
|
||||
### 2.1 主题 A:批号集中与「问题批次」识别
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-BTH-101** | 批号投诉条数排名 | 按 **`批号`**(投诉表)分组,在 **`C3登记日期`** 落入统计期的集合内对 **投诉条数** 计数,降序取 TopN。空批号可单列「未填批号」桶。 | **条形图 + 下钻清单**:快速锁定高频问题批,触发批次调查流程。 |
|
||||
| **QLT-BTH-102** | 单批重复投诉率 | 对每条非空批号:`该批投诉条数 / 该批涉及医院去重数` 或 `该批投诉条数 / 该批产品入院量(若可对齐)`;二选一全司固化。**简单版**:批内投诉条数 ≥2 记为「重复投诉批次」并列表。 | **批次风险标签**:区分「单次孤立」与「同批多点爆发」。 |
|
||||
| **QLT-BTH-103** | 问题批次故障结构 | 在 QLT-BTH-101 选中的批号子集上,对 **`故障类型`** 计数及占比。 | **堆叠条或饼图**:支撑根因假设(工艺 vs 运输 vs 原料)。 |
|
||||
| **QLT-BTH-104** | 投诉批号 × AE 产品批号双计数 | 同一统计期内:投诉侧按 **`批号`** 计数;AE 侧按 **`产品批号`** 计数。通过 **产品名称(及可选型号)** 弱关联后并列展示(不要求逐条匹配)。 | **对照表**:质量与 PV 共看「同一产品批是否双通道上升」。 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 主题 B:批号趋势与生产时段波动
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-BTH-201** | 批号投诉量环比增速 | 按批号聚合 **`C3登记日期`** 落入「本月」与「上月」的投诉条数,计算环比增速;筛选增速 **≥20%**(见 §1.4)的批号列表。 | **预警列表**:优先列入周质量例会。 |
|
||||
| **QLT-BTH-202** | 批号首诉至峰值间隔 | 对每一批号:`首条 C3登记日期` 与 `该批投诉条数达峰月份` 的间隔(天/周)。 | **过程能力参考**:评估从露头到爆发的响应窗口是否足够。 |
|
||||
| **QLT-BTH-203** | 生产/入库时段与批号质量波动(探索性) | 若有 **`批号→生产日期`** 映射表则按生产周/月聚合投诉条数;否则用 **C3登记月** 作为代理时间轴对高投诉批号聚类。**注意**:代理轴与真实生产周混淆风险须在脚注说明。 | **季节/产线波动图**:与主策略 §5.2「故障季节性」联动时,优先在 **故障类型 × 月份** 上复用同一套时间桶。 |
|
||||
|
||||
---
|
||||
|
||||
## 3 层级二:产品与故障模式(集中性与根因池)
|
||||
|
||||
### 3.1 主题 C:多维集中性(批号 / 医院 / 产品 / 事件)
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-CNC-301** | 医院投诉集中度(HHI 或 TopN 占比) | 按 **`医院名称`** 计数投诉条数;可计算 **HHI** = Σ(份额²) 或 **Top5 医院占比**。时间过滤:**`C3登记日期`**。 | **地图/条形图**:识别「单点高压」医院,安排现场或培训。 |
|
||||
| **QLT-CNC-302** | 产品投诉集中度 | 按 **`产品名称`**(或映射到 **MaterialDesc**)计数;可叠加 **BU** 分层。 | **Pareto 条形图**:与 QLT-PRD 系列共用产品排序逻辑。 |
|
||||
| **QLT-CNC-303** | 事件/故障类型集中度 | 按 **`故障类型`** 或从 **`投诉详情`** 抽取的事件标签(若后续有 NLP)计数;Top10 及累计占比达 80% 的截断线(Pareto)。 | **全局缺陷雷达**:与 §3.2 主题 E 的「产品×故障」互补(一维 vs 二维)。 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 主题 D:产品 × 故障类型 Pareto(二八分析)
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-PRD-401** | 组合键投诉条数 | 维度 = **`产品名称` × `故障类型`**,统计期内对 **投诉条数** 求和;按条数降序。 | **Pareto 图(横轴为组合)**:展示「80% 投诉由哪些组合贡献」。 |
|
||||
| **QLT-PRD-402** | 各产品 Top3 故障类型及占比 | 在每个 **`产品名称`** 内,对 **`故障类型`** 取 Top3,占比 = 该类型条数 / 该产品总投诉条数。 | **产品卡片矩阵**:研发与工程按产品认领改进项。 |
|
||||
| **QLT-PRD-403** | 高频故障聚焦清单 | 在全局或 BU 子集上,筛选 **`故障类型`** ∈ {渗漏, 断裂, 流速异常, …}(码表可配置)且条数超阈值的组合。 | **CAPA 输入池**:与主策略 §3.1 e 示例故障对齐。 |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 主题 E:调查结论与质量改进项池
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-INV-501** | 调查结论分布 | 对 **`调查结论(处理结果)`** 计数及占比(产品缺陷成立 / 未复现 / 操作不当 / 运输损伤 / 资料不足等)。时间:**`C3登记日期`** 或 **`调查报告完成日期`**(二选一做「调查产出」视图时固定)。 | **堆叠柱(按时间)**:看结论结构是否恶化(如「产品缺陷成立」上升)。 |
|
||||
| **QLT-INV-502** | 「产品缺陷成立」× 产品 × 故障 | 子集:`调查结论` = **产品缺陷成立**;按 **产品 × 故障类型** 计数。 | **已确认质量问题池**:DFMEA/PFMEA 与设计变更的输入。 |
|
||||
| **QLT-INV-503** | 「操作不当」× 医院 / 产品 | 子集:`调查结论` = **操作不当**;按医院或产品聚合。 | **培训需求清单**:与营销 §4.1 可共用数据,页面受众不同。 |
|
||||
| **QLT-INV-504** | 「运输损伤」× 经销商(若可关联) | 子集:`调查结论` = **运输损伤**;通过入院量 **`DealerName`** 与投诉医院映射后聚合。 | **物流薄弱环节看板**:与主策略 §3.3 n 一致方向。 |
|
||||
| **QLT-INV-505** | 「资料不足」字段缺失模式 | 统计「资料不足」子集中 **`有样品返回`**、附件、关键文本为空等模式(字段可用时)。 | **流程 IT 改进**:减少反复补件。 |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 主题 F:质量失败成本(赔付与产品)
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-CST-601** | 赔付方式分布 | 对 **`赔付结论`**(换货/折让/退款/无赔付/其他)计数及占比。 | **COQ 概览卡片**:管理层看质量失败外显成本结构。 |
|
||||
| **QLT-CST-602** | 产品缺陷成立下的赔付结构 | 在 QLT-INV-502 同子集上交叉 **`赔付结论`**。 | **条形堆叠**:识别「高赔付+高缺陷成立」产品。 |
|
||||
| **QLT-CST-603** | 按产品的赔付频次排名 | 按 **`产品名称`** 统计「非无赔付」条数或加权分数(若金额字段未来接入则替换为金额)。 | **质量成本最高产品榜**:资源分配与退市/改版讨论依据。 |
|
||||
|
||||
---
|
||||
|
||||
## 4 层级三:趋势、行为与升级路径
|
||||
|
||||
### 4.1 主题 G:首次报告与增长预警
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-TRN-701** | 首次出现投诉的医院 | 对每个 **`医院名称`**,取 **最小 `C3登记日期`** 落在统计期内且该医院历史(全量或滚动三年)此前无投诉则标记「首诉医院」。 | **新风险点清单**:客户成功或质量拜访优先级。 |
|
||||
| **QLT-TRN-702** | 首次出现投诉的产品 | 同理对 **`产品名称`**(或物料)判定「首诉产品」。 | **新产品/新适应症暴露** 监测。 |
|
||||
| **QLT-TRN-703** | 产品投诉量环比增长超 20% | 产品维度下对比 **等长两窗** 投诉条数,增速公式见 §1.4;输出超阈产品列表。 | **趋势预警表**:与主策略 §3.2 c 一致。 |
|
||||
| **QLT-TRN-704** | 区域(省)投诉量环比增长超 20% | 投诉经医院映射 **`Province`**(入院量)后聚合;环比同 §1.4。 | **区域热力 + 列表**:供应链或渠道协同。 |
|
||||
|
||||
---
|
||||
|
||||
### 4.2 主题 H:投诉关闭与重复投诉
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-RPT-801** | 同医院同产品重复投诉率 | 定义「重复」:`医院名称 + 产品名称` 在 **`关闭日期`** 后再次 **`C3登记日期`** 的新投诉(窗口如 90/180 天可配置)。**重复率** = 重复组合数 / 有关闭记录的组合数(定义需版本化)。 | **闭环有效性 KPI**:CAPA 是否真正止血。 |
|
||||
| **QLT-RPT-802** | 投诉关闭周期分布 | `关闭日期 − C3登记日期`(天),在已关闭子集上直方图/箱线图。 | **流程效率**:与主策略 §3.3 u 方向一致。 |
|
||||
|
||||
---
|
||||
|
||||
### 4.3 主题 I:入院量联动的质量信号(投诉率与新入院)
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-SAL-901** | 医院×产品投诉率 | **分子**:统计期内该 **医院+产品** 投诉条数(**`C3登记日期`**)。**分母**:同期入院量 **CY Qty**(或 Amt)在相同对齐键上汇总。无分母时灰显。 | **散点或四象限**:识别「低销量高投诉」异常点。 |
|
||||
| **QLT-SAL-902** | 新入院组合投诉率 vs 存量 | 新入院定义见 §1.4;分别计算新入院组合与存量的 **投诉率**(分子分母同 QLT-SAL-901)。 | **新市场磨合风险**:与主策略 §3.3 b 营销价值并列展示时脚注受众。 |
|
||||
| **QLT-SAL-903** | 新入院后首诉时间 | 对每个新入院组合,首条投诉的 **`C3登记日期`** 与入院首月的间隔分布。 | **入院后 30/60/90 天** 关怀与巡检依据。 |
|
||||
|
||||
---
|
||||
|
||||
### 4.4 主题 J:AE 升级与证据链(与合规衔接)
|
||||
|
||||
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|
||||
|----------|----------|----------------|--------------|
|
||||
| **QLT-AE-1001** | 投诉合并为不良事件比例(升级率) | **`是否不良事件 = 是`** 的投诉条数 / 总投诉条数(时间窗内);可按 **BU** 分层。 | **升级文化/流程** 对比:各事业线是否愿报、敢报。 |
|
||||
| **QLT-AE-1002** | 样品返回与调查结论关系 | 交叉 **`有样品返回`** / **`上报坏品数量`** 与 **`调查结论`**(卡方或简单占比表)。 | **证据链完整性**:与主策略 §3.4.1 g 一致。 |
|
||||
| **QLT-AE-1003** | 医院 AE 报告量 vs 入院量 | AE 按 **`单位名称`** 与 **发生日期**(合规口径)计数;入院量同院同期分母;散点。**注意**:与投诉率时间轴不同,页面须双脚注。 | **报告行为异常点**(非直接质量结论):识别需复核的报送模式。 |
|
||||
|
||||
**漏报与上报时效**:严格合规 KPI 仍以 [合规方向分析.md](./合规方向分析.md) **§3.1**(`CMP-CV-101`~`104` 等)为准;质量方向分析视图可 **嵌入同一语义指标** 或链接跳转,避免重复定义。
|
||||
|
||||
---
|
||||
|
||||
## 5 扩展分析主题(指标池与优先级建议)
|
||||
|
||||
主策略 §3.3 中 **g–y**、§3.4.2、§3.4.3、§5.2 等属于**深化与探索**能力,建议按价值—难度矩阵分期:
|
||||
|
||||
| 分期 | 主题摘要 | 说明 |
|
||||
|------|----------|------|
|
||||
| **一期** | 高增长产品投诉联动、重复投诉、关闭周期、文本关键词规则(批次/供应链信号) | 与现有字段强相关,易验收。 |
|
||||
| **二期** | 故障首发/爆发窗口、CY LE AMT 偏差、多故障并发、经销商运输对比 | 需更多时间窗配置或统计规则。 |
|
||||
| **三期** | 投诉描述 NLP、附件/照片结构化、跨品牌医院对比 | 依赖模型与外部数据。 |
|
||||
|
||||
**季节性(主策略 §5.2)**:建议落地为 **`故障类型` × `月份`** 的投诉条数热力图,时间轴用 **`C3登记月份`**;与 **QLT-BTH-203** 共用月份维度以便对照。
|
||||
|
||||
---
|
||||
|
||||
## 6 与主策略文档的对应关系
|
||||
|
||||
| 主策略章节 | 本文档章节 |
|
||||
|------------|------------|
|
||||
| §3.1 投诉集中性(含 Pareto、批号与 AE) | §2.1、§3.1–§3.2 |
|
||||
| §3.2 趋势分析 | §2.2、§4.1 |
|
||||
| §3.3 投诉行为(调查结论、成本、重复投诉等) | §3.3–§3.4、§4.2、§4.3;营销侧重条目见主策略 §4 |
|
||||
| §3.4 AE 报告行为(与质量方向交叉部分) | §4.4 |
|
||||
| §5.2 故障类型季节性 | §5 扩展分期说明 |
|
||||
|
||||
---
|
||||
|
||||
## 7 仍建议在数据模型层固化的细节
|
||||
|
||||
1. **投诉 ↔ 入院量产品对齐键**:`产品名称` 精确匹配 vs **MaterialDesc** 映射表;未匹配入桶规则。
|
||||
2. **批号为空率**与是否强制校验(影响 QLT-BTH-* 覆盖率)。
|
||||
3. **`调查结论`、`赔付结论`** 码表与中英文口径统一(ETL 清洗规则版本化)。
|
||||
4. **重复投诉** 的时间窗与「同一条重开」业务规则(是否算 1 条还是 2 条)。
|
||||
5. **QLT-AE-1003** 与合规侧 AE 统计单元(报告条数)一致,避免与投诉条数混读。
|
||||
|
||||
---
|
||||
|
||||
*文档版本:与主策略 §3 质量方向及 `#数据与表结构.md` 字段对齐;前端演示见 `analytics-demo-web` 质量方向与事件实质菜单;具体数仓表名与刷新 SLA 可另页维护。*
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,185 @@
|
|||
#数据与表结构
|
||||
## 一、数据概览
|
||||
|
||||
### 1.1 数据源
|
||||
|
||||
| 数据表 | Sheet | 记录数 | 时间范围 | 核心内容 |
|
||||
|--------|-------|--------|----------|----------|
|
||||
| 不良事件数据 | POWER BI 总信息 | 1,000 条 | 2023-12 ~ 2026-04 | 上报至监管的不良事件记录 |
|
||||
| 入院量数据 | Sheet1 | 1,000 条 | 2024 ~ 2026(按月) | 各医院各产品的销售金额与数量 |
|
||||
| 质量投诉数据 | complaint form | 1,000 条 | 2023-08 ~ 2026-04 | 客户质量投诉的全生命周期记录 |
|
||||
|
||||
所有数据为模拟数据,并且假设均已清理、规整完毕。
|
||||
|
||||
### 1.2 各表字段清单
|
||||
|
||||
**不良事件表(13 字段)**
|
||||
|
||||
| 字段 | 说明 | 典型值 |
|
||||
|------|------|--------|
|
||||
| 报告编码 | 不良事件唯一编号 | SIM-2024-000001 |
|
||||
| 单位名称 | 报告医院 | 15 家三甲医院 |
|
||||
| 事业线 | 产品所属事业线 | 外科产品、透析产品、输液治疗、诊断/监测耗材 |
|
||||
| 产品名称 | 涉及产品 | 13 种产品 |
|
||||
| 注册证编号 | 产品注册证号 | 国械注进/国械注准 |
|
||||
| 注册人 | 注册持有人 | 贝朗医疗(苏州)有限公司 |
|
||||
| 型号 | 产品型号 | 30 种型号 |
|
||||
| 产品批号 | 生产批号 | — |
|
||||
| 伤害 | 是否造成伤害 | 全部为"是" |
|
||||
| 伤害表现 | 伤害的临床表现 | 35 种表现(感染、出血、疼痛、静脉炎等) |
|
||||
| 器械故障表现 | 器械故障描述 | 53 种故障表现 |
|
||||
| 审核日期 | 事件审核日期 | — |
|
||||
|
||||
**入院量数据(24 字段)**
|
||||
|
||||
| 字段 | 说明 | 典型值 |
|
||||
|------|------|--------|
|
||||
| Year / Month | 销售年月 | 2024–2026 |
|
||||
| HospitalName / HospitalCode | 医院名称及代码 | 10 家医院 |
|
||||
| DealerName / DealerCode | 经销商名称及代码 | 8 家经销商 |
|
||||
| Province / City | 省市 | 10 省市 |
|
||||
| GlobalDivision | 全球事业部 | Avitum、Hospital Care、Aesculap |
|
||||
| LocalDivision | 本地事业部 | 透析、输液治疗、外科 |
|
||||
| BU | 业务单元 | Renal Care BU、IV Therapy BU、Surgical BU |
|
||||
| ProductLine | 产品线 | 透析耗材、静脉输注、缝线与外科耗材、营养输注 |
|
||||
| ProductLineType | 产品线类型 | 透析器、留置针、可吸收缝线等 8 种 |
|
||||
| Material / MaterialDesc | 物料号 / 物料描述 | 8 种物料 |
|
||||
| CY Amt / LY Amt | 当年/去年销售金额 | — |
|
||||
| Growth Amt / Growth% Amt | 金额增长及增长率 | — |
|
||||
| CY Qty / LY Qty | 当年/去年销售数量 | — |
|
||||
| Growth Qty / Growth% Qty | 数量增长及增长率 | — |
|
||||
| CY LE AMT | 当年最新预测金额 | — |
|
||||
|
||||
**质量投诉数据(31 字段)**
|
||||
|
||||
| 字段 | 说明 | 典型值 |
|
||||
|------|------|--------|
|
||||
| C3编号 | 投诉唯一编号 | C3-2026-000001 |
|
||||
| 型号 / 批号 / 序列号 | 产品标识 | — |
|
||||
| 生产企业名称 | 生产企业 | 贝朗医疗(苏州/上海) |
|
||||
| 注册证号 | 产品注册证 | 国械注进/国械注准 |
|
||||
| 产品名称 | 投诉产品 | 5 种核心产品 |
|
||||
| 医院名称 | 投诉医院 | 10 家医院 |
|
||||
| 投诉联系人 / 联系人电话 | 医院端联系方式 | — |
|
||||
| 故障类型 | 故障大类 | 渗漏、断裂、流速异常、包装破损、连接不牢、堵塞、标签不清 |
|
||||
| 投诉详情(中文) | 故障详细描述 | 自由文本 |
|
||||
| 上报人 | 公司内上报人 | 护理部、临床工程、设备科、质控办 |
|
||||
| BU | 业务单元 | Renal Care BU、IV Therapy BU、Surgical BU |
|
||||
| C3登记日期 / C3登记月 | 投诉登记时间 | — |
|
||||
| 是否不良事件 | 是否升级为不良事件 | 是(252条) / 否(748条) |
|
||||
| 不良事件(否) | 未升级原因 | 未造成患者伤害、使用前发现、仅质量缺陷 |
|
||||
| 上报坏品数量 / 坏品退回QA / 退回原厂 | 样品退回链路数量 | — |
|
||||
| 调查报告完成日期 | 调查完成时间 | — |
|
||||
| 调查报告(处理意见) | 英文处理意见 | Need more info、No defect found、Training reinforced |
|
||||
| 调查报告中文(处理意见) | 中文处理意见 | — |
|
||||
| 调查结论(处理结果) | 调查结论 | 产品缺陷成立、未复现、操作不当、运输损伤、资料不足 |
|
||||
| 赔付结论 | 赔付方式 | 换货、折让、退款、无赔付、其他协商处理 |
|
||||
| 关闭日期 | 投诉关闭时间 | — |
|
||||
| 投诉状态 | 当前状态 | 已关闭、调查中、新建、待补充 |
|
||||
| 有样品返回 | 是否有样品退回 | 是/— |
|
||||
| 事业部 | 中文事业部名称 | 透析、输液治疗、外科 |
|
||||
| 例数 | 涉及患者例数 | — |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据关系梳理
|
||||
|
||||
### 2.1 三张表用途说明
|
||||
- 不良事件数据表来源于监管机构,最初报告者是医院,由医院向监管机构报告,监管机构再将报告创数给器械企业;
|
||||
- 医院报告时间的原因有多种:
|
||||
-确实为值得关注的不良事件,希望引起企业的重视;
|
||||
-为了满足报告数量要求,满足监管部门的任务;
|
||||
-与患者发生纠纷,将事件及时告知。
|
||||
|
||||
### 2.2 入院数量表
|
||||
- 本表数据为企业提供,数据准确;
|
||||
|
||||
### 2.3 质量投诉数据
|
||||
- 数据真实性可以接受,不用担心虚假数据;
|
||||
- 可能存在即为质量投诉,同时出现在不良事件报告中的数据。
|
||||
|
||||
|
||||
### 2.1 三表关联关系图
|
||||
|
||||
|
||||
#### 三表数据关联分析
|
||||
|
||||
1. **质量投诉数据 ↔ 入院量数据**
|
||||
- 分析投诉率(某产品/医院的投诉数 ÷ 入院量),评估不同医院、产品或时间段下的投诉发生频率。
|
||||
- 关键关联字段:医院名称、产品名称(需模糊匹配)、BU/事业部。
|
||||
|
||||
2. **不良事件数据 ↔ 入院量数据**
|
||||
- 用于计算不良事件发生率,观察事件报告的分布与产品/医院相关性。
|
||||
- 关键关联字段:医院名称、产品名称(需模糊匹配)、BU/事业部。
|
||||
|
||||
3. **质量投诉数据 ↔ 不良事件数据**
|
||||
- 判断是否存在“同一投诉已升级为不良事件”的重合情形。重合部分有更高分析价值,可用于过程追溯和根因分析。
|
||||
- 关键字段:投诉唯一编号、是否不良事件、医院名称、产品名称、注册证号、型号、批号、时间等。
|
||||
|
||||
---
|
||||
|
||||
#### 结构与字段关系交互图
|
||||
|
||||
```
|
||||
(1) (3)
|
||||
┌───────────────┐ 医院名称/产品名称 ┌───────────────┐
|
||||
│ │◄──────────────────────────────────│ │
|
||||
│ 入院量数据 │ │ 质量投诉数据 │
|
||||
│ (Sales Data) │──────────────────────────────────►│ (Complaint) │
|
||||
│ │ 医院名称/产品名称/BU/事业部 │ │
|
||||
└───────────────┘ └───────────────┘
|
||||
▲ │
|
||||
│ │
|
||||
│ │
|
||||
│ (2) 是否不良事件/投诉编号/产品信息/时间
|
||||
│ │
|
||||
┌───────────────┐ 医院名称/产品/时间等 ┌──────┴────────┐
|
||||
│ │◄─────────────────────────────────│ │
|
||||
│ 不良事件数据 │ │ 质量投诉数据 │
|
||||
│ (AdverseEvent)│─────────────────────────────────►│ (Complaint) │
|
||||
│ │ 医院名称/产品/BU/事业部 │ │
|
||||
└───────────────┘ └───────────────┘
|
||||
▲
|
||||
│
|
||||
│
|
||||
医院名称/产品名称/BU/事业部
|
||||
│
|
||||
┌───────────────┐
|
||||
│ 入院量数据 │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### 2.2 关键关联字段详解
|
||||
|
||||
#### (一)强关联字段(可直接 Join)
|
||||
|
||||
| 关联维度 | 表 A 字段 | 表 B 字段 | 匹配方式 | 重叠情况 |
|
||||
|----------|-----------|-----------|----------|----------|
|
||||
| **医院** | 入院量.HospitalName | 投诉.医院名称 | 精确匹配 | 10 家医院完全重叠 |
|
||||
| **医院** | 入院量.HospitalName | 不良事件.单位名称 | 精确匹配 | 10 家重叠(不良事件多 5 家) |
|
||||
| **医院** | 投诉.医院名称 | 不良事件.单位名称 | 精确匹配 | 10 家完全重叠 |
|
||||
| **BU** | 入院量.BU | 投诉.BU | 精确匹配 | 3 个 BU 完全一致 |
|
||||
| **事业部** | 入院量.LocalDivision | 投诉.事业部 | 精确匹配 | 透析、输液治疗、外科 |
|
||||
| **型号** | 不良事件.型号 | 投诉.型号 | 精确匹配 | 7 个型号重叠 |
|
||||
|
||||
|
||||
#### (二)投诉 → 不良事件的上下游关系
|
||||
|
||||
质量投诉数据中的 **"是否不良事件"** 字段是连接投诉表与不良事件表的核心业务逻辑:
|
||||
- **"是"(252 条,25.2%)**:该投诉已升级为不良事件,理论上在不良事件表中应有对应记录
|
||||
- **"否"(748 条,74.8%)**:未升级,原因记录在"不良事件(否)"字段
|
||||
|
||||
### 2.3 数据维度层级关系
|
||||
|
||||
```
|
||||
GlobalDivision (Avitum / Hospital Care / Aesculap)
|
||||
└─ LocalDivision / 事业部 (透析 / 输液治疗 / 外科)
|
||||
└─ BU (Renal Care BU / IV Therapy BU / Surgical BU)
|
||||
└─ ProductLine (透析耗材 / 静脉输注 / 缝线与外科耗材 / 营养输注)
|
||||
└─ ProductLineType (透析器 / 留置针 / 可吸收缝线 / ...)
|
||||
└─ Material / 产品名称 (血液透析器 1.4m² / ...)
|
||||
└─ 型号 (HD-180 / Introcan Safety 20G / ...)
|
||||
└─ 批号 (单个生产批次)
|
||||
```
|
||||
|
||||
---
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,314 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""按“生成入院量模拟数据.md”生成入院量模拟数据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
HEADERS = [
|
||||
"Year",
|
||||
"Month",
|
||||
"HospitalName",
|
||||
"HospitalCode",
|
||||
"DealerName",
|
||||
"DealerCode",
|
||||
"Province",
|
||||
"City",
|
||||
"GlobalDivision",
|
||||
"LocalDivision",
|
||||
"BU",
|
||||
"ProductLine",
|
||||
"ProductLineType",
|
||||
"Material",
|
||||
"MaterialDesc",
|
||||
"CY Amt",
|
||||
"LY Amt",
|
||||
"Growth Amt",
|
||||
"Growth% Amt",
|
||||
"CY Qty",
|
||||
"LY Qty",
|
||||
"Growth Qty",
|
||||
"Growth% Qty",
|
||||
"CY LE AMT",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hospital:
|
||||
name: str
|
||||
code: str
|
||||
province: str
|
||||
city: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dealer:
|
||||
name: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaterialProfile:
|
||||
global_div: str
|
||||
local_div: str
|
||||
bu: str
|
||||
product_line: str
|
||||
product_line_type: str
|
||||
material: str
|
||||
material_desc: str
|
||||
unit_price_low: float
|
||||
unit_price_high: float
|
||||
weight: int
|
||||
|
||||
|
||||
HOSPITALS = [
|
||||
Hospital("上海市第一人民医院", "H310001", "上海市", "上海市"),
|
||||
Hospital("浙江大学医学院附属第二医院", "H330001", "浙江省", "杭州市"),
|
||||
Hospital("四川大学华西医院", "H510001", "四川省", "成都市"),
|
||||
Hospital("广东省人民医院", "H440001", "广东省", "广州市"),
|
||||
Hospital("中南大学湘雅医院", "H430001", "湖南省", "长沙市"),
|
||||
Hospital("山东大学齐鲁医院", "H370001", "山东省", "济南市"),
|
||||
Hospital("西安交通大学第一附属医院", "H610001", "陕西省", "西安市"),
|
||||
Hospital("南京鼓楼医院", "H320001", "江苏省", "南京市"),
|
||||
Hospital("福建省立医院", "H350001", "福建省", "福州市"),
|
||||
Hospital("郑州大学第一附属医院", "H410001", "河南省", "郑州市"),
|
||||
]
|
||||
|
||||
DEALERS = [
|
||||
Dealer("上海睿康医疗器械有限公司", "D10001"),
|
||||
Dealer("浙江和信医药科技有限公司", "D10002"),
|
||||
Dealer("广州安泰医疗供应链有限公司", "D10003"),
|
||||
Dealer("成都优诺医疗器械有限公司", "D10004"),
|
||||
Dealer("南京博瑞医疗科技有限公司", "D10005"),
|
||||
Dealer("济南康惠医疗设备有限公司", "D10006"),
|
||||
Dealer("西安同泽医药贸易有限公司", "D10007"),
|
||||
Dealer("福州海润医疗器械有限公司", "D10008"),
|
||||
]
|
||||
|
||||
MATERIALS = [
|
||||
MaterialProfile(
|
||||
"Hospital Care",
|
||||
"输液治疗",
|
||||
"IV Therapy BU",
|
||||
"静脉输注",
|
||||
"留置针",
|
||||
"MAT203145",
|
||||
"一次性使用静脉留置针 20G",
|
||||
36,
|
||||
58,
|
||||
160,
|
||||
),
|
||||
MaterialProfile(
|
||||
"Hospital Care",
|
||||
"输液治疗",
|
||||
"IV Therapy BU",
|
||||
"静脉输注",
|
||||
"输液器",
|
||||
"MAT203148",
|
||||
"一次性使用精密过滤输液器",
|
||||
22,
|
||||
40,
|
||||
150,
|
||||
),
|
||||
MaterialProfile(
|
||||
"Hospital Care",
|
||||
"输液治疗",
|
||||
"IV Therapy BU",
|
||||
"静脉输注",
|
||||
"注射器",
|
||||
"MAT203166",
|
||||
"一次性使用无菌注射器 20ml",
|
||||
4.5,
|
||||
8.5,
|
||||
110,
|
||||
),
|
||||
MaterialProfile(
|
||||
"Avitum",
|
||||
"透析",
|
||||
"Renal Care BU",
|
||||
"透析耗材",
|
||||
"透析器",
|
||||
"MAT301201",
|
||||
"血液透析器 1.4m²",
|
||||
145,
|
||||
230,
|
||||
130,
|
||||
),
|
||||
MaterialProfile(
|
||||
"Avitum",
|
||||
"透析",
|
||||
"Renal Care BU",
|
||||
"透析耗材",
|
||||
"透析管路",
|
||||
"MAT301216",
|
||||
"血液透析管路 AV-SET",
|
||||
85,
|
||||
130,
|
||||
90,
|
||||
),
|
||||
MaterialProfile(
|
||||
"Aesculap",
|
||||
"外科",
|
||||
"Surgical BU",
|
||||
"缝线与外科耗材",
|
||||
"可吸收缝线",
|
||||
"MAT402018",
|
||||
"可吸收性外科缝线 3-0",
|
||||
120,
|
||||
180,
|
||||
95,
|
||||
),
|
||||
MaterialProfile(
|
||||
"Aesculap",
|
||||
"外科",
|
||||
"Surgical BU",
|
||||
"缝线与外科耗材",
|
||||
"非吸收缝线",
|
||||
"MAT402026",
|
||||
"非吸收性外科缝线 4-0",
|
||||
88,
|
||||
150,
|
||||
85,
|
||||
),
|
||||
MaterialProfile(
|
||||
"Hospital Care",
|
||||
"输液治疗",
|
||||
"IV Therapy BU",
|
||||
"营养输注",
|
||||
"肠内营养器械",
|
||||
"MAT203199",
|
||||
"一次性使用肠内营养输液器",
|
||||
26,
|
||||
44,
|
||||
70,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def weighted_materials() -> list[MaterialProfile]:
|
||||
out: list[MaterialProfile] = []
|
||||
for m in MATERIALS:
|
||||
out.extend([m] * m.weight)
|
||||
return out
|
||||
|
||||
|
||||
def quarter_factor(month: int) -> float:
|
||||
if month in (10, 11, 12):
|
||||
return 1.08
|
||||
if month in (7, 8, 9):
|
||||
return 1.03
|
||||
return 1.0
|
||||
|
||||
|
||||
def generate_rows(n: int, seed: int) -> list[list]:
|
||||
rng = random.Random(seed)
|
||||
mat_pool = weighted_materials()
|
||||
years = [2024, 2025, 2026]
|
||||
rows: list[list] = []
|
||||
|
||||
for _ in range(n):
|
||||
year = rng.choice(years)
|
||||
month = rng.randint(1, 12)
|
||||
hospital = rng.choice(HOSPITALS)
|
||||
dealer = rng.choice(DEALERS)
|
||||
mat = rng.choice(mat_pool)
|
||||
|
||||
# 数量:近似反映季度小幅波动
|
||||
base_cy_qty = rng.randint(120, 2200)
|
||||
cy_qty = int(round(base_cy_qty * quarter_factor(month)))
|
||||
|
||||
ly_is_zero = rng.random() < 0.05
|
||||
if ly_is_zero:
|
||||
ly_qty = 0
|
||||
else:
|
||||
change_ratio_qty = rng.uniform(-0.28, 0.38)
|
||||
ly_qty = max(1, int(round(cy_qty / (1 + change_ratio_qty))))
|
||||
|
||||
growth_qty = cy_qty - ly_qty
|
||||
growth_pct_qty = 0 if ly_qty == 0 else round(growth_qty / ly_qty, 4)
|
||||
|
||||
# 金额:与数量和物料单价相关,保证可解释性
|
||||
unit_price_cy = rng.uniform(mat.unit_price_low, mat.unit_price_high)
|
||||
cy_amt = round(cy_qty * unit_price_cy, 2)
|
||||
|
||||
if ly_qty == 0:
|
||||
ly_amt = 0.0
|
||||
else:
|
||||
# 同一物料年度单价一般平稳波动
|
||||
unit_price_ly = unit_price_cy * rng.uniform(0.92, 1.08)
|
||||
ly_amt = round(ly_qty * unit_price_ly, 2)
|
||||
|
||||
growth_amt = round(cy_amt - ly_amt, 2)
|
||||
growth_pct_amt = 0 if ly_amt == 0 else round(growth_amt / ly_amt, 4)
|
||||
cy_le_amt = round(cy_amt * rng.uniform(0.90, 1.15), 2)
|
||||
|
||||
rows.append(
|
||||
[
|
||||
year,
|
||||
month,
|
||||
hospital.name,
|
||||
hospital.code,
|
||||
dealer.name,
|
||||
dealer.code,
|
||||
hospital.province,
|
||||
hospital.city,
|
||||
mat.global_div,
|
||||
mat.local_div,
|
||||
mat.bu,
|
||||
mat.product_line,
|
||||
mat.product_line_type,
|
||||
mat.material,
|
||||
mat.material_desc,
|
||||
cy_amt,
|
||||
ly_amt,
|
||||
growth_amt,
|
||||
growth_pct_amt,
|
||||
cy_qty,
|
||||
ly_qty,
|
||||
growth_qty,
|
||||
growth_pct_qty,
|
||||
cy_le_amt,
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
today = date.today()
|
||||
out_name = f"入院量数据-模拟1000条-{today:%Y%m%d}.xlsx"
|
||||
out_path = ROOT / out_name
|
||||
rows = generate_rows(1000, int(today.strftime("%Y%m%d")))
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sheet1"
|
||||
ws.append(HEADERS)
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
|
||||
# 格式设置:金额/增长率显示友好
|
||||
amt_cols = ["P", "Q", "R", "X"]
|
||||
pct_cols = ["S", "W"]
|
||||
for r in range(2, 1002):
|
||||
for col in amt_cols:
|
||||
ws[f"{col}{r}"].number_format = "#,##0.00"
|
||||
for col in pct_cols:
|
||||
ws[f"{col}{r}"].number_format = "0.00%"
|
||||
|
||||
for idx, _ in enumerate(HEADERS, start=1):
|
||||
ws.column_dimensions[get_column_letter(idx)].width = 16
|
||||
|
||||
wb.save(out_path)
|
||||
print(f"Written: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""生成贝朗相关产品模拟不良事件数据(合成数据,仅供分析/培训)。
|
||||
|
||||
依据:贝朗数据/生成不良事件模拟数据.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
HEADERS = [
|
||||
"报告编码",
|
||||
"CC",
|
||||
"单位名称",
|
||||
"事业线",
|
||||
"产品名称",
|
||||
"注册证编号/曾用注册证编号",
|
||||
"注册人",
|
||||
"型号",
|
||||
"产品批号",
|
||||
"伤害",
|
||||
"伤害表现",
|
||||
"器械故障表现",
|
||||
"审核日期",
|
||||
]
|
||||
|
||||
# 产品名:基于贝朗中国常见产品线及行业通用命名归纳;具体注册信息以 NMPA 为准(本表为模拟)
|
||||
# events: (伤害表现K, 器械故障表现L);J 列固定为「是」(见提示词)
|
||||
PRODUCT_PROFILES: list[dict] = [
|
||||
{
|
||||
"line": "输液治疗",
|
||||
"name": "一次性使用静脉留置针",
|
||||
"weight": 180,
|
||||
"models": ("4251624", "4251625", "Introcan Safety 20G", "Introcan 18G"),
|
||||
"events": [
|
||||
("穿刺部位疼痛", "套管与导管座连接处渗漏"),
|
||||
("血肿", "回血观察窗模糊影响血流判断"),
|
||||
("出血", "针尖保护装置回弹不畅"),
|
||||
("红斑", "肝素帽旋紧后微量渗液"),
|
||||
("瘙痒", "延长管打折致滴速下降"),
|
||||
("水肿", "留置针固定翼粘贴失效"),
|
||||
("静脉炎", "导管腔内回血阻力增高"),
|
||||
("局部感染征象", "三通接头裂纹"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "输液治疗",
|
||||
"name": "一次性使用输液器",
|
||||
"weight": 160,
|
||||
"models": ("IS-7.0", "IS-5.0", "4053000"),
|
||||
"events": [
|
||||
("输液部位疼痛", "滴斗液面波动异常"),
|
||||
("空气栓塞相关症状(已处理)", "管路接头松动致滴管内进气"),
|
||||
("心悸", "流量调节轮锁止不良"),
|
||||
("瘙痒", "精密过滤器外壳裂纹"),
|
||||
("渗漏致皮肤红斑", "穿刺器与药瓶胶塞密封不严"),
|
||||
("恶心", "滴速过快相关不适"),
|
||||
("寒战", "输液管路可见异物附着"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "输液治疗",
|
||||
"name": "一次性使用精密过滤输液器",
|
||||
"weight": 90,
|
||||
"models": ("PF-5.0", "PF-7.0"),
|
||||
"events": [
|
||||
("低血压", "过滤膜侧压力升高致滴速骤降"),
|
||||
("头痛", "排气孔堵塞需二次排气"),
|
||||
("胸闷", "侧孔进气不畅"),
|
||||
("发热", "过滤器下游管路温度异常升高"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "输液治疗",
|
||||
"name": "一次性使用输注延长管",
|
||||
"weight": 70,
|
||||
"models": ("EX-50cm", "EX-100cm"),
|
||||
"events": [
|
||||
("输注中断相关焦虑", "螺旋接口与泵管不匹配"),
|
||||
("药物输注延迟", "延长管扭转触发流量报警"),
|
||||
("局部肿胀", "接口渗液"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "输液治疗",
|
||||
"name": "一次性使用肠内营养输液器",
|
||||
"weight": 50,
|
||||
"models": ("EN-SET-1.2", "EN-SET-2.0"),
|
||||
"events": [
|
||||
("腹胀", "营养袋接口与泵管连接处渗漏"),
|
||||
("呕吐", "滴速传感器识别不稳定"),
|
||||
("腹泻", "营养液温度偏低相关不适"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "透析产品",
|
||||
"name": "血液透析器",
|
||||
"weight": 120,
|
||||
"models": ("Diacap Pro 1.3", "Diacap 1.4", "HD-180"),
|
||||
"events": [
|
||||
("透析中低血压", "跨膜压波动偏大"),
|
||||
("出血", "动脉端管路接头渗液"),
|
||||
("头痛", "透析液侧压力传感器报警"),
|
||||
("肌肉痉挛", "透析器外壳细微裂纹"),
|
||||
("恶心", "超滤率设置与监测不一致"),
|
||||
("胸痛", "静脉压升高报警"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "透析产品",
|
||||
"name": "血液透析浓缩液",
|
||||
"weight": 60,
|
||||
"models": ("BIC-35", "ACID-8L"),
|
||||
"events": [
|
||||
("恶心", "浓缩液桶盖密封条变形渗漏"),
|
||||
("呕吐", "电导度监测短暂漂移"),
|
||||
("低血压", "透析液成分配比异常相关不适"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "透析产品",
|
||||
"name": "血液透析管路",
|
||||
"weight": 55,
|
||||
"models": ("AV-SET-A", "AV-SET-P"),
|
||||
"events": [
|
||||
("失血相关血红蛋白下降", "动脉壶液面持续下降"),
|
||||
("出血", "泵管段磨损起皱"),
|
||||
("寒战", "管路预冲残留气泡"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "外科产品",
|
||||
"name": "可吸收性外科缝线",
|
||||
"weight": 75,
|
||||
"models": ("Novosyn 3-0", "Monosyn 4-0"),
|
||||
"events": [
|
||||
("切口裂开", "缝线结滑脱"),
|
||||
("疼痛", "缝针弯曲"),
|
||||
("出血", "线体断裂残留"),
|
||||
("感染征象", "缝线张力过早丧失"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "外科产品",
|
||||
"name": "非吸收性外科缝线",
|
||||
"weight": 50,
|
||||
"models": ("Premilene 3-0", "Dafilon 5-0"),
|
||||
"events": [
|
||||
("异物感", "线结切割组织"),
|
||||
("水肿", "缝针与线体连接处松动"),
|
||||
("红斑", "缝线表面粗糙刺激"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "外科产品",
|
||||
"name": "一次性使用无菌手术膜",
|
||||
"weight": 35,
|
||||
"models": ("OP-FILM-45", "OP-FILM-60"),
|
||||
"events": [
|
||||
("皮肤红斑", "粘性不足边缘翘起"),
|
||||
("疼痛", "去除敷料时皮肤撕脱"),
|
||||
("瘙痒", "敷料下汗液积聚刺激"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "诊断/监测耗材",
|
||||
"name": "一次性使用动脉采血器",
|
||||
"weight": 30,
|
||||
"models": ("ABG-3ml", "SAFE-ABG"),
|
||||
"events": [
|
||||
("血肿", "针头保护套脱落困难"),
|
||||
("出血", "肝素化不足标本凝固需重新采血"),
|
||||
("疼痛", "采血后桡动脉痉挛"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"line": "输液治疗",
|
||||
"name": "一次性使用无菌注射器",
|
||||
"weight": 25,
|
||||
"models": ("10ml Luer", "20ml Luer"),
|
||||
"events": [
|
||||
("疼痛", "推杆卡顿致推注阻力骤增"),
|
||||
("给药剂量偏差相关不适", "刻度印刷模糊"),
|
||||
("局部肿胀", "针头与针座连接处渗漏"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
REGISTRANTS = (
|
||||
"贝朗医疗(上海)国际贸易有限公司",
|
||||
"贝朗爱敦(上海)医疗管理有限公司",
|
||||
"贝朗医疗(苏州)有限公司",
|
||||
)
|
||||
|
||||
HOSPITALS = (
|
||||
"上海市第一人民医院",
|
||||
"浙江大学医学院附属第二医院",
|
||||
"四川大学华西医院",
|
||||
"广东省人民医院",
|
||||
"华中科技大学同济医学院附属协和医院",
|
||||
"中南大学湘雅医院",
|
||||
"山东大学齐鲁医院",
|
||||
"中国医科大学附属第一医院",
|
||||
"西安交通大学第一附属医院",
|
||||
"南京鼓楼医院",
|
||||
"福建省立医院",
|
||||
"重庆医科大学附属第一医院",
|
||||
"天津市肿瘤医院",
|
||||
"郑州大学第一附属医院",
|
||||
"昆明医科大学第一附属医院",
|
||||
)
|
||||
|
||||
CC_POOL = (
|
||||
"质量反馈",
|
||||
"临床使用",
|
||||
"包装标识",
|
||||
"灭菌外观",
|
||||
"物流储运",
|
||||
"培训咨询",
|
||||
"不良事件",
|
||||
)
|
||||
|
||||
|
||||
def weighted_products() -> list[dict]:
|
||||
pool: list[dict] = []
|
||||
for p in PRODUCT_PROFILES:
|
||||
pool.extend([p] * p["weight"])
|
||||
return pool
|
||||
|
||||
|
||||
def random_reg_number(rng: random.Random) -> str:
|
||||
kinds = ("国械注进20", "国械注进201", "国械注准20", "国械注准201")
|
||||
return f"{rng.choice(kinds)}{rng.randint(5, 9)}{rng.randint(100000, 999999)}"
|
||||
|
||||
|
||||
def random_batch(rng: random.Random) -> str:
|
||||
# 约 1% 概率重复上一批号(模拟同批聚集),其余唯一风格
|
||||
return f"{rng.choice('ABCDEFGHJK')}{rng.randint(100000, 999999)}{rng.choice('0123456789')}"
|
||||
|
||||
|
||||
def random_workday(rng: random.Random, end: date) -> date:
|
||||
start = end - timedelta(days=365 * 2 + 120)
|
||||
d = start + timedelta(days=rng.randint(0, (end - start).days))
|
||||
while d.weekday() >= 5:
|
||||
d -= timedelta(days=1)
|
||||
return d
|
||||
|
||||
|
||||
def build_rows(n: int, rng: random.Random, end_d: date) -> list[list]:
|
||||
pool = weighted_products()
|
||||
rows: list[list] = []
|
||||
prev_batch: str | None = None
|
||||
|
||||
for i in range(1, n + 1):
|
||||
prof = rng.choice(pool)
|
||||
harm_k, device_fault = rng.choice(prof["events"])
|
||||
code = f"SIM-2024-{i:06d}"
|
||||
|
||||
if prev_batch is not None and rng.random() < 0.01:
|
||||
batch = prev_batch
|
||||
else:
|
||||
batch = random_batch(rng)
|
||||
prev_batch = batch
|
||||
|
||||
rows.append(
|
||||
[
|
||||
code,
|
||||
rng.choice(CC_POOL),
|
||||
rng.choice(HOSPITALS),
|
||||
prof["line"],
|
||||
prof["name"],
|
||||
random_reg_number(rng),
|
||||
rng.choice(REGISTRANTS),
|
||||
rng.choice(prof["models"]),
|
||||
batch,
|
||||
"是",
|
||||
harm_k,
|
||||
device_fault,
|
||||
random_workday(rng, end_d),
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
end_d = date.today()
|
||||
rng = random.Random(int(end_d.strftime("%Y%m%d")))
|
||||
out_name = f"不良事件数据-模拟1000条-{end_d:%Y%m%d}.xlsx"
|
||||
out_path = ROOT / out_name
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
assert ws is not None
|
||||
ws.title = "POWER BI 总信息"
|
||||
|
||||
ws.append(HEADERS)
|
||||
for row in build_rows(1000, rng, end_d):
|
||||
ws.append(row)
|
||||
|
||||
m_col = HEADERS.index("审核日期") + 1
|
||||
for r in range(2, 1002):
|
||||
c = ws.cell(row=r, column=m_col)
|
||||
if isinstance(c.value, date):
|
||||
c.number_format = "YYYY-MM-DD"
|
||||
|
||||
for j, _ in enumerate(HEADERS, start=1):
|
||||
ws.column_dimensions[get_column_letter(j)].width = min(28, 12 + len(HEADERS[j - 1]) // 2)
|
||||
|
||||
wb.save(out_path)
|
||||
print(f"Written: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""按生成质量投诉模拟数据.md生成质量投诉模拟数据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
TEMPLATE = ROOT / "质量投诉数据-表头.xlsx"
|
||||
|
||||
PRODUCTS = [
|
||||
{
|
||||
"division": "输液治疗",
|
||||
"bu": "IV Therapy BU",
|
||||
"name": "一次性使用静脉留置针",
|
||||
"models": ["20G", "22G", "24G", "Introcan Safety 20G"],
|
||||
"faults": [
|
||||
("渗漏", "使用中发现连接处渗液,影响持续输注"),
|
||||
("连接不牢", "与延长管连接后松动,需重复连接"),
|
||||
("流速异常", "滴速不稳定,出现间断性停止"),
|
||||
("包装破损", "开包前发现无菌包装破损"),
|
||||
],
|
||||
"cases_max": 3,
|
||||
},
|
||||
{
|
||||
"division": "输液治疗",
|
||||
"bu": "IV Therapy BU",
|
||||
"name": "一次性使用输液器",
|
||||
"models": ["IS-5.0", "IS-7.0", "PF-5.0"],
|
||||
"faults": [
|
||||
("堵塞", "输液过程中阻力升高,液体无法正常滴注"),
|
||||
("渗漏", "滴斗下方接口渗漏,需更换器械"),
|
||||
("标签不清", "批号标签印刷不清,追溯困难"),
|
||||
("流速异常", "调节轮无法稳定控制滴速"),
|
||||
],
|
||||
"cases_max": 4,
|
||||
},
|
||||
{
|
||||
"division": "透析",
|
||||
"bu": "Renal Care BU",
|
||||
"name": "血液透析器",
|
||||
"models": ["1.3m²", "1.4m²", "HD-180"],
|
||||
"faults": [
|
||||
("连接不牢", "血路连接后出现轻度渗血风险"),
|
||||
("断裂", "外壳边缘出现细微裂纹,已停止使用"),
|
||||
("流速异常", "跨膜压波动导致透析流程中断"),
|
||||
("包装破损", "透析器外包装密封不完整"),
|
||||
],
|
||||
"cases_max": 2,
|
||||
},
|
||||
{
|
||||
"division": "透析",
|
||||
"bu": "Renal Care BU",
|
||||
"name": "血液透析管路",
|
||||
"models": ["AV-SET-A", "AV-SET-P"],
|
||||
"faults": [
|
||||
("渗漏", "动脉端管路连接位出现渗液"),
|
||||
("堵塞", "泵管段阻力偏高触发报警"),
|
||||
("连接不牢", "静脉端接头锁定后仍可松脱"),
|
||||
],
|
||||
"cases_max": 2,
|
||||
},
|
||||
{
|
||||
"division": "外科",
|
||||
"bu": "Surgical BU",
|
||||
"name": "可吸收性外科缝线",
|
||||
"models": ["3-0", "4-0", "5-0"],
|
||||
"faults": [
|
||||
("断裂", "缝合过程中线体提前断裂"),
|
||||
("标签不清", "规格标签与实际颜色识别困难"),
|
||||
("包装破损", "单支包装封口松开,未使用"),
|
||||
],
|
||||
"cases_max": 2,
|
||||
},
|
||||
]
|
||||
|
||||
HOSPITALS = [
|
||||
"上海市第一人民医院",
|
||||
"浙江大学医学院附属第二医院",
|
||||
"四川大学华西医院",
|
||||
"广东省人民医院",
|
||||
"中南大学湘雅医院",
|
||||
"山东大学齐鲁医院",
|
||||
"西安交通大学第一附属医院",
|
||||
"南京鼓楼医院",
|
||||
"福建省立医院",
|
||||
"郑州大学第一附属医院",
|
||||
]
|
||||
|
||||
CONTACTS = ["张医生", "李护士长", "王老师", "陈医生", "赵工", "刘老师", "周护士"]
|
||||
REPORTERS = ["设备科-赵工", "护理部-陈老师", "质控办-王老师", "临床工程-李工"]
|
||||
MANUFACTURERS = [
|
||||
"贝朗医疗(上海)国际贸易有限公司",
|
||||
"贝朗医疗(苏州)有限公司",
|
||||
"贝朗爱敦(上海)医疗管理有限公司",
|
||||
]
|
||||
AE_NEG_REASONS = ["未造成患者伤害", "仅质量缺陷,无临床后果", "使用前发现缺陷,未接触患者"]
|
||||
CONCLUSIONS = ["产品缺陷成立", "操作不当", "运输损伤", "未复现", "资料不足"]
|
||||
PAYMENTS = ["无赔付", "换货", "折让", "退款", "其他协商处理"]
|
||||
STATUS_POOL = ["新建", "调查中", "待补充", "已关闭"]
|
||||
|
||||
|
||||
def rand_reg_no(rng: random.Random) -> str:
|
||||
return f"国械注进20{rng.randint(15,26)}{rng.randint(100000,999999)}"
|
||||
|
||||
|
||||
def rand_batch(rng: random.Random) -> str:
|
||||
return f"{rng.choice('ABCDEFGH')}{rng.randint(1000000,9999999)}"
|
||||
|
||||
|
||||
def rand_phone(rng: random.Random) -> str:
|
||||
return f"1{rng.choice('3456789')}{rng.randint(100,999)}****{rng.randint(1000,9999)}"
|
||||
|
||||
|
||||
def rand_serial(rng: random.Random, use_na: bool) -> str:
|
||||
if use_na:
|
||||
return "N/A"
|
||||
return f"SN{rng.randint(10**9,10**10-1)}"
|
||||
|
||||
|
||||
def choose_resolution(rng: random.Random, conclusion: str) -> tuple[str, str]:
|
||||
if conclusion == "产品缺陷成立":
|
||||
return "Replace", "更换同批次产品并加强到货检验"
|
||||
if conclusion == "操作不当":
|
||||
return "Training reinforced", "复测未见异常,建议规范操作培训"
|
||||
if conclusion == "运输损伤":
|
||||
return "Logistics improved", "判定为运输环节损伤,已优化包装与交付流程"
|
||||
if conclusion == "未复现":
|
||||
return "No defect found", "复测未见异常,建议持续观察后续批次"
|
||||
return "Need more info", "现有证据不足,待补充样品及记录"
|
||||
|
||||
|
||||
def generate_rows(n: int, seed: int) -> list[list]:
|
||||
rng = random.Random(seed)
|
||||
rows: list[list] = []
|
||||
today = date.today()
|
||||
start = today - timedelta(days=980)
|
||||
|
||||
for i in range(1, n + 1):
|
||||
p = rng.choice(PRODUCTS)
|
||||
fault, detail = rng.choice(p["faults"])
|
||||
hospital = rng.choice(HOSPITALS)
|
||||
c3_date = start + timedelta(days=rng.randint(0, (today - start).days))
|
||||
c3_month = c3_date.strftime("%Y-%m")
|
||||
|
||||
is_ae = "是" if rng.random() < 0.28 else "否"
|
||||
ae_no_reason = "" if is_ae == "是" else rng.choice(AE_NEG_REASONS)
|
||||
|
||||
status = rng.choices(STATUS_POOL, weights=[0.15, 0.25, 0.1, 0.5], k=1)[0]
|
||||
report_done = None
|
||||
closed_date = None
|
||||
if status in ("调查中", "已关闭", "待补充"):
|
||||
report_done = c3_date + timedelta(days=rng.randint(3, 45))
|
||||
if status == "已关闭":
|
||||
base = report_done if report_done else c3_date
|
||||
closed_date = base + timedelta(days=rng.randint(1, 30))
|
||||
|
||||
bad_qty = rng.randint(1, 20)
|
||||
sample_return = "是" if rng.random() < 0.78 else "否"
|
||||
if sample_return == "否":
|
||||
qa_qty = 0 if rng.random() < 0.95 else rng.randint(0, bad_qty)
|
||||
else:
|
||||
qa_qty = rng.randint(1, bad_qty)
|
||||
factory_qty = rng.randint(0, qa_qty)
|
||||
case_count = 1 if rng.random() < 0.85 else rng.randint(2, p["cases_max"])
|
||||
|
||||
conclusion = rng.choice(CONCLUSIONS)
|
||||
payment = rng.choice(PAYMENTS)
|
||||
opinion_en, opinion_cn = choose_resolution(rng, conclusion)
|
||||
|
||||
# make payment more coherent
|
||||
if conclusion in ("未复现", "资料不足", "操作不当"):
|
||||
payment = rng.choices(["无赔付", "其他协商处理", "折让"], weights=[0.7, 0.2, 0.1], k=1)[0]
|
||||
elif conclusion == "产品缺陷成立":
|
||||
payment = rng.choices(["换货", "退款", "折让", "无赔付"], weights=[0.5, 0.2, 0.2, 0.1], k=1)[0]
|
||||
|
||||
row = [
|
||||
f"C3-{today:%Y}-{i:06d}",
|
||||
rng.choice(p["models"]),
|
||||
rand_batch(rng),
|
||||
rand_serial(rng, use_na=("输液" in p["division"] and rng.random() < 0.6)),
|
||||
rng.choice(MANUFACTURERS),
|
||||
rand_reg_no(rng),
|
||||
p["name"],
|
||||
hospital,
|
||||
rng.choice(CONTACTS),
|
||||
rand_phone(rng),
|
||||
fault,
|
||||
detail,
|
||||
rng.choice(REPORTERS),
|
||||
p["bu"],
|
||||
c3_date,
|
||||
c3_month,
|
||||
is_ae,
|
||||
ae_no_reason,
|
||||
bad_qty,
|
||||
qa_qty,
|
||||
factory_qty,
|
||||
report_done,
|
||||
opinion_en,
|
||||
opinion_cn,
|
||||
conclusion,
|
||||
payment,
|
||||
closed_date,
|
||||
status,
|
||||
sample_return,
|
||||
p["division"],
|
||||
case_count,
|
||||
]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def template_headers() -> list[str]:
|
||||
wb = load_workbook(TEMPLATE, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
headers = [ws.cell(1, j).value for j in range(1, ws.max_column + 1)]
|
||||
wb.close()
|
||||
return headers
|
||||
|
||||
|
||||
def main() -> None:
|
||||
headers = template_headers()
|
||||
today = date.today()
|
||||
out_path = ROOT / f"质量投诉数据-模拟1000条-{today:%Y%m%d}.xlsx"
|
||||
rows = generate_rows(1000, int(today.strftime("%Y%m%d")))
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "complaint form"
|
||||
ws.append(headers)
|
||||
for r in rows:
|
||||
ws.append(r)
|
||||
|
||||
# date columns
|
||||
for r in range(2, 1002):
|
||||
for col in ("O", "V", "AA"):
|
||||
if ws[f"{col}{r}"].value:
|
||||
ws[f"{col}{r}"].number_format = "YYYY-MM-DD"
|
||||
|
||||
for i in range(1, len(headers) + 1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = 16
|
||||
|
||||
wb.save(out_path)
|
||||
print(f"Written: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""生成药品不良事件&投诉模拟数据(1000行)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
TEMPLATE = ROOT / "药品不良事件&投诉数据-表头.xlsx"
|
||||
|
||||
CASE_OWNERS = ["PV专员-张", "PV专员-李", "药物警戒-王", "PV经理-陈"]
|
||||
REPORTERS = ["销售-赵", "医学联络-周", "医院上报-吴", "客服-郑"]
|
||||
INBOUND = ["电话", "邮件", "销售反馈", "医疗机构上报", "监管转入"]
|
||||
FEEDBACK_CODE = ["有效", "待补充", "无效"]
|
||||
FIRST_FOLLOW = ["首次", "跟踪"]
|
||||
EXPECTED = ["预期", "非预期"]
|
||||
REPORT_TYPE = ["自发报告", "文献", "上市后研究", "其他"]
|
||||
SERIOUS = ["是", "否"]
|
||||
UNIT_TYPE = ["三级医院", "二级医院", "基层医疗机构", "药店", "其他"]
|
||||
SEX = ["男", "女"]
|
||||
ETHNIC = ["汉族", "回族", "满族", "壮族", "苗族", "其他"]
|
||||
BOOL_YN = ["是", "否"]
|
||||
YN_UNK = ["是", "否", "不详", "未再用"]
|
||||
INFO_SRC = ["医务人员", "患者", "文献", "监管机构", "公司主动收集"]
|
||||
IMPORTANT_INFO = ["肝功能异常", "肾功能异常", "妊娠", "过敏体质", "其他", "无"]
|
||||
SUSPECT_COMBI = ["怀疑药品", "合并用药"]
|
||||
DOSAGE_UNITS = ["mg", "ml", "片", "粒", "支"]
|
||||
FREQ = ["qd", "bid", "tid", "qod", "每周一次"]
|
||||
ROUTE = ["口服", "静脉滴注", "肌内注射", "皮下注射"]
|
||||
|
||||
DRUGS = [
|
||||
{
|
||||
"approval": "国药准字H20103001",
|
||||
"generic": "头孢呋辛酯片",
|
||||
"brand": "新福辛",
|
||||
"form": "片剂",
|
||||
"mfg": "贝朗制药(苏州)有限公司",
|
||||
"dose_range": (250, 500),
|
||||
"dose_unit": "mg",
|
||||
"route": "口服",
|
||||
"indications": ["呼吸道感染", "泌尿系感染"],
|
||||
},
|
||||
{
|
||||
"approval": "国药准字H20153077",
|
||||
"generic": "甲硝唑氯化钠注射液",
|
||||
"brand": "美舒宁",
|
||||
"form": "注射剂",
|
||||
"mfg": "贝朗医疗制药(上海)有限公司",
|
||||
"dose_range": (100, 250),
|
||||
"dose_unit": "ml",
|
||||
"route": "静脉滴注",
|
||||
"indications": ["腹腔感染", "术后感染预防"],
|
||||
},
|
||||
{
|
||||
"approval": "国药准字H20064211",
|
||||
"generic": "左氧氟沙星氯化钠注射液",
|
||||
"brand": "利复宁",
|
||||
"form": "注射剂",
|
||||
"mfg": "贝朗医疗制药(上海)有限公司",
|
||||
"dose_range": (100, 200),
|
||||
"dose_unit": "ml",
|
||||
"route": "静脉滴注",
|
||||
"indications": ["肺部感染", "皮肤软组织感染"],
|
||||
},
|
||||
]
|
||||
|
||||
REACTIONS = [
|
||||
("皮疹", "Skin and subcutaneous tissue disorders", "皮肤及皮下组织类疾病"),
|
||||
("恶心", "Gastrointestinal disorders", "胃肠系统疾病"),
|
||||
("呕吐", "Gastrointestinal disorders", "胃肠系统疾病"),
|
||||
("头晕", "Nervous system disorders", "神经系统疾病"),
|
||||
("肝功能异常", "Hepatobiliary disorders", "肝胆系统疾病"),
|
||||
("过敏反应", "Immune system disorders", "免疫系统疾病"),
|
||||
("注射部位反应", "General disorders and administration site conditions", "全身性疾病及给药部位各种反应"),
|
||||
]
|
||||
|
||||
RESULTS = ["痊愈", "好转", "未好转", "不详", "死亡"]
|
||||
SEVERITY = ["轻度", "中度", "重度"]
|
||||
CONCLUSION = ["可能有关", "很可能有关", "无法评价", "待随访"]
|
||||
PROV_CITY_DIST = [
|
||||
("上海市", "上海市", "浦东新区"),
|
||||
("浙江省", "杭州市", "西湖区"),
|
||||
("广东省", "广州市", "越秀区"),
|
||||
("四川省", "成都市", "武侯区"),
|
||||
("江苏省", "南京市", "鼓楼区"),
|
||||
("山东省", "济南市", "历下区"),
|
||||
]
|
||||
|
||||
|
||||
def template_headers() -> list[str]:
|
||||
wb = load_workbook(TEMPLATE, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
headers = [ws.cell(1, j).value for j in range(1, ws.max_column + 1)]
|
||||
wb.close()
|
||||
return headers
|
||||
|
||||
|
||||
def rand_date(rng: random.Random, start: date, end: date) -> date:
|
||||
return start + timedelta(days=rng.randint(0, (end - start).days))
|
||||
|
||||
|
||||
def make_row(i: int, rng: random.Random, today: date) -> list:
|
||||
receipt = rand_date(rng, today - timedelta(days=900), today - timedelta(days=1))
|
||||
entry = receipt + timedelta(days=rng.randint(0, 3))
|
||||
submit = entry + timedelta(days=rng.randint(0, 5))
|
||||
report_ha = "是" if rng.random() < 0.85 else "否"
|
||||
due_adr = receipt + timedelta(days=rng.randint(3, 15)) if report_ha == "是" else None
|
||||
|
||||
first_follow = rng.choices(FIRST_FOLLOW, weights=[0.8, 0.2], k=1)[0]
|
||||
serious_ae = rng.choices(SERIOUS, weights=[0.2, 0.8], k=1)[0]
|
||||
|
||||
age = rng.randint(18, 85)
|
||||
birth = receipt - timedelta(days=365 * age + rng.randint(0, 364))
|
||||
weight = round(rng.uniform(45, 95), 1)
|
||||
|
||||
reaction_date = receipt - timedelta(days=rng.randint(0, 20))
|
||||
if reaction_date < today - timedelta(days=1200):
|
||||
reaction_date = today - timedelta(days=1200)
|
||||
|
||||
result = rng.choices(RESULTS, weights=[0.4, 0.35, 0.15, 0.08, 0.02], k=1)[0]
|
||||
death_time = None
|
||||
death_cause = None
|
||||
if result == "死亡":
|
||||
death_time = reaction_date + timedelta(days=rng.randint(0, 15))
|
||||
death_cause = rng.choice(["感染性休克", "呼吸衰竭", "多器官功能衰竭"])
|
||||
|
||||
has_hist = rng.random() < 0.28
|
||||
has_family = rng.random() < 0.2
|
||||
|
||||
react_cn, pt, soc = rng.choice(REACTIONS)
|
||||
drug = rng.choice(DRUGS)
|
||||
|
||||
med_start = reaction_date - timedelta(days=rng.randint(1, 30))
|
||||
med_days = rng.randint(1, 30)
|
||||
med_end = med_start + timedelta(days=med_days - 1)
|
||||
|
||||
serious_lv = "重度" if serious_ae == "是" else rng.choice(["轻度", "中度"])
|
||||
province, city, district = rng.choice(PROV_CITY_DIST)
|
||||
|
||||
important = rng.choice(IMPORTANT_INFO)
|
||||
important_other = "合并高脂血症" if important == "其他" else ""
|
||||
|
||||
report_date = submit + timedelta(days=rng.randint(0, 5))
|
||||
|
||||
return [
|
||||
rng.choice(CASE_OWNERS),
|
||||
rng.choice(REPORTERS),
|
||||
rng.choice(INBOUND),
|
||||
receipt,
|
||||
entry,
|
||||
report_ha,
|
||||
due_adr,
|
||||
f"AER-{today:%Y}-{i:06d}",
|
||||
f"CC-{today:%Y}-{i:06d}",
|
||||
submit,
|
||||
rng.choice(FEEDBACK_CODE),
|
||||
first_follow,
|
||||
rng.choice(EXPECTED),
|
||||
rng.choice(REPORT_TYPE),
|
||||
serious_ae,
|
||||
rng.choice(UNIT_TYPE),
|
||||
rng.choice(SEX),
|
||||
birth,
|
||||
age,
|
||||
"岁",
|
||||
rng.choice(ETHNIC),
|
||||
weight,
|
||||
"有" if has_hist else "无",
|
||||
"有" if has_family else "无",
|
||||
reaction_date,
|
||||
f"患者用药后出现{react_cn},经对症处理后病情{'好转' if result!='死亡' else '加重'}。",
|
||||
result,
|
||||
"无" if result in ("痊愈", "好转") else ("轻度皮肤色素沉着" if result == "未好转" else ""),
|
||||
death_time,
|
||||
death_cause,
|
||||
rng.choice(YN_UNK),
|
||||
rng.choice(YN_UNK),
|
||||
rng.choice(["无明显影响", "原患疾病短暂波动", "加重原患疾病"]),
|
||||
rng.choice(CONCLUSION),
|
||||
rng.choice(CONCLUSION),
|
||||
report_date,
|
||||
rng.choice(INFO_SRC),
|
||||
"模拟数据,仅用于分析演示",
|
||||
rng.choice(drug["indications"]),
|
||||
react_cn if has_family else "",
|
||||
rng.choice([d["brand"] for d in DRUGS]) if has_family else "",
|
||||
rng.choice(SEVERITY) if has_family else "",
|
||||
react_cn if has_hist else "",
|
||||
rng.choice(SEVERITY) if has_hist else "",
|
||||
rng.choice([d["brand"] for d in DRUGS]) if has_hist else "",
|
||||
important,
|
||||
important_other,
|
||||
rng.choices(SUSPECT_COMBI, weights=[0.7, 0.3], k=1)[0],
|
||||
1,
|
||||
drug["approval"],
|
||||
drug["generic"],
|
||||
drug["brand"],
|
||||
drug["form"],
|
||||
drug["mfg"],
|
||||
f"{rng.choice('ABCDEFGH')}{rng.randint(100000,999999)}",
|
||||
round(rng.uniform(*drug["dose_range"]), 1),
|
||||
drug["dose_unit"] if rng.random() < 0.85 else rng.choice(DOSAGE_UNITS),
|
||||
rng.choice(FREQ),
|
||||
med_days,
|
||||
drug["route"] if rng.random() < 0.85 else rng.choice(ROUTE),
|
||||
med_start,
|
||||
med_end,
|
||||
rng.choice(drug["indications"]),
|
||||
react_cn,
|
||||
serious_lv,
|
||||
pt,
|
||||
soc,
|
||||
province,
|
||||
city,
|
||||
district,
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
headers = template_headers()
|
||||
today = date.today()
|
||||
rng = random.Random(int(today.strftime("%Y%m%d")))
|
||||
out = ROOT / f"药品不良事件&投诉数据-模拟1000条-{today:%Y%m%d}.xlsx"
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Tabelle1"
|
||||
ws.append(headers)
|
||||
|
||||
for i in range(1, 1001):
|
||||
ws.append(make_row(i, rng, today))
|
||||
|
||||
# date formats
|
||||
date_cols = [4, 5, 7, 10, 18, 25, 29, 36, 61, 62]
|
||||
for r in range(2, 1002):
|
||||
for c in date_cols:
|
||||
v = ws.cell(r, c).value
|
||||
if isinstance(v, date):
|
||||
ws.cell(r, c).number_format = "YYYY-MM-DD"
|
||||
for i in range(1, len(headers) + 1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = 15
|
||||
|
||||
wb.save(out)
|
||||
print(f"Written: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
生成不良事件模拟数据
|
||||
|
||||
# 角色与目标
|
||||
|
||||
你是数据分析助手。请根据本地 Excel 表头,生成 1000 条「贝朗(B. Braun)相关产品」的模拟不良事件数据(仅用于内部分析、培训或演示,非真实上报数据),并导出为新的 Excel 文件。
|
||||
|
||||
# 1. 输入文件(必须先读)
|
||||
|
||||
路径:贝朗数据/不良事件数据-表头.xlsx(若工作区根目录不同,以用户提供的「贝朗数据」文件夹为准)。
|
||||
|
||||
| 列 | 字段名 |
|
||||
|----|--------|
|
||||
| A | 报告编码 |
|
||||
| B | CC |
|
||||
| C | 单位名称 |
|
||||
| D | 事业线 |
|
||||
| E | 产品名称 |
|
||||
| F | 注册证编号/曾用注册证编号 |
|
||||
| G | 注册人 |
|
||||
| H | 型号 |
|
||||
| I | 产品批号 |
|
||||
| J | 伤害 |
|
||||
| K | 伤害表现 |
|
||||
| L | 器械故障表现 |
|
||||
| M | 审核日期 |
|
||||
|
||||
工作表名:**POWER BI 总信息**(与源文件一致)。
|
||||
|
||||
# 2. 各列填充规则(A–M)
|
||||
|
||||
以下规则适用于第 2–1001 行数据行;第 1 行为表头,不得改动列名与列顺序。
|
||||
|
||||
## A 列「报告编码」
|
||||
|
||||
- 1000 条记录**互不重复**。
|
||||
- 格式建议:`SIM-年份-6 位序号`(如 `SIM-2024-000001`~`SIM-2024-001000`);若源表头文件中有示例编码,优先与示例**风格一致**。
|
||||
- 仅作模拟数据集主键,**不代表**任何真实上报编号。
|
||||
|
||||
## B 列「CC」
|
||||
|
||||
- 表示投诉/反馈渠道或分类的简短标签(具体含义可按内部分类理解)。
|
||||
- 从有限**枚举池**中抽取并轮换使用,例如:`质量反馈`、`临床使用`、`包装标识`、`灭菌外观`、`物流储运`、`培训咨询`、`不良事件` 等;可酌情增减,但须**全表多样化**,避免 1000 行几乎同一取值。
|
||||
- 取值风格统一为**简短中文短语**,勿使用空值占位。
|
||||
|
||||
## C 列「单位名称」
|
||||
|
||||
- 填写**医疗机构或报告单位**风格的全称(如「××大学附属××医院」「××省人民医院」等)。
|
||||
- 覆盖**多个省份/城市**,名称不雷同;可混合三甲、肿瘤专科、大学附属医院等,增强分布真实感。
|
||||
- **勿**使用真实在世的具体科室或个人姓名;机构名可与公开信息风格相近,整体仍为**合成**。
|
||||
|
||||
## D 列「事业线」
|
||||
|
||||
- 与 **E 列产品名称**严格对应:每条记录的事业线必须是该产品所属业务板块(如输液治疗、透析产品、外科产品、诊断/监测耗材等)。
|
||||
- 使用**统一、有限**的事业线名称集合,避免同一产品线出现多种写法(如不要同时出现「透析」与「透析产品线」两种未规范写法)。
|
||||
- 禁止出现与 E 列**明显矛盾**的组合(例如事业线为透析而产品名称为静脉留置针)。
|
||||
|
||||
## E 列「产品名称」
|
||||
|
||||
- 先检索并整理「贝朗(B. Braun)在中国境内已上市医疗器械」的**代表性产品清单**(优先依据:国家药监局 NMPA 公开数据、贝朗中国官网/说明书中的产品名、行业常见命名习惯)。
|
||||
- 若无法保证 100% 与备案/注册信息一致,须在交付说明中明确标注:**「产品名为基于公开信息的归纳与模拟,不用于法规申报」**。
|
||||
- **只能**使用该清单中的名称;1000 条中多个产品按**合理比例**分布(不必均匀,可模拟销量/用量结构,如留置针、输液器占比可高于小众型号)。
|
||||
|
||||
## F 列「注册证编号/曾用注册证编号」
|
||||
|
||||
- 风格与境内医疗器械注册证号**格式相近**(如 `国械注进20××××××`、`国械注准201××××××` 等),**可为虚构**,不要求与真实证号一一对应。
|
||||
- 同一产品名称(E)下可出现**多条不同证号**(曾用证、换证等场景),但须避免全表仅 1~2 个证号来回复制。
|
||||
- **禁止**在交付物中声称「本列与 NMPA 公示完全一致」。
|
||||
|
||||
## G 列「注册人」
|
||||
|
||||
- 填写与**贝朗在华主体**命名风格一致的注册人/备案人名称,例如:`贝朗医疗(上海)国际贸易有限公司`、`贝朗爱敦(上海)医疗管理有限公司`、`贝朗医疗(苏州)有限公司` 等(可从公开信息归纳固定枚举池)。
|
||||
- 从枚举池中**加权或随机轮换**,避免 1000 行全部为同一字符串。
|
||||
- 注册人可与 E 列产品线有**常识性对应**(不必逐条严格考证),整体保持可信。
|
||||
|
||||
## H 列「型号」
|
||||
|
||||
- 与 **E 列具体产品**匹配:每条记录的型号应像该产品在说明书或标签上会出现的规格/型号写法(含规格代码、Gauge、容量等)。
|
||||
- 同一 E 列产品可对应**多个型号**,在 1000 条中分散出现。
|
||||
- 避免全表型号重复率过高;**勿**编造与该产品类别完全无关的型号描述。
|
||||
|
||||
## I 列「产品批号」
|
||||
|
||||
- 模拟生产批号:字母与数字组合(如 `A7382912`、`H9210045`),长度与风格在**合理区间**内波动。
|
||||
- **每条尽量不同**;允许极低概率的「同批号不同报告」以模拟聚集性,但不应成为主流。
|
||||
- 不使用明显无效占位(如 `TEST`、`111111` 连续大量出现)。
|
||||
|
||||
## J 列「伤害」
|
||||
|
||||
- 简明表示是否造成伤害或伤害分级习惯用语,须与真实上报字段定义**在风格上兼容**;常见写法如:`是`、`否`、`不详` 等(若源系统仅有「是/否」,则不要使用三级分类)。
|
||||
- **必须与 K、L 列逻辑一致**:若 L 为严重器械故障而 K 为重伤表现,则 J 一般不应为「否」;避免三列自相矛盾。
|
||||
- 作为模拟,所有数据,J列均为 是。
|
||||
|
||||
## K 列「伤害表现」
|
||||
|
||||
- 用**简短中文**描述临床表现或患者主诉,与 **J 列**及器械使用场景相符。
|
||||
- 无伤害时可为「无」或与 J=否 一致的轻描述;有伤害时描述**程度与部位**宜多样化(红肿、出血、血肿、低血压、头痛等),避免千行同一句话,伤害表现应该符合医学术语表达规范,如使用WHOART医学术语。
|
||||
- 措辞偏**临床记录体**,避免小说式长段落。
|
||||
- 作为模拟,我们需要所有数据都包括 伤害表现。
|
||||
|
||||
|
||||
## L 列「器械故障表现」
|
||||
|
||||
- 描述**器械本身**的异常或失效表现,与 **E 列产品类别**强相关(见下节示例维度)。
|
||||
- 与 **J、K** 形成因果或并列关系合理:如渗漏、堵塞、压力异常、固定失效、裂纹、连接不牢等。
|
||||
- 同类别产品在句式上**变换同义词与细节**,避免模板化重复。
|
||||
|
||||
## M 列「审核日期」
|
||||
|
||||
- 落在近 **24~36 个月**内的日期范围;优先使用**工作日**(周一至周五),减少全体落在周末的不自然分布。
|
||||
- Excel 中保存为**日期型**(非纯文本),显示格式可与源表头文件一致(如 `YYYY-MM-DD`)。
|
||||
- 全表日期应有一定**随机分散**,避免集中为同一天或呈完全规律递增。
|
||||
|
||||
# 3. 真实感与「产品—事件」关联(核心,与第 2 节配合)
|
||||
|
||||
- **D、E、H** 三位一体:事业线、产品名、型号须为同一叙事下的器械信息。
|
||||
- **J、K、L** 须与产品类别逻辑一致,例如:
|
||||
- **输液/输注类**:渗漏、堵塞、流速异常、管路打折/断裂、接头不牢、排气问题等;
|
||||
- **透析类**:跨膜压/电导度异常、管路渗血渗液、透析器外壳问题、液面异常等;
|
||||
- **外科/缝线类**:线结滑脱、缝针异常、异物感、固定或粘贴失效等。
|
||||
- **C 列**可间接体现场景(大型综合医院、肿瘤中心等),与产品使用场景不冲突。
|
||||
- 避免每条记录使用**完全相同**的 K/L 模板句;在同类别内替换措辞、程度与发现环节。
|
||||
|
||||
# 4. 输出
|
||||
|
||||
新建 Excel 文件,保存到 **贝朗数据** 文件夹下。
|
||||
建议文件名:`不良事件数据-模拟1000条-YYYYMMDD.xlsx`(日期为生成当日)。
|
||||
工作表名仍为 **POWER BI 总信息**;第 1 行为原表头,第 2–1001 行为数据。
|
||||
列顺序与列名与源表头文件**完全一致**,便于后续 Power BI 或透视表使用。
|
||||
|
||||
# 5. 交付时请用文字简要说明
|
||||
|
||||
- 产品清单主要依据哪些公开来源(或说明为归纳模拟)。
|
||||
- 各事业线/主要产品的大致条数占比。
|
||||
- **明确声明**:本文件为**合成数据**,不代表现实中的不良事件报告。
|
||||
|
||||
---
|
||||
|
||||
## 附:文档结构说明(供执行方自检)
|
||||
|
||||
| 维度 | 说明 |
|
||||
|------|------|
|
||||
| 结构 | 已写明工作表名与 13 个字段及**逐列填充规则**,避免猜列或错位。 |
|
||||
| E 列 | 要求检索路径 + 无法核验时的声明,降低「虚构产品名却被当作事实」的风险。 |
|
||||
| 真实性 | 各列规则 + 第 3 节跨列关联,保证 J/K/L 与产品线一致及字段多样化。 |
|
||||
| 输出 | 固定行数(2–1001)、命名规则、路径明确。 |
|
||||
| 合规 | 强调合成数据用途,避免误用作正式上报。 |
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
生成入院量模拟数据
|
||||
|
||||
# 角色与目标
|
||||
|
||||
你是数据分析助手。请根据本地 Excel 表头,生成 1000 条「贝朗(B. Braun)产品在医院端入院/使用相关业务口径」的模拟数据(仅用于内部分析、培训或演示,非真实经营报表数据),并导出为新的 Excel 文件。
|
||||
|
||||
# 1. 输入文件(必须先读)
|
||||
|
||||
路径:`贝朗数据/入院量-表头.xlsx`(若工作区根目录不同,以用户提供的「贝朗数据」文件夹为准)。
|
||||
|
||||
| 列 | 字段名 |
|
||||
|----|--------|
|
||||
| A | Year |
|
||||
| B | Month |
|
||||
| C | HospitalName |
|
||||
| D | HospitalCode |
|
||||
| E | DealerName |
|
||||
| F | DealerCode |
|
||||
| G | Province |
|
||||
| H | City |
|
||||
| I | GlobalDivision |
|
||||
| J | LocalDivision |
|
||||
| K | BU |
|
||||
| L | ProductLine |
|
||||
| M | ProductLineType |
|
||||
| N | Material |
|
||||
| O | MaterialDesc |
|
||||
| P | CY Amt |
|
||||
| Q | LY Amt |
|
||||
| R | Growth Amt |
|
||||
| S | Growth% Amt |
|
||||
| T | CY Qty |
|
||||
| U | LY Qty |
|
||||
| V | Growth Qty |
|
||||
| W | Growth% Qty |
|
||||
| X | CY LE AMT |
|
||||
|
||||
工作表名:`Sheet1`(与源文件一致)。
|
||||
|
||||
# 2. 各列填充规则(A–X)
|
||||
|
||||
以下规则适用于第 2–1001 行数据行;第 1 行为表头,不得改动列名与列顺序。
|
||||
|
||||
## A 列 `Year`
|
||||
|
||||
- 建议取近 2~3 年(如 `2024`、`2025`、`2026`)的整数年份。
|
||||
- 与 B 列 `Month` 组合后应形成合理时间分布,避免 1000 条全部落在同一年同一月。
|
||||
|
||||
## B 列 `Month`
|
||||
|
||||
- 取值范围 `1`~`12`(整数)。
|
||||
- 与 A 列匹配,允许季节性波动(如 Q4 数值略高)但不要机械重复。
|
||||
|
||||
## C 列 `HospitalName`
|
||||
|
||||
- 采用中国境内医院全称风格(如「××大学附属××医院」「××省人民医院」)。
|
||||
- 与 G/H(省/市)保持一致,避免城市与医院明显冲突。
|
||||
- 全表使用多个医院,避免极端集中到单家医院。
|
||||
|
||||
## D 列 `HospitalCode`
|
||||
|
||||
- 为 `HospitalName` 的稳定唯一编码(同一医院编码必须一致)。
|
||||
- 格式建议:`H` + 5~8 位数字或字母数字组合(如 `H310001`)。
|
||||
- 不同医院不得复用同一编码。
|
||||
|
||||
## E 列 `DealerName`
|
||||
|
||||
- 使用经销商/渠道商公司名称风格(如「××医疗器械有限公司」「××医药科技有限公司」)。
|
||||
- 同一医院可出现多个经销商;同一经销商也可服务多个医院。
|
||||
|
||||
## F 列 `DealerCode`
|
||||
|
||||
- 为 `DealerName` 的稳定唯一编码(同名同码、异名异码)。
|
||||
- 格式建议:`D` + 4~8 位数字(如 `D10258`)。
|
||||
|
||||
## G 列 `Province`
|
||||
|
||||
- 省级行政区名称(如 `上海市`、`浙江省`、`广东省`)。
|
||||
- 必须与 H 列 `City` 形成真实的省市归属关系。
|
||||
|
||||
## H 列 `City`
|
||||
|
||||
- 地级市/直辖市名称(如 `上海市`、`杭州市`、`广州市`)。
|
||||
- 与 C 列医院所在地、G 列省份一致。
|
||||
|
||||
## I 列 `GlobalDivision`
|
||||
|
||||
- 使用有限枚举值,建议按全球业务大类,如 `Hospital Care`、`Aesculap`、`Avitum`。
|
||||
- 与 J/K/L/M 保持层级逻辑一致,不要跨事业部乱配。
|
||||
|
||||
## J 列 `LocalDivision`
|
||||
|
||||
- 中国本地事业部分组名称(如 `输液治疗`、`外科`、`透析`)。
|
||||
- 与 I 列映射稳定(同一 LocalDivision 不要映射到多个互斥 GlobalDivision)。
|
||||
|
||||
## K 列 `BU`
|
||||
|
||||
- 业务单元名称(如 `IV Therapy BU`、`Renal Care BU`、`Surgical BU`)。
|
||||
- 与 J/L 保持业务口径一致,避免出现不相关组合。
|
||||
|
||||
## L 列 `ProductLine`
|
||||
|
||||
- 产品线名称(如 `静脉输注`、`透析耗材`、`缝线与外科耗材`)。
|
||||
- 与 M/N/O 联动:同一产品线应对应合理的物料与描述。
|
||||
|
||||
## M 列 `ProductLineType`
|
||||
|
||||
- 产品线子类型(如 `输液器`、`留置针`、`透析器`、`缝线`)。
|
||||
- 建议作为 L 列的细分层,不可脱离 L 列独立随机。
|
||||
|
||||
## N 列 `Material`
|
||||
|
||||
- 物料编码,建议使用稳定格式(如 `MAT` + 6 位数字,例 `MAT203145`)。
|
||||
- 同一 O 列 `MaterialDesc` 对应固定 Material 编码。
|
||||
- 不同物料编码应可重复出现(代表多月或多医院销售/入院量)。
|
||||
|
||||
## O 列 `MaterialDesc`
|
||||
|
||||
- 物料中文描述,体现规格和品类信息(如「一次性使用静脉留置针 20G」)。
|
||||
- 与 M/N 严格一致,避免一个编码对应多种冲突描述。
|
||||
|
||||
## P 列 `CY Amt`
|
||||
|
||||
- 当年金额(Current Year Amount),数值型,建议保留 2 位小数。
|
||||
- 取值必须非负;建议以业务真实感设置在合理范围(如几千到几十万不等)。
|
||||
|
||||
## Q 列 `LY Amt`
|
||||
|
||||
- 去年同期金额(Last Year Amount),数值型,建议保留 2 位小数。
|
||||
- 允许部分记录为 0(新品/新医院场景),但比例不宜过高(建议 <10%)。
|
||||
|
||||
## R 列 `Growth Amt`
|
||||
|
||||
- 由公式逻辑生成:`Growth Amt = CY Amt - LY Amt`。
|
||||
- 应与 P/Q 精确一致,不可独立随机。
|
||||
|
||||
## S 列 `Growth% Amt`
|
||||
|
||||
- 由公式逻辑生成:当 `LY Amt > 0` 时,`Growth% Amt = Growth Amt / LY Amt`。
|
||||
- 当 `LY Amt = 0` 时,可统一规则为 `0` 或空值,且需在交付说明中说明处理口径。
|
||||
- 建议保留 4 位小数或百分比显示格式(如 `0.1234` 对应 `12.34%`)。
|
||||
|
||||
## T 列 `CY Qty`
|
||||
|
||||
- 当年数量(Current Year Quantity),整数型(建议 >=0)。
|
||||
- 与 P 列金额保持大致单价一致(同一物料单价波动不应过大)。
|
||||
|
||||
## U 列 `LY Qty`
|
||||
|
||||
- 去年同期数量,整数型(建议 >=0)。
|
||||
- 可少量为 0(新品导入场景),但应与 Q 列口径一致。
|
||||
|
||||
## V 列 `Growth Qty`
|
||||
|
||||
- 由公式逻辑生成:`Growth Qty = CY Qty - LY Qty`。
|
||||
- 应与 T/U 严格一致。
|
||||
|
||||
## W 列 `Growth% Qty`
|
||||
|
||||
- 由公式逻辑生成:当 `LY Qty > 0` 时,`Growth% Qty = Growth Qty / LY Qty`。
|
||||
- 当 `LY Qty = 0` 时按统一口径处理(0 或空值),并在交付说明注明。
|
||||
|
||||
## X 列 `CY LE AMT`
|
||||
|
||||
- 当年预计金额(Latest Estimate Amount),数值型,建议保留 2 位小数。
|
||||
- 与 P 列相关但不应完全相同;建议围绕 `CY Amt` 在合理区间波动(如 `0.9x`~`1.15x`)。
|
||||
- 禁止出现明显异常值(如负数、极端大值)破坏整体分布。
|
||||
|
||||
# 3. 真实感与跨列关联(核心,与第 2 节配合)
|
||||
|
||||
- 组织维度关联:`HospitalName/HospitalCode`、`DealerName/DealerCode` 必须一一稳定映射。
|
||||
- 地理维度关联:`Province/City/HospitalName` 三列一致,不得出现跨省错配。
|
||||
- 产品维度关联:`GlobalDivision → LocalDivision → BU → ProductLine → ProductLineType → Material → MaterialDesc` 需层级一致。
|
||||
- 指标维度关联:`Growth Amt` 与 `Growth% Amt` 由金额推导;`Growth Qty` 与 `Growth% Qty` 由数量推导,不能脱离基础值随机填。
|
||||
- 经营合理性:金额与数量保持可解释的单价区间;避免同一物料在相邻月份出现无理由 10 倍跳变。
|
||||
|
||||
# 4. 输出
|
||||
|
||||
新建 Excel 文件,保存到 `贝朗数据` 文件夹下。
|
||||
建议文件名:`入院量数据-模拟1000条-YYYYMMDD.xlsx`(日期为生成当日)。
|
||||
工作表名仍为 `Sheet1`;第 1 行为原表头,第 2–1001 行为数据。
|
||||
列顺序与列名与源表头文件完全一致,便于后续 Power BI 或透视分析使用。
|
||||
|
||||
# 5. 交付时请用文字简要说明
|
||||
|
||||
- 组织维度(医院、经销商)与产品维度(事业部、产品线、物料)的生成口径。
|
||||
- 金额/数量及增长率字段的计算口径(尤其 LY=0 时的处理规则)。
|
||||
- 各年度、主要产品线、主要省份的数据占比概览。
|
||||
- 明确声明:本文件为合成数据,不代表真实业务入院量或销售数据。
|
||||
|
||||
---
|
||||
|
||||
## 附:执行自检清单
|
||||
|
||||
| 检查项 | 合格标准 |
|
||||
|------|------|
|
||||
| 行数 | 总行数为 1001(含表头),数据行 1000。 |
|
||||
| 列结构 | 24 列列名与顺序与 `入院量-表头.xlsx` 完全一致。 |
|
||||
| 编码一致性 | 同一医院/经销商/物料名称对应唯一编码,不发生混码。 |
|
||||
| 计算一致性 | R/S/V/W 与 P/Q/T/U 计算逻辑一致,无公式冲突。 |
|
||||
| 业务合理性 | 省市医院匹配、产品层级匹配、金额数量分布无明显异常。 |
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
生成药品不良事件&投诉模拟数据
|
||||
|
||||
# 角色与目标
|
||||
|
||||
你是数据分析助手。请根据本地 Excel 表头,生成 1000 条「药品不良事件与投诉」模拟数据(仅用于内部分析、培训或演示,非真实药物警戒上报数据),并导出为新的 Excel 文件。
|
||||
|
||||
# 1. 输入文件(必须先读)
|
||||
|
||||
路径:`贝朗数据/药品不良事件&投诉数据-表头.xlsx`(若工作区根目录不同,以用户提供的「贝朗数据」文件夹为准)。
|
||||
|
||||
工作表名:`Tabelle1`(与源文件一致)。
|
||||
|
||||
表头共 70 列,必须严格保持列名与顺序不变(第 1 行原样保留):
|
||||
|
||||
| 序号 | 字段名 |
|
||||
|---|---|
|
||||
| 1 | Case owner |
|
||||
| 2 | reporter |
|
||||
| 3 | Inbound Channel |
|
||||
| 4 | Local PV ReceiptDate(含原表头换行说明) |
|
||||
| 5 | Initial data entry date |
|
||||
| 6 | Reportability to HA |
|
||||
| 7 | Due Date to ADR Center |
|
||||
| 8 | AER# |
|
||||
| 9 | CC# |
|
||||
| 10 | Submit date |
|
||||
| 11 | 反馈码 |
|
||||
| 12 | 首次/跟踪报告 |
|
||||
| 13 | 预期/非预期 |
|
||||
| 14 | 报告类型 |
|
||||
| 15 | 严重不良反应 |
|
||||
| 16 | 报告单位类别 |
|
||||
| 17 | 性别 |
|
||||
| 18 | 出生日期 |
|
||||
| 19 | 年龄 |
|
||||
| 20 | 年龄单位 |
|
||||
| 21 | 民族 |
|
||||
| 22 | 体重 |
|
||||
| 23 | 既往药品不良反应 |
|
||||
| 24 | 家族药品不良反应 |
|
||||
| 25 | 不良反应发生时间 |
|
||||
| 26 | 不良反应过程描述 |
|
||||
| 27 | 不良反应结果 |
|
||||
| 28 | 后遗症表现 |
|
||||
| 29 | 死亡时间 |
|
||||
| 30 | 直接死因 |
|
||||
| 31 | 停药减药后反应是否减轻或消失 |
|
||||
| 32 | 再次使用可疑药是否出现同样反应 |
|
||||
| 33 | 对原患疾病影响 |
|
||||
| 34 | 报告人评价 |
|
||||
| 35 | 报告单位评价 |
|
||||
| 36 | 报告日期 |
|
||||
| 37 | 信息来源 |
|
||||
| 38 | 备注 |
|
||||
| 39 | 原患疾病名称 |
|
||||
| 40 | 家族药品不良反应[不良反应名称] |
|
||||
| 41 | 家族药品不良反应[商品名称] |
|
||||
| 42 | 家族药品不良反应[严重程度] |
|
||||
| 43 | 既往药品不良反应[不良反应名称] |
|
||||
| 44 | 既往药品不良反应[严重程度] |
|
||||
| 45 | 既往药品不良反应[商品名称] |
|
||||
| 46 | 相关重要信息 |
|
||||
| 47 | 相关重要信息(其他) |
|
||||
| 48 | 怀疑/合并 |
|
||||
| 49 | 序号 |
|
||||
| 50 | 批准文号 |
|
||||
| 51 | 通用名称 |
|
||||
| 52 | 商品名称 |
|
||||
| 53 | 剂型 |
|
||||
| 54 | 生产厂家 |
|
||||
| 55 | 生产批号 |
|
||||
| 56 | 用量 |
|
||||
| 57 | 用量单位 |
|
||||
| 58 | 用药-次数 |
|
||||
| 59 | 用药-日数 |
|
||||
| 60 | 给药途径 |
|
||||
| 61 | 用药开始时间 |
|
||||
| 62 | 用药结束时间 |
|
||||
| 63 | 用药原因 |
|
||||
| 64 | 不良反应名称 |
|
||||
| 65 | 严重程度 |
|
||||
| 66 | 不良反应名称(PT) |
|
||||
| 67 | 不良反应名称(SOC) |
|
||||
| 68 | 省 |
|
||||
| 69 | 市 |
|
||||
| 70 | 区 |
|
||||
|
||||
# 2. 各列填充规则(1–70)
|
||||
|
||||
以下规则适用于第 2–1001 行;第 1 行为原表头,不得改动。
|
||||
|
||||
## A. 病例流转与时效字段(1–11)
|
||||
|
||||
- `Case owner`、`reporter`:使用岗位化姓名(匿名化),格式统一,避免真实个人敏感信息。
|
||||
- `Inbound Channel`:枚举值(如 `电话`、`邮件`、`销售反馈`、`医疗机构上报`、`监管转入`)。
|
||||
- 日期逻辑必须满足:
|
||||
`Local PV ReceiptDate <= Initial data entry date <= Submit date`。
|
||||
若 `Reportability to HA=是`,则 `Due Date to ADR Center` 必填,且应晚于 `ReceiptDate`。
|
||||
- `AER#`、`CC#`:各自唯一编码,建议格式如 `AER-YYYY-000001`、`CC-YYYY-000001`。
|
||||
- `反馈码`:有限枚举(如 `有效`、`无效`、`待补充`)。
|
||||
|
||||
## B. 报告属性与患者基本信息(12–24)
|
||||
|
||||
- `首次/跟踪报告`:枚举 `首次`、`跟踪`,若为跟踪,`AER#` 应复用同案例主编号规则。
|
||||
- `预期/非预期`、`报告类型`、`严重不良反应`、`报告单位类别`使用固定枚举,避免自由文本混乱。
|
||||
- 患者信息联动:`出生日期`、`年龄`、`年龄单位`必须可互相解释(允许有轻微误差);`体重`为数值型并在合理区间。
|
||||
- `既往药品不良反应`、`家族药品不良反应`与其展开字段(40–45)保持一致:若主字段为“无”,对应展开字段应空或 `N/A`。
|
||||
|
||||
## C. 反应发生与医学结局(25–38)
|
||||
|
||||
- `不良反应发生时间`应晚于或等于 `用药开始时间`(若有)。
|
||||
- `不良反应过程描述`为中文临床叙述,简洁、可读、与药品信息一致。
|
||||
- `不良反应结果`、`后遗症表现`、`死亡时间`、`直接死因`联动:
|
||||
- 非死亡病例:`死亡时间/直接死因`应为空;
|
||||
- 死亡病例:`严重程度`应为重度相关,且死因可解释。
|
||||
- `停药减药后反应是否减轻或消失`、`再次使用可疑药是否出现同样反应`使用标准枚举(是/否/不详/未再用)。
|
||||
- `报告人评价`与`报告单位评价`给出一致但不完全重复的专业结论文本。
|
||||
- `报告日期`通常晚于 `不良反应发生时间`。
|
||||
- `信息来源`、`备注`可补充场景,但不得出现真实可识别隐私信息。
|
||||
|
||||
## D. 既往/家族史扩展与重要信息(39–47)
|
||||
|
||||
- `原患疾病名称`使用规范疾病名(示例:高血压、糖尿病、慢性肾病等)。
|
||||
- 家族史与既往史展开字段(40–45)建议结构化填写:
|
||||
- 不良反应名称
|
||||
- 涉及商品名称
|
||||
- 严重程度
|
||||
- `相关重要信息`可枚举(如 `肝功能异常`、`肾功能异常`、`妊娠`、`过敏体质`、`其他`);
|
||||
若为`其他`,`相关重要信息(其他)`必须填写。
|
||||
|
||||
## E. 用药信息子表(48–63)
|
||||
|
||||
- `怀疑/合并`:枚举 `怀疑药品`、`合并用药`,至少有一条怀疑药品记录。
|
||||
- `序号`:同一病例内递增序号(若每行仅1药,可统一为 1)。
|
||||
- `批准文号`、`通用名称`、`商品名称`、`剂型`、`生产厂家`、`生产批号`需互相匹配且风格真实;文号可模拟但格式需合理。
|
||||
- `用量`为数值,`用量单位`(mg/ml/片等)与剂型匹配。
|
||||
- `用药-次数`、`用药-日数`与`用药开始/结束时间`可互相解释(结束时间应不早于开始时间)。
|
||||
- `给药途径`(口服/静脉滴注/皮下/肌注等)需与药品剂型和反应场景匹配。
|
||||
- `用药原因`与`原患疾病名称`存在医学关联。
|
||||
|
||||
## F. 不良反应标准术语与地区字段(64–70)
|
||||
|
||||
- `不良反应名称`使用中文医学术语;`不良反应名称(PT)`、`(SOC)`与其对应(可参考 MedDRA 术语风格)。
|
||||
- `严重程度`与上文 `严重不良反应`一致,不得矛盾。
|
||||
- `省/市/区`三级行政区必须逻辑一致,不跨省错配;可与报告单位地理分布相匹配。
|
||||
|
||||
# 3. 真实感与跨列关联(核心)
|
||||
|
||||
- **时间链路闭环**:获知日期、录入日期、上报日期、发生日期、用药起止日期必须前后合理。
|
||||
- **医学逻辑闭环**:可疑药品 -> 用药信息 -> 不良反应名称/PT/SOC -> 严重程度 -> 结局描述应一致。
|
||||
- **结构化字段闭环**:主字段与展开字段(既往史、家族史、重要信息)不冲突。
|
||||
- **编码唯一性**:`AER#` 和 `CC#` 全表唯一;若设计跟踪报告,应保持主案例可追踪。
|
||||
- **文本去模板化**:过程描述和评价需同类可变体,不得 1000 条高度重复同一句。
|
||||
|
||||
# 4. 输出
|
||||
|
||||
新建 Excel 文件,保存到 `贝朗数据` 文件夹下。
|
||||
建议文件名:`药品不良事件&投诉数据-模拟1000条-YYYYMMDD.xlsx`(日期为生成当日)。
|
||||
工作表名仍为 `Tabelle1`;第 1 行为原表头,第 2–1001 行为数据。
|
||||
列顺序与列名与源表头文件完全一致(包括原始表头中的换行字符)。
|
||||
|
||||
# 5. 交付时请用文字简要说明
|
||||
|
||||
- 病例来源渠道、报告类型、严重程度的大致分布口径。
|
||||
- 用药信息与反应术语(PT/SOC)的映射口径与来源说明(如为归纳模拟需明确说明)。
|
||||
- 日期与状态字段的约束规则(尤其上报时限字段)。
|
||||
- 明确声明:本文件为合成数据,不代表真实药物警戒病例或监管上报记录。
|
||||
|
||||
---
|
||||
|
||||
## 附:执行自检清单
|
||||
|
||||
| 检查项 | 合格标准 |
|
||||
|---|---|
|
||||
| 行数 | 总行数 1001(含表头),数据行 1000。 |
|
||||
| 列结构 | 70 列列名与顺序与 `药品不良事件&投诉数据-表头.xlsx` 完全一致。 |
|
||||
| 编码规则 | `AER#`、`CC#` 唯一;跟踪报告可追踪。 |
|
||||
| 时间逻辑 | 关键日期前后关系正确,无明显逆序。 |
|
||||
| 术语一致 | 反应中文名、PT、SOC、严重程度互相匹配。 |
|
||||
| 字段联动 | 主字段与展开字段(既往史/家族史/重要信息)一致。 |
|
||||
| 合规脱敏 | 不出现真实可识别个人电话和身份信息。 |
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
生成质量投诉模拟数据
|
||||
|
||||
# 角色与目标
|
||||
|
||||
你是数据分析助手。请根据本地 Excel 表头,生成 1000 条「贝朗(B. Braun)产品质量投诉」模拟数据(仅用于内部分析、培训或演示,非真实投诉记录),并导出为新的 Excel 文件。
|
||||
|
||||
# 1. 输入文件(必须先读)
|
||||
|
||||
路径:`贝朗数据/质量投诉数据-表头.xlsx`(若工作区根目录不同,以用户提供的「贝朗数据」文件夹为准)。
|
||||
|
||||
| 列 | 字段名 |
|
||||
|----|--------|
|
||||
| A | C3编号 |
|
||||
| B | 型号 |
|
||||
| C | 批号 |
|
||||
| D | 序列号 |
|
||||
| E | 生产企业名称 |
|
||||
| F | 注册证号 |
|
||||
| G | 产品名称 |
|
||||
| H | 医院名称 |
|
||||
| I | 投诉联系人 |
|
||||
| J | 联系人电话 |
|
||||
| K | 故障类型 |
|
||||
| L | 投诉详情(中文) |
|
||||
| M | 上报人 |
|
||||
| N | BU |
|
||||
| O | C3登记日期 |
|
||||
| P | C3登记月 |
|
||||
| Q | 是否不良事件 |
|
||||
| R | 不良事件(否) |
|
||||
| S | 上报坏品数量 |
|
||||
| T | 坏品退回QA数量 |
|
||||
| U | A退回原厂数量 |
|
||||
| V | 调查报告完成日期 |
|
||||
| W | 调查报告(处理意见) |
|
||||
| X | 调查报告中文(处理意见) |
|
||||
| Y | 调查结论(处理结果) |
|
||||
| Z | 赔付结论 |
|
||||
| AA | 关闭日期 |
|
||||
| AB | 投诉状态 |
|
||||
| AC | 有样品返回 |
|
||||
| AD | 事业部 |
|
||||
| AE | 例数 |
|
||||
|
||||
工作表名:`complaint form`(与源文件一致)。
|
||||
|
||||
# 2. 各列填充规则(A–AE)
|
||||
|
||||
以下规则适用于第 2–1001 行数据行;第 1 行为表头,不得改动列名与列顺序。
|
||||
|
||||
## A 列 `C3编号`
|
||||
|
||||
- 1000 条记录必须唯一。
|
||||
- 格式建议:`C3-YYYY-6位序号`(如 `C3-2026-000001`)。
|
||||
- 若源系统存在固定前缀规则,优先匹配该风格。
|
||||
|
||||
## B 列 `型号`
|
||||
|
||||
- 与 G 列 `产品名称`严格匹配,采用医疗器械常见型号表达(含规格代码、尺寸、Gauge 等)。
|
||||
- 同一产品名称应对应多个型号,避免全表单一型号。
|
||||
|
||||
## C 列 `批号`
|
||||
|
||||
- 字母数字组合(如 `A7429158`),长度风格统一。
|
||||
- 允许低比例重复(同批次多例投诉),但不可大面积重复。
|
||||
|
||||
## D 列 `序列号`
|
||||
|
||||
- 对于有唯一序列号管理的设备/器械填写唯一值;耗材类可用空值或 `N/A`(需统一口径)。
|
||||
- 若填写,建议格式:`SN` + 8~12 位字符。
|
||||
|
||||
## E 列 `生产企业名称`
|
||||
|
||||
- 使用贝朗相关主体或生产方命名风格(如贝朗医疗相关公司名)。
|
||||
- 与产品线存在常识性匹配,避免明显冲突。
|
||||
|
||||
## F 列 `注册证号`
|
||||
|
||||
- 使用与 NMPA 注册证号风格一致的格式(可虚构,但格式合理)。
|
||||
- 可出现历史证号/曾用证号场景,但不得宣称与真实公示逐条一致。
|
||||
|
||||
## G 列 `产品名称`
|
||||
|
||||
- 基于贝朗在中国常见产品线归纳(输液、透析、外科等)并保持可解释性。
|
||||
- 与 B/K/AD 等列保持逻辑一致。
|
||||
|
||||
## H 列 `医院名称`
|
||||
|
||||
- 采用中国境内医院全称风格,覆盖多个省市和层级医院。
|
||||
- 与投诉场景合理匹配,不出现明显虚构乱码名称。
|
||||
|
||||
## I 列 `投诉联系人`
|
||||
|
||||
- 使用匿名化中文姓名(如“张医生”“李护士长”或“王老师”),避免真实个人敏感信息。
|
||||
- 可重复但不宜全表高度重复。
|
||||
|
||||
## J 列 `联系人电话`
|
||||
|
||||
- 使用脱敏规则生成(如 `138****5621`)或模拟号段(11 位手机格式)。
|
||||
- 禁止真实可识别电话号码。
|
||||
|
||||
## K 列 `故障类型`
|
||||
|
||||
- 使用有限枚举值,如:`渗漏`、`堵塞`、`断裂`、`连接不牢`、`包装破损`、`标签不清`、`流速异常` 等。
|
||||
- 与 G/L 语义一致。
|
||||
|
||||
## L 列 `投诉详情(中文)`
|
||||
|
||||
- 用简洁中文描述现场问题、发现环节、初步影响。
|
||||
- 必须与 K 列故障类型一致,避免“类型-详情”冲突。
|
||||
|
||||
## M 列 `上报人`
|
||||
|
||||
- 使用医院端岗位化称谓(如“设备科-赵工”“护理部-陈老师”)或匿名姓名。
|
||||
- 与 H/I 保持合理关联。
|
||||
|
||||
## N 列 `BU`
|
||||
|
||||
- 业务单元枚举(如 `IV Therapy BU`、`Renal Care BU`、`Surgical BU`)。
|
||||
- 与 G/AD 产品事业线一致。
|
||||
|
||||
## O 列 `C3登记日期`
|
||||
|
||||
- 日期型字段,建议覆盖近 24~36 个月。
|
||||
- 需早于或等于 V 列、AA 列对应日期(若后两者非空)。
|
||||
|
||||
## P 列 `C3登记月`
|
||||
|
||||
- 建议格式:`YYYY-MM`,且必须由 O 列日期派生。
|
||||
- 禁止与 O 列月份不一致。
|
||||
|
||||
## Q 列 `是否不良事件`
|
||||
|
||||
- 枚举:`是`/`否`。
|
||||
- 与 R 列联动:若 Q=`是`,R 应为空或 `N/A`;若 Q=`否`,R 必须有原因说明。
|
||||
|
||||
## R 列 `不良事件(否)`
|
||||
|
||||
- 仅在 Q=`否` 时填写,如:`未造成患者伤害`、`仅质量缺陷,无临床后果`。
|
||||
- Q=`是` 时应为空值或统一占位。
|
||||
|
||||
## S 列 `上报坏品数量`
|
||||
|
||||
- 正整数,通常 1~20;特殊批量事件可更高但占比应低。
|
||||
- 与 AC(是否有样品返回)和 T/U 数量关系一致。
|
||||
|
||||
## T 列 `坏品退回QA数量`
|
||||
|
||||
- 整数,范围 `0 <= T <= S`。
|
||||
- 若 AC=`否`,通常 T=0(或极低比例例外并需可解释)。
|
||||
|
||||
## U 列 `A退回原厂数量`
|
||||
|
||||
- 整数,范围 `0 <= U <= T`。
|
||||
- 不应大于 T,且与处理流程状态一致。
|
||||
|
||||
## V 列 `调查报告完成日期`
|
||||
|
||||
- 日期型;通常晚于 O 列。
|
||||
- 对于 `投诉状态=处理中` 可为空;`已关闭` 应有值。
|
||||
|
||||
## W 列 `调查报告(处理意见)`
|
||||
|
||||
- 可用简短英文或系统化术语(如 `Replace`, `No defect found`, `Training reinforced`)。
|
||||
- 与 X 中文意见语义一致。
|
||||
|
||||
## X 列 `调查报告中文(处理意见)`
|
||||
|
||||
- 中文处理意见,示例:`更换同批次产品并加强到货检验`、`复测未见异常,建议规范操作培训`。
|
||||
- 与 Y/Z/AB 结论一致。
|
||||
|
||||
## Y 列 `调查结论(处理结果)`
|
||||
|
||||
- 枚举建议:`产品缺陷成立`、`操作不当`、`运输损伤`、`未复现`、`资料不足`。
|
||||
- 与故障类型、处理意见、赔付结论相互印证。
|
||||
|
||||
## Z 列 `赔付结论`
|
||||
|
||||
- 枚举建议:`无赔付`、`换货`、`折让`、`退款`、`其他协商处理`。
|
||||
- 对应 Y 结果和投诉严重程度,避免明显不合理组合。
|
||||
|
||||
## AA 列 `关闭日期`
|
||||
|
||||
- 日期型;`已关闭` 状态必须有关闭日期,且 `AA >= V >= O`(当 V 非空)。
|
||||
- `处理中` 可为空。
|
||||
|
||||
## AB 列 `投诉状态`
|
||||
|
||||
- 枚举建议:`新建`、`调查中`、`待补充`、`已关闭`。
|
||||
- 与 V/AA 是否为空保持一致。
|
||||
|
||||
## AC 列 `有样品返回`
|
||||
|
||||
- 枚举:`是`/`否`。
|
||||
- 与 T/U/S 数量字段联动(无样品返回时,通常 QA/原厂退回数量为 0)。
|
||||
|
||||
## AD 列 `事业部`
|
||||
|
||||
- 事业部名称(如 `输液治疗`、`透析`、`外科`)。
|
||||
- 与 N(BU)和 G(产品名称)一致,不得错配。
|
||||
|
||||
## AE 列 `例数`
|
||||
|
||||
- 整数,建议多数为 `1`,聚集性投诉可 >1。
|
||||
- 与 S 列数量逻辑可区分:`例数`是案例数,`上报坏品数量`是坏品件数。
|
||||
|
||||
# 3. 真实感与跨列关联(核心,与第 2 节配合)
|
||||
|
||||
- 产品与组织一致性:`产品名称-型号-事业部-BU` 必须同一业务语境。
|
||||
- 时间一致性:`登记日期 -> 调查完成日期 -> 关闭日期` 顺序正确。
|
||||
- 状态一致性:`投诉状态` 与 `调查完成/关闭日期` 及 `处理意见`匹配。
|
||||
- 数量一致性:满足 `0 <= U <= T <= S`,并与 `有样品返回` 联动。
|
||||
- 事件一致性:`是否不良事件` 与 `不良事件(否)` 互斥逻辑严格执行。
|
||||
- 文本一致性:`故障类型`、`投诉详情`、`调查结论`、`赔付结论` 语义闭环,不互相矛盾。
|
||||
|
||||
# 4. 输出
|
||||
|
||||
新建 Excel 文件,保存到 `贝朗数据` 文件夹下。
|
||||
建议文件名:`质量投诉数据-模拟1000条-YYYYMMDD.xlsx`(日期为生成当日)。
|
||||
工作表名仍为 `complaint form`;第 1 行为原表头,第 2–1001 行为数据。
|
||||
列顺序与列名与源表头文件完全一致,便于后续 Power BI 或透视分析使用。
|
||||
|
||||
# 5. 交付时请用文字简要说明
|
||||
|
||||
- 产品清单、故障类型、事业部/BU 的生成口径。
|
||||
- 状态流转与日期逻辑(新建/调查中/已关闭)的规则。
|
||||
- 数量字段(S/T/U/AE)的约束规则与异常处理口径。
|
||||
- 明确声明:本文件为合成数据,不代表真实质量投诉或不良事件记录。
|
||||
|
||||
---
|
||||
|
||||
## 附:执行自检清单
|
||||
|
||||
| 检查项 | 合格标准 |
|
||||
|------|------|
|
||||
| 行数 | 总行数 1001(含表头),数据行 1000。 |
|
||||
| 列结构 | 31 列列名与顺序与 `质量投诉数据-表头.xlsx` 完全一致。 |
|
||||
| 编码唯一性 | A 列 `C3编号` 唯一;医院/产品关键映射稳定。 |
|
||||
| 数量约束 | 全量满足 `0 <= U <= T <= S`。 |
|
||||
| 日期约束 | 已关闭记录满足 `AA >= V >= O`(当 V 非空)。 |
|
||||
| 状态约束 | `已关闭` 记录有关闭日期;`调查中` 可无关闭日期。 |
|
||||
| 事件约束 | Q/R 列互斥逻辑一致,无冲突值。 |
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue