import { and, asc, eq, gte, isNull, lt } from 'drizzle-orm' import type { Establishment, OshaCase, OshaLogRow, OshaYear } from '../../shared/api-types.ts' import { oshaCaseTypes } from '../../shared/enums.ts' import { incidentReference } from '../../shared/format.ts' import { capDays, incidenceRate, severeInjuryReport, summaryChecks, type SummaryTotals } from '../../shared/osha.ts' import type { Db } from '../db/client.ts' import { areas, hoursWorked, incidents, injuredPersons, organizations, sites } from '../db/schema.ts' import { iso } from './dates.ts' import { fail } from './http.ts' type Person = typeof injuredPersons.$inferSelect type IncidentInfo = { id: string; number: number; title: string; occurredAt: Date; createdAt: Date } /** Jan 1 to Jan 1 in the server's time zone (set TZ to the plant's zone). */ export function yearRange(year: number): [Date, Date] { return [new Date(year, 0, 1), new Date(year + 1, 0, 1)] } /** Fields the ITA case upload and Form 301 require that are still empty. */ export function missingFields(person: Person): string[] { if (!person.recordable) return [] const required: [keyof Person, string][] = [ ['jobTitle', 'job title'], ['dateOfBirth', 'date of birth'], ['dateOfHire', 'date hired'], ['activityBefore', 'what they were doing'], ['whatHappened', 'what happened'], ['natureOfInjury', 'the injury or illness'], ['objectSubstance', 'object or substance'], ] const missing = required.filter(([key]) => !person[key]).map(([, label]) => label) if (person.died && !person.dateOfDeath) missing.push('date of death') return missing } export function toOshaCase(person: Person, incident: IncidentInfo): OshaCase { const severe = severeInjuryReport(person) return { id: person.id, incident: { id: incident.id, number: incident.number, reference: incidentReference(incident.number), title: incident.title, occurredAt: iso(incident.occurredAt) }, caseNumber: person.caseNumber ?? incidentReference(incident.number), name: person.name, jobTitle: person.jobTitle, employmentType: person.employmentType, bodyPart: person.bodyPart, natureOfInjury: person.natureOfInjury, caseType: person.caseType, workRelated: person.workRelated, newCase: person.newCase, died: person.died, dateOfDeath: person.dateOfDeath, daysAwayFromWork: person.daysAwayFromWork, daysAway: person.daysAway, restrictedOrTransfer: person.restrictedOrTransfer, daysRestricted: person.daysRestricted, medicalTreatment: person.medicalTreatment, lossOfConsciousness: person.lossOfConsciousness, significantDiagnosis: person.significantDiagnosis, specialCase: person.specialCase, privacyCase: person.privacyCase, recordable: person.recordable, outcome: person.outcome, inpatientHospitalization: person.inpatientHospitalization, amputation: person.amputation, lossOfEye: person.lossOfEye, reportedToOshaAt: iso(person.reportedToOshaAt), // The clock starts when the employer learns of it; the report date is the best record of that. severeReport: severe && { ...severe, dueBy: new Date(incident.createdAt.getTime() + severe.withinHours * 3_600_000).toISOString() }, employeeAddress: person.employeeAddress, dateOfBirth: person.dateOfBirth, dateOfHire: person.dateOfHire, sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null, physicianName: person.physicianName, treatmentFacility: person.treatmentFacility, treatedInEmergencyRoom: person.treatedInEmergencyRoom, timeStartedWork: person.timeStartedWork, activityBefore: person.activityBefore, whatHappened: person.whatHappened, objectSubstance: person.objectSubstance, missingFields: missingFields(person), updatedAt: iso(person.updatedAt), } } /** Form 300 column F: injury, body part, and what caused it. */ export function describeCase(person: Pick): string { const injury = [person.natureOfInjury, person.bodyPart && `(${person.bodyPart})`].filter(Boolean).join(' ') return [injury, person.objectSubstance].filter(Boolean).join('; ') } export const establishmentOf = (site: typeof sites.$inferSelect): Establishment => ({ streetAddress: site.streetAddress, city: site.city, state: site.state, zip: site.zip, naicsCode: site.naicsCode, industryDescription: site.industryDescription, ein: site.ein, establishmentType: site.establishmentType, }) export function missingEstablishmentFields(site: typeof sites.$inferSelect): string[] { const required: [keyof typeof site, string][] = [ ['streetAddress', 'street address'], ['city', 'city'], ['state', 'state'], ['zip', 'ZIP code'], ['naicsCode', 'NAICS code'], ['ein', 'EIN'], ] return required.filter(([key]) => !site[key]).map(([, label]) => label) } /** Everything for one establishment's year: the 300 Log, 300A totals, hours, rates and ITA checks. */ export async function buildOshaYear(db: Db, organizationId: string, siteId: string, year: number): Promise { const [site] = await db.select().from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId))) if (!site) throw fail(404, 'Site not found') const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, organizationId)) const [from, to] = yearRange(year) const caseRows = await db .select({ person: injuredPersons, incident: incidents, areaName: areas.name }) .from(injuredPersons) .innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId)) .leftJoin(areas, eq(areas.id, incidents.areaId)) .where(and(eq(incidents.siteId, siteId), eq(injuredPersons.recordable, true), gte(incidents.occurredAt, from), lt(incidents.occurredAt, to))) .orderBy(asc(incidents.occurredAt), asc(injuredPersons.caseNumber)) const rows: OshaLogRow[] = caseRows.map(({ person, incident, areaName }) => { const days = capDays(person.daysAwayFromWork ? person.daysAway : 0, person.restrictedOrTransfer ? person.daysRestricted : 0) return { caseId: person.id, caseNumber: person.caseNumber ?? incidentReference(incident.number), incidentNumber: incident.number, employeeName: person.privacyCase ? 'Privacy case' : person.name, jobTitle: person.jobTitle ?? '', dateOfInjury: iso(incident.occurredAt), location: [areaName, incident.locationDetail].filter(Boolean).join(', ') || site.name, description: describeCase(person), outcome: person.outcome ?? 'other', daysAway: days.daysAway, daysRestricted: days.daysRestricted, caseType: person.caseType, missingFields: missingFields(person), } }) const totals: SummaryTotals = { deaths: rows.filter((r) => r.outcome === 'death').length, daysAwayCases: rows.filter((r) => r.outcome === 'days_away').length, restrictedCases: rows.filter((r) => r.outcome === 'restricted').length, otherCases: rows.filter((r) => r.outcome === 'other').length, daysAway: rows.reduce((sum, r) => sum + r.daysAway, 0), daysRestricted: rows.reduce((sum, r) => sum + r.daysRestricted, 0), byType: Object.fromEntries(oshaCaseTypes.map((type) => [type, rows.filter((r) => r.caseType === type).length])) as SummaryTotals['byType'], } const monthRows = await db .select() .from(hoursWorked) .where(and(eq(hoursWorked.siteId, siteId), gte(hoursWorked.month, `${year}-01-01`), lt(hoursWorked.month, `${year + 1}-01-01`))) const months = Array.from({ length: 12 }, (_, i) => { const row = monthRows.find((m) => Number(m.month.slice(5, 7)) === i + 1) return { month: i + 1, hours: row ? Number(row.hours) : null, averageEmployees: row?.averageEmployees ?? null } }) const totalHoursWorked = Math.round(months.reduce((sum, m) => sum + (m.hours ?? 0), 0)) const headcounts = months.map((m) => m.averageEmployees).filter((n): n is number => n !== null) // 1904.32 instructions: total of employees each pay period รท number of pay periods; here, months. const annualAverageEmployees = headcounts.length ? Math.round(headcounts.reduce((a, b) => a + b, 0) / headcounts.length) : 0 const maxEmployees = headcounts.length ? Math.max(...headcounts) : 0 const recordable = rows.length return { site: { id: site.id, name: site.name, ...establishmentOf(site) }, companyName: organization.name, year, rows, totals, months, totalHoursWorked, annualAverageEmployees, maxEmployees, rates: { trir: incidenceRate(recordable, totalHoursWorked), dart: incidenceRate(totals.daysAwayCases + totals.restrictedCases, totalHoursWorked), ltir: incidenceRate(totals.daysAwayCases, totalHoursWorked), }, checks: summaryChecks(totals, annualAverageEmployees, totalHoursWorked), missingEstablishmentFields: missingEstablishmentFields(site), } } /** Active sites for an organization (establishments that may need a log). */ export function activeSites(db: Db, organizationId: string) { return db .select() .from(sites) .where(and(eq(sites.organizationId, organizationId), isNull(sites.archivedAt))) .orderBy(asc(sites.name)) }