e42e4f5639
* fix(stock): ignore doses from other medications * test(backend): add timezone column to in-memory user_settings schemas
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
import type { doseTracking, medications } from "../db/schema.js";
|
|
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
|
import {
|
|
countScheduledOccurrencesInRange,
|
|
getDateOnlyTimestamp,
|
|
getNextScheduledOccurrenceTime,
|
|
normalizeIntakeUsageForStock,
|
|
parseIntakesJson,
|
|
parseLocalDateTime,
|
|
parseTakenByJson,
|
|
} from "../utils/scheduler-utils.js";
|
|
|
|
type MedicationRow = typeof medications.$inferSelect;
|
|
type DoseRow = typeof doseTracking.$inferSelect;
|
|
|
|
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
|
|
|
function getDoseTakenAtMs(dose: DoseRow): number {
|
|
const rawTakenAt = Number(dose.takenAt);
|
|
if (Number.isFinite(rawTakenAt)) {
|
|
return rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
|
}
|
|
|
|
return new Date(dose.takenAt).getTime();
|
|
}
|
|
|
|
export function computeMedicationCurrentStock(options: {
|
|
medication: MedicationRow;
|
|
doses: DoseRow[];
|
|
stockCalculationMode: "automatic" | "manual";
|
|
nowMs?: number;
|
|
}): number {
|
|
const { medication, doses, stockCalculationMode, nowMs = Date.now() } = options;
|
|
|
|
const intakes = parseIntakesJson(
|
|
medication.intakesJson,
|
|
{
|
|
usageJson: medication.usageJson,
|
|
everyJson: medication.everyJson,
|
|
startJson: medication.startJson,
|
|
},
|
|
medication.intakeRemindersEnabled ?? false
|
|
);
|
|
|
|
const baseStock = isAmountBasedPackageType(medication.packageType)
|
|
? medication.looseTablets + (medication.stockAdjustment ?? 0)
|
|
: medication.packCount * medication.blistersPerPack * medication.pillsPerBlister +
|
|
medication.looseTablets +
|
|
(medication.stockAdjustment ?? 0);
|
|
|
|
const relevantDoses = doses.filter((dose) => !dose.dismissed);
|
|
const stockCorrectionCutoff = medication.lastStockCorrectionAt
|
|
? new Date(medication.lastStockCorrectionAt).getTime()
|
|
: 0;
|
|
let consumed = 0;
|
|
|
|
if (stockCalculationMode === "automatic") {
|
|
const medicationTakenBy = parseTakenByJson(medication.takenByJson);
|
|
|
|
intakes.forEach((intake, intakeIndex) => {
|
|
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
|
const intakeStart = parseLocalDateTime(intake.start).getTime();
|
|
if (Number.isNaN(intakeStart)) return;
|
|
|
|
const effectiveStart =
|
|
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
|
|
? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
|
|
: intakeStart;
|
|
if (effectiveStart === null) return;
|
|
|
|
let peopleForThisIntake: Array<string | null>;
|
|
if (intake.takenBy) {
|
|
peopleForThisIntake = [intake.takenBy];
|
|
} else if (medicationTakenBy.length > 0) {
|
|
peopleForThisIntake = medicationTakenBy;
|
|
} else {
|
|
peopleForThisIntake = [null];
|
|
}
|
|
|
|
let lastAutoConsumedDateMs = 0;
|
|
if (effectiveStart <= nowMs) {
|
|
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
|
intake,
|
|
effectiveStart,
|
|
nowMs
|
|
);
|
|
consumed += occurrences * usage * peopleForThisIntake.length;
|
|
|
|
if (lastOccurrenceMs !== null) {
|
|
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
|
}
|
|
}
|
|
|
|
const stockCorrectionDateOnly =
|
|
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
|
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
|
|
|
for (const dose of relevantDoses) {
|
|
const match = doseIdPattern.exec(dose.doseId);
|
|
if (!match) continue;
|
|
|
|
const parsedMedicationId = Number.parseInt(match[1], 10);
|
|
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
|
if (
|
|
Number.isNaN(parsedMedicationId) ||
|
|
Number.isNaN(parsedIntakeIndex) ||
|
|
Number.isNaN(doseDateOnlyMs) ||
|
|
parsedMedicationId !== medication.id ||
|
|
parsedIntakeIndex !== intakeIndex
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (doseDateOnlyMs > earlyCutoff) {
|
|
consumed += usage;
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
intakes.forEach((intake, intakeIndex) => {
|
|
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
|
const intakeStart = parseLocalDateTime(intake.start);
|
|
const intakeStartDateOnly = new Date(
|
|
intakeStart.getFullYear(),
|
|
intakeStart.getMonth(),
|
|
intakeStart.getDate()
|
|
).getTime();
|
|
if (Number.isNaN(intakeStartDateOnly)) return;
|
|
|
|
for (const dose of relevantDoses) {
|
|
const match = doseIdPattern.exec(dose.doseId);
|
|
if (!match) continue;
|
|
|
|
const parsedMedicationId = Number.parseInt(match[1], 10);
|
|
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
|
if (
|
|
Number.isNaN(parsedMedicationId) ||
|
|
Number.isNaN(parsedIntakeIndex) ||
|
|
Number.isNaN(doseDateOnlyMs) ||
|
|
parsedMedicationId !== medication.id ||
|
|
parsedIntakeIndex !== intakeIndex
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const takenAtMs = getDoseTakenAtMs(dose);
|
|
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAtMs > stockCorrectionCutoff;
|
|
if (doseDateOnlyMs >= intakeStartDateOnly && afterCorrectionOrNoCorrection) {
|
|
consumed += usage;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return Math.max(0, Math.floor(baseStock - consumed));
|
|
}
|