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() 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), } } /** CMP-AE-101 补充:为月度序列计算对应年度平均数 */ export function buildAeMonthlyWithYearAverage(context: AnalysisContext) { const monthly = buildAeMonthlyByOccurrence(context) const yearlyBucket = new Map() monthly.months.forEach((month, idx) => { const year = month.slice(0, 4) const values = yearlyBucket.get(year) ?? [] values.push(monthly.values[idx]) yearlyBucket.set(year, values) }) const yearlyAvg = new Map() yearlyBucket.forEach((values, year) => { const avg = values.reduce((sum, val) => sum + val, 0) / values.length yearlyAvg.set(year, Number(avg.toFixed(2))) }) return { months: monthly.months, values: monthly.values, yearAvg: monthly.months.map((month) => yearlyAvg.get(month.slice(0, 4)) ?? 0), } } /** 产品事件 Top(全期,按发生日期归属可选扩展;此处为全量计数) */ export function buildPsurTopProducts(context: AnalysisContext, topN = 10) { const counter = new Map() 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, topN) 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 { const map = new Map() context.admissions.forEach((row) => { const key = admissionMonthKey(row) map.set(key, (map.get(key) ?? 0) + row.cyQty) }) return map } /** CMP-AE-201 简化:全局月度 PPM(AE 按发生月 / 入院量按业务月) */ export function buildGlobalMonthlyPpm(context: AnalysisContext) { const aeByMonth = new Map() 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-201 补充:月度涉及产品数与 AE 报告数(可设置截止月) */ export function buildMonthlyProductAndAeCounts(context: AnalysisContext, endMonth = '9999-12') { const productSets = new Map>() const aeCount = new Map() context.ae.forEach((record) => { const month = monthKey(record.occurrenceDate) if (month > endMonth) return const products = productSets.get(month) ?? new Set() products.add(record.productName) productSets.set(month, products) aeCount.set(month, (aeCount.get(month) ?? 0) + 1) }) const months = [...aeCount.keys()].sort() return { months, productCounts: months.map((m) => productSets.get(m)?.size ?? 0), aeCounts: months.map((m) => aeCount.get(m) ?? 0), } } /** CMP-AE-501:SAE 报告条数(按发生月) */ export function buildSaeMonthlyByOccurrence(context: AnalysisContext) { const counter = new Map() context.ae.forEach((record) => { if (!isSaeRecord(record)) return const key = monthKey(record.occurrenceDate) counter.set(key, (counter.get(key) ?? 0) + 1) }) const months = [...counter.keys()].sort() return { months, values: months.map((item) => counter.get(item) ?? 0), } } /** CMP-AE-502:SAE 占全部 AE 比例(按发生月) */ export function buildSaeShareMonthly(context: AnalysisContext) { const total = new Map() const sae = new Map() 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() context.ae.forEach((r) => { counter.set(r.province, (counter.get(r.province) ?? 0) + 1) }) const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1]) return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) } } /** CMP-AE-701:Lag = T_end(审核日期) − 登记日期,返回原始天数列表 */ export function buildReportingLagDays(context: AnalysisContext): number[] { return context.ae.map((r) => { const lagMs = parseDay(r.reviewDate) - parseDay(r.registrationDate) return Math.round(lagMs / 86400000) }) } /** CMP-AE-702 演示:≤15 天为合规 */ export function buildReportingTimelinessRate(context: AnalysisContext, thresholdDays = 15) { const lags = buildReportingLagDays(context) const ok = lags.filter((d) => d >= 0 && d <= thresholdDays).length const denom = lags.filter((d) => d >= 0).length || 1 return { rate: Number(((ok / denom) * 100).toFixed(1)), sample: denom } } /** CMP-AE-301:器械故障 Top10(含每类涉及产品信息) */ export function buildTopDeviceFailures(context: AnalysisContext, topN = 10) { const counter = new Map() const productsByFailure = new Map>() context.ae.forEach((r) => { const k = r.deviceFailure || '(空)' counter.set(k, (counter.get(k) ?? 0) + 1) const p = r.productName || '(空)' if (!productsByFailure.has(k)) { productsByFailure.set(k, new Map()) } const productCounter = productsByFailure.get(k)! productCounter.set(p, (productCounter.get(p) ?? 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))), topProducts: sorted.map(([failure]) => { const productCounter = productsByFailure.get(failure) if (!productCounter) return [] return [...productCounter.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([product, count]) => ({ product, count })) }), } } 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() 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() 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 const matchedLagDays: number[] = [] 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 if (lagReg >= 0) matchedLagDays.push(lagReg) 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, matchedLagDays, missedRows, } }