MeshiTrack/packages/api/src/modules/regimens/regimens.service.ts

199 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-03-28 18:25:49 +09:00
import type { RegimensRepository } from './regimens.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
import type { CreateRegimenInput, UpdateRegimenInput, RegimenQueryInput } from '@meshitrack/shared';
import { getFrequencyMultiplier } from '@meshitrack/shared';
import type { DosageFrequency } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
interface Deps {
regimensRepository: RegimensRepository;
medicinesRepository: MedicinesRepository;
cabinetRepository: CabinetRepository;
cabinetEventsService: CabinetEventsService;
}
export class RegimensService {
private readonly regimensRepository: RegimensRepository;
private readonly medicinesRepository: MedicinesRepository;
private readonly cabinetRepository: CabinetRepository;
private readonly cabinetEventsService: CabinetEventsService;
public constructor({
regimensRepository,
medicinesRepository,
cabinetRepository,
cabinetEventsService,
}: Deps) {
this.regimensRepository = regimensRepository;
this.medicinesRepository = medicinesRepository;
this.cabinetRepository = cabinetRepository;
this.cabinetEventsService = cabinetEventsService;
}
public async list(householdId: string, userId: string, query: RegimenQueryInput) {
return this.regimensRepository.findByHousehold(householdId, userId, query);
}
public async getById(id: string, householdId: string, userId: string) {
const regimen = await this.regimensRepository.findById(id, householdId, userId);
if (!regimen) throw new NotFoundError('Regimen not found');
return regimen;
}
public async create(data: CreateRegimenInput, householdId: string, userId: string) {
const medications = await this.denormalizeMedications(data.medications, householdId);
return this.regimensRepository.create(
{ name: data.name, isActive: data.isActive, medications },
householdId,
userId,
userId,
);
}
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenInput) {
await this.getById(id, householdId, userId);
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData['name'] = data.name;
if (data.isActive !== undefined) updateData['isActive'] = data.isActive;
if (data.medications !== undefined) {
updateData['medications'] = await this.denormalizeMedications(data.medications, householdId);
}
const updated = await this.regimensRepository.update(id, householdId, userId, updateData);
if (!updated) throw new NotFoundError('Regimen not found');
return updated;
}
public async delete(id: string, householdId: string, userId: string) {
await this.getById(id, householdId, userId);
const deleted = await this.regimensRepository.softDelete(id, householdId, userId);
if (!deleted) throw new NotFoundError('Regimen not found');
return deleted;
}
public async getActiveByUser(householdId: string, userId: string) {
return this.regimensRepository.findActiveByUser(householdId, userId);
}
public async calculateBurnRates(householdId: string, userId: string) {
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
// Sum daily consumption per medicine across all active regimens
2026-04-26 18:44:59 +09:00
const consumptionMap = new Map<string, { medicineName: string; dailyConsumption: number }>();
2026-03-28 18:25:49 +09:00
for (const regimen of regimens) {
for (const med of regimen.medications) {
const multiplier = getFrequencyMultiplier(
med.frequency as DosageFrequency,
med.customFrequencyPerDay ?? undefined,
);
const dailyDose = med.dosage * multiplier;
const existing = consumptionMap.get(med.medicineId);
if (existing) {
existing.dailyConsumption += dailyDose;
} else {
consumptionMap.set(med.medicineId, {
medicineName: med.medicineName,
dailyConsumption: dailyDose,
});
}
}
}
// Exclude medicines with zero daily consumption (e.g., AS_NEEDED frequency)
for (const [id, consumption] of consumptionMap) {
if (consumption.dailyConsumption === 0) consumptionMap.delete(id);
}
if (consumptionMap.size === 0) return [];
// Get cabinet summary for all medicines in regimens
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
2026-04-26 18:44:59 +09:00
const stockMap = new Map<string, { totalQuantity: number; earliestExpiry: Date | null }>();
2026-03-28 18:25:49 +09:00
for (const s of summaryResults) {
stockMap.set(s._id as string, {
totalQuantity: s.totalQuantity as number,
earliestExpiry: (s.earliestExpiry as Date | null) ?? null,
});
}
// Get average unit prices from purchase events
const medicineIds = [...consumptionMap.keys()];
const priceMap = await this.cabinetEventsService.getAvgUnitPrices(householdId, medicineIds);
// Build burn rate array
const burnRates = [];
for (const [medicineId, consumption] of consumptionMap) {
const stock = stockMap.get(medicineId);
const totalInCabinet = stock?.totalQuantity ?? 0;
const earliestExpiry = stock?.earliestExpiry ?? null;
const dailyConsumption = consumption.dailyConsumption;
/* v8 ignore next */
2026-03-28 18:25:49 +09:00
const daysUntilEmpty =
dailyConsumption > 0 ? Math.floor(totalInCabinet / dailyConsumption) : null;
const priceData = priceMap.get(medicineId);
const avgUnitPrice = priceData?.avgUnitPrice ?? null;
const currency = priceData?.currency ?? null;
const projectedDailyCost =
avgUnitPrice !== null && dailyConsumption > 0 ? avgUnitPrice * dailyConsumption : null;
burnRates.push({
medicineId,
medicineName: consumption.medicineName,
dailyConsumption,
totalInCabinet,
daysUntilEmpty,
earliestExpiry: earliestExpiry ? earliestExpiry.toISOString() : null,
avgUnitPrice,
projectedDailyCost,
projectedMonthlyCost: projectedDailyCost !== null ? projectedDailyCost * 30 : null,
projectedYearlyCost: projectedDailyCost !== null ? projectedDailyCost * 365 : null,
currency,
});
}
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
/* v8 ignore next 6 */
2026-03-28 18:25:49 +09:00
burnRates.sort((a, b) => {
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
if (a.daysUntilEmpty === null) return 1;
if (b.daysUntilEmpty === null) return -1;
return a.daysUntilEmpty - b.daysUntilEmpty;
});
return burnRates;
}
private async denormalizeMedications(
medications: CreateRegimenInput['medications'],
householdId: string,
) {
const result = [];
for (const med of medications) {
const medicine = await this.medicinesRepository.findById(med.medicineId, householdId);
if (!medicine) {
throw new NotFoundError(`Medicine not found: ${med.medicineId}`);
}
result.push({
medicineId: med.medicineId,
medicineName: medicine.name,
medicineStrength: medicine.strength,
medicineStrengthUnit: medicine.strengthUnit,
medicineForm: medicine.form,
dosage: med.dosage,
dosageUnit: med.dosageUnit,
frequency: med.frequency,
customFrequencyPerDay: med.customFrequencyPerDay,
timeOfDay: med.timeOfDay,
instructions: med.instructions,
});
}
return result;
}
}