410 lines
19 KiB
TypeScript
410 lines
19 KiB
TypeScript
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
|
||
}
|