281 lines
9.4 KiB
TypeScript
281 lines
9.4 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { and, eq, gte, inArray, sql } from "drizzle-orm";
|
|
import { schema } from "@app/database";
|
|
import { z } from "zod";
|
|
import { buildReconciliationCandidates, classifyExpiry, computeTrust } from "@app/inventory-engine";
|
|
import { errors, parse } from "../lib/errors.js";
|
|
import {
|
|
getActiveDecayProfile,
|
|
requireActiveHousehold,
|
|
requireMembership,
|
|
trackProductAnalytics,
|
|
} from "../lib/helpers.js";
|
|
import { reconciliationResolveInputSchema, reconciliationStartInputSchema } from "@app/validation";
|
|
import { inventoryReconciliationCompleted, inventoryReconciliationStarted } from "@app/analytics";
|
|
|
|
/** Quick Reconciliation (Fas 2 §5.4) */
|
|
export async function reconciliationRoutes(app: FastifyInstance) {
|
|
const auth = { preHandler: [app.authenticate] };
|
|
|
|
app.post("/v1/reconciliations/start", auth, async (req) => {
|
|
const householdId = await requireActiveHousehold(app.db, req.userId);
|
|
await requireMembership(app.db, householdId, req.userId);
|
|
const input = parse(reconciliationStartInputSchema, req.body);
|
|
|
|
const decayProfile = await getActiveDecayProfile(app.db);
|
|
|
|
const items = await app.db
|
|
.select({
|
|
item: schema.inventoryItems,
|
|
locationType: schema.storageLocations.type,
|
|
locationName: schema.storageLocations.name,
|
|
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
|
|
})
|
|
.from(schema.inventoryItems)
|
|
.innerJoin(
|
|
schema.storageLocations,
|
|
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
|
|
)
|
|
.leftJoin(
|
|
schema.canonicalIngredients,
|
|
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(schema.inventoryItems.householdId, householdId),
|
|
sql`${schema.inventoryItems.depletedAt} IS NULL`,
|
|
sql`${schema.inventoryItems.quantity} > 0`,
|
|
),
|
|
);
|
|
|
|
const itemIds = items.map((r) => r.item.id);
|
|
|
|
// Ingredienser i planerade recept närmaste 7 dagarna
|
|
const plannedRecipeIngredientIds = new Map<string, string[]>();
|
|
const upcomingEntries = await app.db
|
|
.select({ recipeId: schema.weekPlanEntries.recipeId, date: schema.weekPlanEntries.date })
|
|
.from(schema.weekPlanEntries)
|
|
.innerJoin(schema.weekPlans, eq(schema.weekPlanEntries.weekPlanId, schema.weekPlans.id))
|
|
.where(
|
|
and(
|
|
eq(schema.weekPlans.householdId, householdId),
|
|
sql`${schema.weekPlanEntries.date} >= CURRENT_DATE`,
|
|
sql`${schema.weekPlanEntries.date} <= CURRENT_DATE + INTERVAL '7 days'`,
|
|
),
|
|
);
|
|
|
|
if (upcomingEntries.length > 0) {
|
|
const recipeIds = [
|
|
...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean)),
|
|
] as string[];
|
|
if (recipeIds.length > 0) {
|
|
const ingredients = await app.db
|
|
.select({
|
|
recipeId: schema.recipeIngredients.recipeId,
|
|
canonicalId: schema.recipeIngredients.canonicalIngredientId,
|
|
})
|
|
.from(schema.recipeIngredients)
|
|
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
|
|
for (const ing of ingredients) {
|
|
if (!ing.canonicalId) continue;
|
|
const list = plannedRecipeIngredientIds.get(ing.canonicalId) ?? [];
|
|
if (!list.includes(ing.recipeId)) list.push(ing.recipeId);
|
|
plannedRecipeIngredientIds.set(ing.canonicalId, list);
|
|
}
|
|
}
|
|
}
|
|
|
|
const priceMinorByItemId = new Map<string, number>();
|
|
const dailyConsumptionRate = new Map<string, number>();
|
|
const daysLeftByItemId = new Map<string, number | null>();
|
|
|
|
for (const r of items) {
|
|
const expiry = classifyExpiry({
|
|
bestBeforeDate: r.item.bestBeforeDate,
|
|
useByDate: r.item.useByDate,
|
|
openedAt: r.item.openedAt,
|
|
frozenAt: r.item.frozenAt,
|
|
thawedAt: r.item.thawedAt,
|
|
purchasedAt: r.item.purchasedAt,
|
|
storageLocationType: r.locationType,
|
|
shelfLifeGuidance: r.shelfLife,
|
|
});
|
|
daysLeftByItemId.set(r.item.id, expiry.daysLeft);
|
|
|
|
if (r.item.priceMinor != null) {
|
|
priceMinorByItemId.set(r.item.id, r.item.priceMinor);
|
|
}
|
|
|
|
// Enkel heuristik: senaste 30 dagarnas genomsnittliga dagliga förbrukning
|
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
|
|
const txAgg = await app.db
|
|
.select({
|
|
total: sql<number>`COALESCE(SUM(ABS(${schema.inventoryTransactions.quantityDelta})), 0)`,
|
|
days: sql<number>`GREATEST(1, COUNT(DISTINCT DATE(${schema.inventoryTransactions.createdAt})))`,
|
|
})
|
|
.from(schema.inventoryTransactions)
|
|
.where(
|
|
and(
|
|
eq(schema.inventoryTransactions.inventoryItemId, r.item.id),
|
|
eq(schema.inventoryTransactions.type, "consume"),
|
|
sql`${schema.inventoryTransactions.createdAt} >= ${thirtyDaysAgo}`,
|
|
),
|
|
);
|
|
const rate = Number(txAgg[0]?.total ?? 0) / Number(txAgg[0]?.days ?? 1);
|
|
if (rate > 0) dailyConsumptionRate.set(r.item.id, rate);
|
|
}
|
|
|
|
const mappedItems = items.map((r) => {
|
|
const trust = computeTrust(
|
|
{
|
|
confidence: r.item.confidence,
|
|
verifiedByUser: r.item.verifiedByUser,
|
|
lastVerifiedAt: r.item.lastVerifiedAt,
|
|
quantity: r.item.quantity,
|
|
updatedAt: r.item.updatedAt,
|
|
},
|
|
new Date(),
|
|
decayProfile,
|
|
);
|
|
return {
|
|
id: r.item.id,
|
|
displayName: r.item.displayName,
|
|
quantity: r.item.quantity,
|
|
unit: r.item.unit,
|
|
locationName: r.locationName,
|
|
confidence: r.item.confidence,
|
|
verifiedByUser: r.item.verifiedByUser,
|
|
lastVerifiedAt: r.item.lastVerifiedAt,
|
|
updatedAt: r.item.updatedAt,
|
|
depletedAt: r.item.depletedAt,
|
|
canonicalIngredientId: r.item.canonicalIngredientId,
|
|
trustState: trust.state,
|
|
};
|
|
});
|
|
|
|
const candidates = buildReconciliationCandidates(
|
|
mappedItems,
|
|
{
|
|
plannedRecipeIngredientIds,
|
|
daysLeftByItemId,
|
|
dailyConsumptionRate,
|
|
priceMinorByItemId,
|
|
},
|
|
new Date(),
|
|
input.maxItems ?? 15,
|
|
);
|
|
|
|
await trackProductAnalytics(
|
|
app.db,
|
|
req.userId,
|
|
inventoryReconciliationStarted({
|
|
householdId,
|
|
properties: { candidateCount: candidates.length },
|
|
}),
|
|
);
|
|
|
|
return {
|
|
candidates: candidates.map((c) => ({
|
|
itemId: c.itemId,
|
|
displayName: c.displayName,
|
|
quantity: c.quantity,
|
|
unit: c.unit,
|
|
locationName: c.locationName,
|
|
reasons: c.reasons,
|
|
suggestedAction: c.suggestedAction,
|
|
suggestedQuantity: c.suggestedQuantity,
|
|
})),
|
|
};
|
|
});
|
|
|
|
app.post("/v1/reconciliations/items/:itemId/resolve", auth, async (req) => {
|
|
const householdId = await requireActiveHousehold(app.db, req.userId);
|
|
await requireMembership(app.db, householdId, req.userId);
|
|
|
|
const params = z.object({ itemId: z.uuid() }).parse(req.params);
|
|
const input = parse(reconciliationResolveInputSchema, req.body);
|
|
|
|
const [item] = await app.db
|
|
.select()
|
|
.from(schema.inventoryItems)
|
|
.where(
|
|
and(
|
|
eq(schema.inventoryItems.id, params.itemId),
|
|
eq(schema.inventoryItems.householdId, householdId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!item) throw errors.notFound("Varan finns inte.");
|
|
|
|
const now = new Date();
|
|
const newQuantity = input.quantity;
|
|
const quantityChange = newQuantity != null ? newQuantity - item.quantity : 0;
|
|
|
|
const update: Partial<typeof schema.inventoryItems.$inferInsert> = {
|
|
updatedAt: now,
|
|
};
|
|
|
|
if (input.action === "exists") {
|
|
update.verifiedByUser = true;
|
|
update.lastVerifiedAt = now;
|
|
update.depletedAt = null;
|
|
if (newQuantity != null) update.quantity = newQuantity;
|
|
} else if (input.action === "depleted") {
|
|
update.quantity = 0;
|
|
update.depletedAt = now;
|
|
} else {
|
|
// uncertain: bara registrera en adjustment om användaren justerat mängd
|
|
if (newQuantity != null) update.quantity = newQuantity;
|
|
}
|
|
|
|
const [updated] = await app.db
|
|
.update(schema.inventoryItems)
|
|
.set(update)
|
|
.where(eq(schema.inventoryItems.id, params.itemId))
|
|
.returning();
|
|
if (!updated) throw errors.internal("Kunde inte uppdatera varan.");
|
|
|
|
if (input.action === "depleted") {
|
|
await app.db.insert(schema.inventoryTransactions).values({
|
|
inventoryItemId: params.itemId,
|
|
householdId,
|
|
actorUserId: req.userId,
|
|
type: "correction",
|
|
quantityDelta: -item.quantity,
|
|
unit: item.unit,
|
|
note: input.note,
|
|
});
|
|
} else if (input.action === "exists" || quantityChange !== 0) {
|
|
await app.db.insert(schema.inventoryTransactions).values({
|
|
inventoryItemId: params.itemId,
|
|
householdId,
|
|
actorUserId: req.userId,
|
|
type: input.action === "exists" ? "correction" : "adjust",
|
|
quantityDelta: quantityChange,
|
|
unit: item.unit,
|
|
note: input.note,
|
|
});
|
|
}
|
|
|
|
await trackProductAnalytics(
|
|
app.db,
|
|
req.userId,
|
|
inventoryReconciliationCompleted({
|
|
householdId,
|
|
properties: {
|
|
itemId: params.itemId,
|
|
action: input.action,
|
|
hadAdjustment: quantityChange !== 0,
|
|
},
|
|
}),
|
|
);
|
|
|
|
return {
|
|
itemId: params.itemId,
|
|
action: input.action,
|
|
quantity: updated.quantity,
|
|
verifiedByUser: updated.verifiedByUser,
|
|
};
|
|
});
|
|
}
|