348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
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 简化:全局月度 PPM(AE 按发生月 / 入院量按业务月) */
|
||
export function buildGlobalMonthlyPpm(context: AnalysisContext) {
|
||
const aeByMonth = new Map<string, number>()
|
||
context.ae.forEach((r) => {
|
||
const k = monthKey(r.occurrenceDate)
|
||
aeByMonth.set(k, (aeByMonth.get(k) ?? 0) + 1)
|
||
})
|
||
const qtyByMonth = buildAdmissionQtyByMonth(context)
|
||
const months = [...new Set([...aeByMonth.keys(), ...qtyByMonth.keys()])].sort()
|
||
const ppm = months.map((m) => {
|
||
const num = aeByMonth.get(m) ?? 0
|
||
const den = qtyByMonth.get(m) ?? 0
|
||
if (den <= 0) return null
|
||
return Number(((num / den) * 1_000_000).toFixed(2))
|
||
})
|
||
return { months, ppm }
|
||
}
|
||
|
||
/** CMP-AE-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-501:SAE 报告条数(按发生月) */
|
||
export function buildSaeMonthlyByOccurrence(context: AnalysisContext) {
|
||
const counter = new Map<string, number>()
|
||
context.ae.forEach((record) => {
|
||
if (!isSaeRecord(record)) return
|
||
const key = monthKey(record.occurrenceDate)
|
||
counter.set(key, (counter.get(key) ?? 0) + 1)
|
||
})
|
||
const months = [...counter.keys()].sort()
|
||
return {
|
||
months,
|
||
values: months.map((item) => counter.get(item) ?? 0),
|
||
}
|
||
}
|
||
|
||
/** CMP-AE-502:SAE 占全部 AE 比例(按发生月) */
|
||
export function buildSaeShareMonthly(context: AnalysisContext) {
|
||
const total = new Map<string, number>()
|
||
const sae = new Map<string, number>()
|
||
context.ae.forEach((r) => {
|
||
const k = monthKey(r.occurrenceDate)
|
||
total.set(k, (total.get(k) ?? 0) + 1)
|
||
if (isSaeRecord(r)) sae.set(k, (sae.get(k) ?? 0) + 1)
|
||
})
|
||
const months = [...total.keys()].sort()
|
||
const shares = months.map((m) => {
|
||
const t = total.get(m) ?? 1
|
||
const s = sae.get(m) ?? 0
|
||
return Number(((s / t) * 100).toFixed(1))
|
||
})
|
||
return { months, shares }
|
||
}
|
||
|
||
/** CMP-AE-601:省级 AE 报告数(发生日期全期) */
|
||
export function buildProvinceAeCounts(context: AnalysisContext) {
|
||
const counter = new Map<string, number>()
|
||
context.ae.forEach((r) => {
|
||
counter.set(r.province, (counter.get(r.province) ?? 0) + 1)
|
||
})
|
||
const sorted = [...counter.entries()].sort((a, b) => b[1] - a[1])
|
||
return { labels: sorted.map((i) => i[0]), values: sorted.map((i) => i[1]) }
|
||
}
|
||
|
||
/** CMP-AE-701:Lag = T_end(审核日期) − 登记日期,返回原始天数列表 */
|
||
export function buildReportingLagDays(context: AnalysisContext): number[] {
|
||
return context.ae.map((r) => {
|
||
const lagMs = parseDay(r.reviewDate) - parseDay(r.registrationDate)
|
||
return Math.round(lagMs / 86400000)
|
||
})
|
||
}
|
||
|
||
/** CMP-AE-702 演示:≤15 天为合规 */
|
||
export function buildReportingTimelinessRate(context: AnalysisContext, thresholdDays = 15) {
|
||
const lags = buildReportingLagDays(context)
|
||
const ok = lags.filter((d) => d >= 0 && d <= thresholdDays).length
|
||
const denom = lags.filter((d) => d >= 0).length || 1
|
||
return { rate: Number(((ok / denom) * 100).toFixed(1)), sample: denom }
|
||
}
|
||
|
||
/** CMP-AE-301:器械故障 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,
|
||
}
|
||
}
|