Implement stores and refills, improve testing

This commit is contained in:
Aerilyn Weber 2026-04-18 12:36:29 +09:00
parent 9f416903ef
commit 5536acd67d
137 changed files with 21218 additions and 221 deletions

View file

@ -0,0 +1,285 @@
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 { PurchasesRepository } from '../purchases/purchases.repository.js';
import type {
CreateRefillListInput,
UpdateRefillListInput,
UpdateRefillListItemInput,
RefillListQueryInput,
RefillAlertQueryInput,
} 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;
purchasesRepository: PurchasesRepository;
}
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 purchasesRepository: PurchasesRepository;
public constructor({
refillsRepository,
regimensService,
cabinetRepository,
cabinetService,
medicinePricesRepository,
purchasesRepository,
}: Deps) {
this.refillsRepository = refillsRepository;
this.regimensService = regimensService;
this.cabinetRepository = cabinetRepository;
this.cabinetService = cabinetService;
this.medicinePricesRepository = medicinePricesRepository;
this.purchasesRepository = purchasesRepository;
}
public async getAlerts(householdId: string, userId: string, thresholdDays = 7) {
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 summaries = await this.cabinetRepository.getAggregateSummary(householdId);
const summaryMap = new Map<string, { medicineStrength: number; medicineStrengthUnit: string }>();
for (const s of summaries) {
summaryMap.set(s._id as string, {
medicineStrength: s.medicineStrength as number,
medicineStrengthUnit: s.medicineStrengthUnit as string,
});
}
// Get pending stock from ordered purchases
const pendingStockRows = await this.purchasesRepository.getPendingMedicineStock(householdId);
const pendingStockMap = new Map<string, number>();
for (const row of pendingStockRows) {
pendingStockMap.set(row.medicineId, row.totalUnits);
}
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') as never,
daysUntilEmpty: br.daysUntilEmpty as number,
dailyConsumption: br.dailyConsumption,
currentStock: br.totalInCabinet,
pendingOrderStock,
daysUntilEmptyWithOrders,
suggestedQuantity,
lastKnownPrice: latestRecord
? {
price: latestRecord.price as number,
pricePerUnit: latestRecord.pricePerUnit as number,
storeName: latestRecord.storeName as string,
storeId: latestRecord.storeId as string,
date: latestRecord.date as 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) {
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' as string,
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 as string,
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) {
return this.refillsRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
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) {
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,
) {
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) {
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) {
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);
}
}