This commit is contained in:
williamwan 2026-04-16 13:15:17 +08:00
commit 56dd8a9628
76 changed files with 59970 additions and 0 deletions

142
AI问答策略.md Normal file
View File

@ -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 增减分析维度) |

24
analytics-demo-web/.gitignore vendored Normal file
View File

@ -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?

View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@ -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).

View File

@ -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>

3047
analytics-demo-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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×1e6AE 按发生日期入桶,入院量按年月与产品汇总后与 AE 产品名对齐(演示)。'
export const COMPLIANCE_FOOTNOTE_AUDIT =
'漏报:投诉「是否不良事件=是」在 ±30 天内无匹配 AE医院+产品+型号发生日期↔C3登记时效Lag=审核日期登记日期演示阈值≤15 天。'

View File

@ -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 取众数'

View File

@ -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),
)

View File

@ -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 按医院+产品汇总(演示未按月份与投诉月严格对齐)'

View File

@ -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>

View File

@ -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 简化:全局月度 PPMAE 按发生月 / 入院量按业务月) */
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-501SAE 报告条数(按发生月) */
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-502SAE 占全部 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-701Lag = 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,
}
}

View File

@ -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
}

View File

@ -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]) }
}

View File

@ -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
}

View File

@ -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'),
}
}

View File

@ -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(16月) vs H2(712月) 产品投诉增速 ≥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,
}))
}

View File

@ -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')

View File

@ -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

View File

@ -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)
},
},
})

View File

@ -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 = ''
},
},
})

View File

@ -0,0 +1,10 @@
* {
box-sizing: border-box;
}
/* body / #app 基础样式见 assets/styles/index.scss */
#app {
width: 100%;
min-height: 100vh;
}

View File

@ -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[]
}

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -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"]
}

View File

@ -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'),
},
},
})

View File

@ -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()

143
vite.config.js Normal file
View File

@ -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: []
}
}
}
})

View File

@ -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}")

View File

@ -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 **报告条数**或 PPMAE 时间轴 **发生日期**;注意外部混杂,页面提示「关联非因果」)。 | **改进叙事图**:对内对外说明「已采取控制措施」的佐证材料。 |
---
## 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 可另页维护。*

View File

@ -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`)不参与汇总列表。
- 每条结论建议控制在 **24 个要点**,与单图 `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 再排版。
---
*文档版本:与前端「画像与综合 → 综合分析」菜单一致。*

View File

@ -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` 营销方向菜单。*

View File

@ -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 画像扩展说明)。
- 构建“事件严重度-业务影响度”双轴优先级矩阵
- 构建“医院分层经营策略”建议(高风险治理型/高潜力增长型)
- 构建“质量成本-业务收益”平衡模型(赔付、退换、增长)
- 构建“监管风险热区地图”(按产品、区域、时间)

View File

@ -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 主题 JAE 升级与证据链(与合规衔接)
| 指标编码 | 指标名称 | 计算逻辑与方法 | 页面呈现用途 |
|----------|----------|----------------|--------------|
| **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 中 **gy**、§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 可另页维护。*

1968
前端技术设计规范.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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 | 销售年月 | 20242026 |
| 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.

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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. 各列填充规则AM
以下规则适用于第 21001 行数据行;第 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下可出现**多条不同证号**(曾用证、换证等场景),但须避免全表仅 12 个证号来回复制。
- **禁止**在交付物中声称「本列与 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 列「审核日期」
- 落在近 **2436 个月**内的日期范围;优先使用**工作日**(周一至周五),减少全体落在周末的不自然分布。
- Excel 中保存为**日期型**(非纯文本),显示格式可与源表头文件一致(如 `YYYY-MM-DD`)。
- 全表日期应有一定**随机分散**,避免集中为同一天或呈完全规律递增。
# 3. 真实感与「产品—事件」关联(核心,与第 2 节配合)
- **D、E、H** 三位一体:事业线、产品名、型号须为同一叙事下的器械信息。
- **J、K、L** 须与产品类别逻辑一致,例如:
- **输液/输注类**:渗漏、堵塞、流速异常、管路打折/断裂、接头不牢、排气问题等;
- **透析类**:跨膜压/电导度异常、管路渗血渗液、透析器外壳问题、液面异常等;
- **外科/缝线类**:线结滑脱、缝针异常、异物感、固定或粘贴失效等。
- **C 列**可间接体现场景(大型综合医院、肿瘤中心等),与产品使用场景不冲突。
- 避免每条记录使用**完全相同**的 K/L 模板句;在同类别内替换措辞、程度与发现环节。
# 4. 输出
新建 Excel 文件,保存到 **贝朗数据** 文件夹下。
建议文件名:`不良事件数据-模拟1000条-YYYYMMDD.xlsx`(日期为生成当日)。
工作表名仍为 **POWER BI 总信息**;第 1 行为原表头,第 21001 行为数据。
列顺序与列名与源表头文件**完全一致**,便于后续 Power BI 或透视表使用。
# 5. 交付时请用文字简要说明
- 产品清单主要依据哪些公开来源(或说明为归纳模拟)。
- 各事业线/主要产品的大致条数占比。
- **明确声明**:本文件为**合成数据**,不代表现实中的不良事件报告。
---
## 附:文档结构说明(供执行方自检)
| 维度 | 说明 |
|------|------|
| 结构 | 已写明工作表名与 13 个字段及**逐列填充规则**,避免猜列或错位。 |
| E 列 | 要求检索路径 + 无法核验时的声明,降低「虚构产品名却被当作事实」的风险。 |
| 真实性 | 各列规则 + 第 3 节跨列关联,保证 J/K/L 与产品线一致及字段多样化。 |
| 输出 | 固定行数21001、命名规则、路径明确。 |
| 合规 | 强调合成数据用途,避免误用作正式上报。 |

View File

@ -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. 各列填充规则AX
以下规则适用于第 21001 行数据行;第 1 行为表头,不得改动列名与列顺序。
## A 列 `Year`
- 建议取近 23 年(如 `2024`、`2025`、`2026`)的整数年份。
- 与 B 列 `Month` 组合后应形成合理时间分布,避免 1000 条全部落在同一年同一月。
## B 列 `Month`
- 取值范围 `1``12`(整数)。
- 与 A 列匹配,允许季节性波动(如 Q4 数值略高)但不要机械重复。
## C 列 `HospitalName`
- 采用中国境内医院全称风格(如「××大学附属××医院」「××省人民医院」)。
- 与 G/H省/市)保持一致,避免城市与医院明显冲突。
- 全表使用多个医院,避免极端集中到单家医院。
## D 列 `HospitalCode`
- 为 `HospitalName` 的稳定唯一编码(同一医院编码必须一致)。
- 格式建议:`H` + 58 位数字或字母数字组合(如 `H310001`)。
- 不同医院不得复用同一编码。
## E 列 `DealerName`
- 使用经销商/渠道商公司名称风格(如「××医疗器械有限公司」「××医药科技有限公司」)。
- 同一医院可出现多个经销商;同一经销商也可服务多个医院。
## F 列 `DealerCode`
- 为 `DealerName` 的稳定唯一编码(同名同码、异名异码)。
- 格式建议:`D` + 48 位数字(如 `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 行为原表头,第 21001 行为数据。
列顺序与列名与源表头文件完全一致,便于后续 Power BI 或透视分析使用。
# 5. 交付时请用文字简要说明
- 组织维度(医院、经销商)与产品维度(事业部、产品线、物料)的生成口径。
- 金额/数量及增长率字段的计算口径(尤其 LY=0 时的处理规则)。
- 各年度、主要产品线、主要省份的数据占比概览。
- 明确声明:本文件为合成数据,不代表真实业务入院量或销售数据。
---
## 附:执行自检清单
| 检查项 | 合格标准 |
|------|------|
| 行数 | 总行数为 1001含表头数据行 1000。 |
| 列结构 | 24 列列名与顺序与 `入院量-表头.xlsx` 完全一致。 |
| 编码一致性 | 同一医院/经销商/物料名称对应唯一编码,不发生混码。 |
| 计算一致性 | R/S/V/W 与 P/Q/T/U 计算逻辑一致,无公式冲突。 |
| 业务合理性 | 省市医院匹配、产品层级匹配、金额数量分布无明显异常。 |

View File

@ -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. 各列填充规则170
以下规则适用于第 21001 行;第 1 行为原表头,不得改动。
## A. 病例流转与时效字段111
- `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. 报告属性与患者基本信息1224
- `首次/跟踪报告`:枚举 `首次`、`跟踪`,若为跟踪,`AER#` 应复用同案例主编号规则。
- `预期/非预期`、`报告类型`、`严重不良反应`、`报告单位类别`使用固定枚举,避免自由文本混乱。
- 患者信息联动:`出生日期`、`年龄`、`年龄单位`必须可互相解释(允许有轻微误差);`体重`为数值型并在合理区间。
- `既往药品不良反应`、`家族药品不良反应`与其展开字段4045保持一致若主字段为“无”对应展开字段应空或 `N/A`
## C. 反应发生与医学结局2538
- `不良反应发生时间`应晚于或等于 `用药开始时间`(若有)。
- `不良反应过程描述`为中文临床叙述,简洁、可读、与药品信息一致。
- `不良反应结果`、`后遗症表现`、`死亡时间`、`直接死因`联动:
- 非死亡病例:`死亡时间/直接死因`应为空;
- 死亡病例:`严重程度`应为重度相关,且死因可解释。
- `停药减药后反应是否减轻或消失`、`再次使用可疑药是否出现同样反应`使用标准枚举(是/否/不详/未再用)。
- `报告人评价`与`报告单位评价`给出一致但不完全重复的专业结论文本。
- `报告日期`通常晚于 `不良反应发生时间`
- `信息来源`、`备注`可补充场景,但不得出现真实可识别隐私信息。
## D. 既往/家族史扩展与重要信息3947
- `原患疾病名称`使用规范疾病名(示例:高血压、糖尿病、慢性肾病等)。
- 家族史与既往史展开字段4045建议结构化填写
- 不良反应名称
- 涉及商品名称
- 严重程度
- `相关重要信息`可枚举(如 `肝功能异常`、`肾功能异常`、`妊娠`、`过敏体质`、`其他`
若为`其他``相关重要信息(其他)`必须填写。
## E. 用药信息子表4863
- `怀疑/合并`:枚举 `怀疑药品`、`合并用药`,至少有一条怀疑药品记录。
- `序号`同一病例内递增序号若每行仅1药可统一为 1
- `批准文号`、`通用名称`、`商品名称`、`剂型`、`生产厂家`、`生产批号`需互相匹配且风格真实;文号可模拟但格式需合理。
- `用量`为数值,`用量单位`mg/ml/片等)与剂型匹配。
- `用药-次数`、`用药-日数`与`用药开始/结束时间`可互相解释(结束时间应不早于开始时间)。
- `给药途径`(口服/静脉滴注/皮下/肌注等)需与药品剂型和反应场景匹配。
- `用药原因`与`原患疾病名称`存在医学关联。
## F. 不良反应标准术语与地区字段6470
- `不良反应名称`使用中文医学术语;`不良反应名称PT`、`SOC`与其对应(可参考 MedDRA 术语风格)。
- `严重程度`与上文 `严重不良反应`一致,不得矛盾。
- `省/市/区`三级行政区必须逻辑一致,不跨省错配;可与报告单位地理分布相匹配。
# 3. 真实感与跨列关联(核心)
- **时间链路闭环**:获知日期、录入日期、上报日期、发生日期、用药起止日期必须前后合理。
- **医学逻辑闭环**:可疑药品 -> 用药信息 -> 不良反应名称/PT/SOC -> 严重程度 -> 结局描述应一致。
- **结构化字段闭环**:主字段与展开字段(既往史、家族史、重要信息)不冲突。
- **编码唯一性**`AER#` 和 `CC#` 全表唯一;若设计跟踪报告,应保持主案例可追踪。
- **文本去模板化**:过程描述和评价需同类可变体,不得 1000 条高度重复同一句。
# 4. 输出
新建 Excel 文件,保存到 `贝朗数据` 文件夹下。
建议文件名:`药品不良事件&投诉数据-模拟1000条-YYYYMMDD.xlsx`(日期为生成当日)。
工作表名仍为 `Tabelle1`;第 1 行为原表头,第 21001 行为数据。
列顺序与列名与源表头文件完全一致(包括原始表头中的换行字符)。
# 5. 交付时请用文字简要说明
- 病例来源渠道、报告类型、严重程度的大致分布口径。
- 用药信息与反应术语PT/SOC的映射口径与来源说明如为归纳模拟需明确说明
- 日期与状态字段的约束规则(尤其上报时限字段)。
- 明确声明:本文件为合成数据,不代表真实药物警戒病例或监管上报记录。
---
## 附:执行自检清单
| 检查项 | 合格标准 |
|---|---|
| 行数 | 总行数 1001含表头数据行 1000。 |
| 列结构 | 70 列列名与顺序与 `药品不良事件&投诉数据-表头.xlsx` 完全一致。 |
| 编码规则 | `AER#`、`CC#` 唯一;跟踪报告可追踪。 |
| 时间逻辑 | 关键日期前后关系正确,无明显逆序。 |
| 术语一致 | 反应中文名、PT、SOC、严重程度互相匹配。 |
| 字段联动 | 主字段与展开字段(既往史/家族史/重要信息)一致。 |
| 合规脱敏 | 不出现真实可识别个人电话和身份信息。 |

View File

@ -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. 各列填充规则AAE
以下规则适用于第 21001 行数据行;第 1 行为表头,不得改动列名与列顺序。
## A 列 `C3编号`
- 1000 条记录必须唯一。
- 格式建议:`C3-YYYY-6位序号`(如 `C3-2026-000001`)。
- 若源系统存在固定前缀规则,优先匹配该风格。
## B 列 `型号`
- 与 G 列 `产品名称`严格匹配采用医疗器械常见型号表达含规格代码、尺寸、Gauge 等)。
- 同一产品名称应对应多个型号,避免全表单一型号。
## C 列 `批号`
- 字母数字组合(如 `A7429158`),长度风格统一。
- 允许低比例重复(同批次多例投诉),但不可大面积重复。
## D 列 `序列号`
- 对于有唯一序列号管理的设备/器械填写唯一值;耗材类可用空值或 `N/A`(需统一口径)。
- 若填写,建议格式:`SN` + 812 位字符。
## 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登记日期`
- 日期型字段,建议覆盖近 2436 个月。
- 需早于或等于 V 列、AA 列对应日期(若后两者非空)。
## P 列 `C3登记月`
- 建议格式:`YYYY-MM`,且必须由 O 列日期派生。
- 禁止与 O 列月份不一致。
## Q 列 `是否不良事件`
- 枚举:`是`/`否`。
- 与 R 列联动:若 Q=`是`R 应为空或 `N/A`;若 Q=`否`R 必须有原因说明。
## R 列 `不良事件(否)`
- 仅在 Q=`否` 时填写,如:`未造成患者伤害`、`仅质量缺陷,无临床后果`。
- Q=`是` 时应为空值或统一占位。
## S 列 `上报坏品数量`
- 正整数,通常 120特殊批量事件可更高但占比应低。
- 与 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 列 `事业部`
- 事业部名称(如 `输液治疗`、`透析`、`外科`)。
- 与 NBU和 G产品名称一致不得错配。
## AE 列 `例数`
- 整数,建议多数为 `1`,聚集性投诉可 >1。
- 与 S 列数量逻辑可区分:`例数`是案例数,`上报坏品数量`是坏品件数。
# 3. 真实感与跨列关联(核心,与第 2 节配合)
- 产品与组织一致性:`产品名称-型号-事业部-BU` 必须同一业务语境。
- 时间一致性:`登记日期 -> 调查完成日期 -> 关闭日期` 顺序正确。
- 状态一致性:`投诉状态` 与 `调查完成/关闭日期``处理意见`匹配。
- 数量一致性:满足 `0 <= U <= T <= S`,并与 `有样品返回` 联动。
- 事件一致性:`是否不良事件` 与 `不良事件(否)` 互斥逻辑严格执行。
- 文本一致性:`故障类型`、`投诉详情`、`调查结论`、`赔付结论` 语义闭环,不互相矛盾。
# 4. 输出
新建 Excel 文件,保存到 `贝朗数据` 文件夹下。
建议文件名:`质量投诉数据-模拟1000条-YYYYMMDD.xlsx`(日期为生成当日)。
工作表名仍为 `complaint form`;第 1 行为原表头,第 21001 行为数据。
列顺序与列名与源表头文件完全一致,便于后续 Power BI 或透视分析使用。
# 5. 交付时请用文字简要说明
- 产品清单、故障类型、事业部/BU 的生成口径。
- 状态流转与日期逻辑(新建/调查中/已关闭)的规则。
- 数量字段S/T/U/AE的约束规则与异常处理口径。
- 明确声明:本文件为合成数据,不代表真实质量投诉或不良事件记录。
---
## 附:执行自检清单
| 检查项 | 合格标准 |
|------|------|
| 行数 | 总行数 1001含表头数据行 1000。 |
| 列结构 | 31 列列名与顺序与 `质量投诉数据-表头.xlsx` 完全一致。 |
| 编码唯一性 | A 列 `C3编号` 唯一;医院/产品关键映射稳定。 |
| 数量约束 | 全量满足 `0 <= U <= T <= S`。 |
| 日期约束 | 已关闭记录满足 `AA >= V >= O`(当 V 非空)。 |
| 状态约束 | `已关闭` 记录有关闭日期;`调查中` 可无关闭日期。 |
| 事件约束 | Q/R 列互斥逻辑一致,无冲突值。 |