Data_Analysis/analytics-demo-web/src/lib/aggregate.ts

348 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
}
}
/** CMP-AE-101 补充:为月度序列计算对应年度平均数 */
export function buildAeMonthlyWithYearAverage(context: AnalysisContext) {
const monthly = buildAeMonthlyByOccurrence(context)
const yearlyBucket = new Map<string, number[]>()
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<string, number>()
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<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, 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<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-201 补充:月度涉及产品数与 AE 报告数(可设置截止月) */
export function buildMonthlyProductAndAeCounts(context: AnalysisContext, endMonth = '9999-12') {
const productSets = new Map<string, Set<string>>()
const aeCount = new Map<string, number>()
context.ae.forEach((record) => {
const month = monthKey(record.occurrenceDate)
if (month > endMonth) return
const products = productSets.get(month) ?? new Set<string>()
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-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器械故障 Top10含每类涉及产品信息 */
export function buildTopDeviceFailures(context: AnalysisContext, topN = 10) {
const counter = new Map<string, number>()
const productsByFailure = new Map<string, Map<string, number>>()
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<string, number>())
}
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<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
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,
}
}