2026-04-18 12:36:29 +09:00
|
|
|
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,
|
|
|
|
|
} 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);
|
2026-04-26 18:44:59 +09:00
|
|
|
const summaryMap = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{ medicineStrength: number; medicineStrengthUnit: string }
|
|
|
|
|
>();
|
2026-04-18 12:36:29 +09:00
|
|
|
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);
|
|
|
|
|
|
2026-04-26 18:44:59 +09:00
|
|
|
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);
|
2026-04-18 12:36:29 +09:00
|
|
|
|
|
|
|
|
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,
|
2026-04-26 18:44:59 +09:00
|
|
|
unitPrice:
|
|
|
|
|
item.actualPrice !== undefined && item.quantity > 0
|
|
|
|
|
? item.actualPrice / item.quantity
|
|
|
|
|
: undefined,
|
2026-04-18 12:36:29 +09:00
|
|
|
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 = [
|
2026-04-26 18:44:59 +09:00
|
|
|
...new Set((list.items as Array<{ medicineId: string }>).map((item) => item.medicineId)),
|
2026-04-18 12:36:29 +09:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|