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

379 lines
12 KiB
TypeScript

import type { RefillsRepository } from './refills.repository.js';
import type { RegimensService } from '../regimens/regimens.service.js';
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
import type { CabinetService } from '../cabinet/cabinet.service.js';
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
import type { ShoppingListsRepository } from '../shopping-lists/shopping-lists.repository.js';
import type { RefillListDocument } from '../../schemas/refill-list.schema.js';
import type {
CreateRefillListInput,
UpdateRefillListInput,
UpdateRefillListItemInput,
RefillListQueryInput,
} from '@meshitrack/shared';
import { RefillListStatus } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
interface Deps {
refillsRepository: RefillsRepository;
regimensService: RegimensService;
cabinetRepository: CabinetRepository;
cabinetService: CabinetService;
medicinePricesRepository: MedicinePricesRepository;
shoppingListsRepository: ShoppingListsRepository;
}
interface RefillAlert {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
daysUntilEmpty: number;
dailyConsumption: number;
currentStock: number;
pendingOrderStock: number;
daysUntilEmptyWithOrders: number | null;
suggestedQuantity: number;
lastKnownPrice?: {
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
cheapestOption?: {
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
}
interface CabinetAggregateSummaryGroup {
_id: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
totalQuantity: number;
unit: string;
earliestExpiry: Date | null;
itemCount: number;
}
export class RefillsService {
private readonly refillsRepository: RefillsRepository;
private readonly regimensService: RegimensService;
private readonly cabinetRepository: CabinetRepository;
private readonly cabinetService: CabinetService;
private readonly medicinePricesRepository: MedicinePricesRepository;
private readonly shoppingListsRepository: ShoppingListsRepository;
public constructor({
refillsRepository,
regimensService,
cabinetRepository,
cabinetService,
medicinePricesRepository,
shoppingListsRepository,
}: Deps) {
this.refillsRepository = refillsRepository;
this.regimensService = regimensService;
this.cabinetRepository = cabinetRepository;
this.cabinetService = cabinetService;
this.medicinePricesRepository = medicinePricesRepository;
this.shoppingListsRepository = shoppingListsRepository;
}
public async getAlerts(
householdId: string,
userId: string,
thresholdDays = 7,
): Promise<RefillAlert[]> {
const burnRates = await this.regimensService.calculateBurnRates(householdId, userId);
const triggered = burnRates.filter(
(br) => br.daysUntilEmpty !== null && br.daysUntilEmpty <= thresholdDays,
);
if (triggered.length === 0) return [];
// Get strength data from cabinet aggregate
const summariesRaw = await this.cabinetRepository.getAggregateSummary(householdId);
const summaries = summariesRaw as unknown as CabinetAggregateSummaryGroup[];
const summaryMap = new Map<
string,
{ medicineStrength: number; medicineStrengthUnit: string }
>();
for (const s of summaries) {
summaryMap.set(s._id, {
medicineStrength: s.medicineStrength,
medicineStrengthUnit: s.medicineStrengthUnit,
});
}
// Get pending stock from active shopping lists
const activeLists = await this.shoppingListsRepository.findActiveByHousehold(householdId);
const pendingStockMap = new Map<string, number>();
for (const list of activeLists) {
if (list.items) {
for (const item of list.items) {
const itemAny = item as unknown as {
productId?: string;
quantity: number;
checked: boolean;
};
if (itemAny.productId && !itemAny.checked) {
const currentQty = pendingStockMap.get(itemAny.productId) ?? 0;
pendingStockMap.set(itemAny.productId, currentQty + itemAny.quantity);
}
}
}
}
const alerts = await Promise.all(
triggered.map(async (br) => {
const summary = summaryMap.get(br.medicineId);
const suggestedQuantity = Math.ceil(br.dailyConsumption * 30);
const pendingOrderStock = pendingStockMap.get(br.medicineId) ?? 0;
const daysUntilEmptyWithOrders =
br.dailyConsumption > 0
? (br.totalInCabinet + pendingOrderStock) / br.dailyConsumption
: null;
const [latestRecord, comparisons] = await Promise.all([
this.medicinePricesRepository.getLatestForMedicine(householdId, br.medicineId),
this.medicinePricesRepository.compareStores(householdId, br.medicineId),
]);
return {
medicineId: br.medicineId,
medicineName: br.medicineName,
medicineStrength: summary?.medicineStrength ?? 0,
medicineStrengthUnit: summary?.medicineStrengthUnit ?? 'mg',
daysUntilEmpty: br.daysUntilEmpty as number,
dailyConsumption: br.dailyConsumption,
currentStock: br.totalInCabinet,
pendingOrderStock,
daysUntilEmptyWithOrders,
suggestedQuantity,
lastKnownPrice: latestRecord
? {
price: latestRecord.price,
pricePerUnit: latestRecord.pricePerUnit,
storeName: latestRecord.storeName,
storeId: latestRecord.storeId,
date: latestRecord.date,
}
: undefined,
cheapestOption:
comparisons.length > 0
? {
price: comparisons[0].latestPrice,
pricePerUnit: comparisons[0].latestPricePerUnit,
storeName: comparisons[0].storeName,
storeId: comparisons[0].storeId,
date: comparisons[0].date,
}
: undefined,
};
}),
);
return alerts;
}
public async createList(
data: CreateRefillListInput,
householdId: string,
userId: string,
): Promise<RefillListDocument> {
let items: Array<{
medicineId: string;
medicineName: string;
quantity: number;
unit: string;
estimatedPrice?: number;
storeId?: string;
notes?: string;
}> = [];
if (data.fromAlerts) {
const alerts = await this.getAlerts(householdId, userId, data.thresholdDays);
items = alerts.map((alert) => ({
medicineId: alert.medicineId,
medicineName: alert.medicineName,
quantity: alert.suggestedQuantity,
unit: 'tablet',
estimatedPrice: alert.cheapestOption?.price ?? alert.lastKnownPrice?.price,
storeId: alert.cheapestOption?.storeId ?? alert.lastKnownPrice?.storeId,
}));
} else if (data.items) {
items = data.items.map((item) => ({
medicineId: item.medicineId,
medicineName: item.medicineName,
quantity: item.quantity,
unit: item.unit,
estimatedPrice: item.estimatedPrice,
storeId: item.storeId,
notes: item.notes,
}));
}
const totalEstimatedCost =
items.length > 0
? items.reduce((sum, item) => sum + (item.estimatedPrice ?? 0), 0) || undefined
: undefined;
return this.refillsRepository.create({
householdId,
name: data.name,
status: RefillListStatus.ACTIVE,
preferredStoreId: data.preferredStoreId,
totalEstimatedCost,
createdBy: userId,
items,
});
}
public async list(
householdId: string,
query: RefillListQueryInput,
): Promise<{
data: RefillListDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.refillsRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string): Promise<RefillListDocument> {
const list = await this.refillsRepository.findById(id, householdId);
if (!list) throw new NotFoundError('Refill list not found');
return list;
}
public async updateList(
id: string,
householdId: string,
data: UpdateRefillListInput,
): Promise<RefillListDocument> {
await this.getById(id, householdId);
const updated = await this.refillsRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Refill list not found');
return updated;
}
public async updateItem(
listId: string,
householdId: string,
itemId: string,
data: UpdateRefillListItemInput,
): Promise<RefillListDocument> {
await this.getById(listId, householdId);
const updateData: UpdateRefillListItemInput & { checkedAt?: Date } = { ...data };
if (data.checked === true) {
updateData.checkedAt = new Date();
}
const updated = await this.refillsRepository.updateItem(
listId,
householdId,
itemId,
updateData,
);
if (!updated) throw new NotFoundError('Refill list or item not found');
return updated;
}
public async addToCabinet(
listId: string,
householdId: string,
userId: string,
): Promise<{ addedCount: number; priceRecordsCreated: number }> {
const list = await this.getById(listId, householdId);
const checkedItems = (
list.items as Array<{
_id: { toString: () => string };
medicineId: string;
medicineName: string;
quantity: number;
unit: string;
actualPrice?: number;
storeId?: string;
checked: boolean;
addedToCabinet: boolean;
}>
).filter((item) => item.checked && !item.addedToCabinet);
if (checkedItems.length === 0) {
return { addedCount: 0, priceRecordsCreated: 0 };
}
let addedCount = 0;
const addedItemIds: string[] = [];
for (const item of checkedItems) {
await this.cabinetService.addItem(
{
medicineId: item.medicineId,
quantity: item.quantity,
unit: item.unit as never,
unitPrice:
item.actualPrice !== undefined && item.quantity > 0
? item.actualPrice / item.quantity
: undefined,
totalPrice: item.actualPrice,
storeId: item.storeId,
purchaseDate: new Date().toISOString(),
},
householdId,
userId,
);
addedCount++;
addedItemIds.push(item._id.toString());
}
await this.refillsRepository.markItemsAddedToCabinet(listId, householdId, addedItemIds);
return { addedCount, priceRecordsCreated: 0 };
}
public async getStoreComparison(
listId: string,
householdId: string,
): Promise<
Array<{
medicineId: string;
storeOptions: Array<{
storeId: string;
storeName: string;
latestPrice: number;
latestPricePerUnit: number;
currency: string;
date: Date;
isInsurancePrice: boolean;
}>;
}>
> {
const list = await this.getById(listId, householdId);
const medicineIds = [
...new Set((list.items as Array<{ medicineId: string }>).map((item) => item.medicineId)),
];
const comparisons = await Promise.all(
medicineIds.map(async (medicineId) => {
const storeOptions = await this.medicinePricesRepository.compareStores(
householdId,
medicineId,
);
return { medicineId, storeOptions };
}),
);
return comparisons.filter((c) => c.storeOptions.length > 0);
}
}