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;
|
|
|
|
|
|
2026-04-18 12:36:29 +09:00
|
|
|
/* 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)
|
2026-04-18 12:36:29 +09:00
|
|
|
/* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|