407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
||
import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||
import { schema } from "@app/database";
|
||
import type { StoreSection } from "@app/shared-types";
|
||
import {
|
||
addShoppingItemInputSchema,
|
||
completeShoppingInputSchema,
|
||
createShoppingListInputSchema,
|
||
idParamSchema,
|
||
updateShoppingItemInputSchema,
|
||
} from "@app/validation";
|
||
import { convert } from "@app/nutrition-engine";
|
||
import { errors, parse } from "../lib/errors.js";
|
||
import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js";
|
||
|
||
/**
|
||
* Inköpslista (spec §27): dra av lager, slå ihop ingredienser, sortera per
|
||
* butiksavdelning, dela i hushållet, uppdatera lagret efter köp.
|
||
*/
|
||
|
||
const CATEGORY_TO_SECTION: Record<string, StoreSection> = {
|
||
mejeri: "mejeri",
|
||
kott_fagel: "kott_fagel",
|
||
fisk: "fisk",
|
||
gronsaker: "frukt_gront",
|
||
frukt: "frukt_gront",
|
||
spannmal: "skafferi",
|
||
baljvaxter: "skafferi",
|
||
skafferi: "skafferi",
|
||
konserver: "konserver",
|
||
kryddor: "kryddor_bak",
|
||
brod: "brod",
|
||
};
|
||
|
||
export async function shoppingRoutes(app: FastifyInstance) {
|
||
const auth = { preHandler: [app.authenticate] };
|
||
|
||
app.get("/v1/shopping-lists", auth, async (req) => {
|
||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||
const lists = await app.db
|
||
.select()
|
||
.from(schema.shoppingLists)
|
||
.where(
|
||
and(
|
||
eq(schema.shoppingLists.householdId, householdId),
|
||
eq(schema.shoppingLists.status, "active"),
|
||
),
|
||
);
|
||
return { lists };
|
||
});
|
||
|
||
app.post("/v1/shopping-lists", auth, async (req, reply) => {
|
||
const input = parse(createShoppingListInputSchema, req.body);
|
||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||
|
||
const [list] = await app.db
|
||
.insert(schema.shoppingLists)
|
||
.values({ householdId, name: input.name, weekPlanId: input.weekPlanId ?? null })
|
||
.returning();
|
||
|
||
// Generera från veckoplan: receptbehov − befintligt lager (spec §27)
|
||
if (input.generateFromPlan && input.weekPlanId) {
|
||
await generateItemsFromPlan(app, list!.id, input.weekPlanId, householdId, req.userId);
|
||
}
|
||
|
||
const items = await app.db
|
||
.select()
|
||
.from(schema.shoppingListItems)
|
||
.where(eq(schema.shoppingListItems.shoppingListId, list!.id));
|
||
return reply.status(201).send({ list, items });
|
||
});
|
||
|
||
app.get("/v1/shopping-lists/:id", auth, async (req) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
const list = await getOwnedList(app, id, req.userId);
|
||
const items = await app.db
|
||
.select()
|
||
.from(schema.shoppingListItems)
|
||
.where(eq(schema.shoppingListItems.shoppingListId, id))
|
||
.orderBy(schema.shoppingListItems.storeSection, schema.shoppingListItems.sortOrder);
|
||
const estimatedTotal = items.reduce((sum, i) => sum + (i.estimatedPriceMinor ?? 0), 0);
|
||
// Prisuppskattningar härleds ur katalogens baspriser (SEK) tills per-marknads-priser (M8).
|
||
return { list, items, estimatedTotalMinor: Math.round(estimatedTotal), currency: "SEK" };
|
||
});
|
||
|
||
app.post("/v1/shopping-lists/:id/items", auth, async (req, reply) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
await getOwnedList(app, id, req.userId);
|
||
const input = parse(addShoppingItemInputSchema, req.body);
|
||
|
||
let section = input.storeSection;
|
||
let estimatedPrice = input.estimatedPriceMinor;
|
||
if (input.canonicalIngredientId) {
|
||
const [ing] = await app.db
|
||
.select()
|
||
.from(schema.canonicalIngredients)
|
||
.where(eq(schema.canonicalIngredients.id, input.canonicalIngredientId))
|
||
.limit(1);
|
||
if (ing) {
|
||
section = section ?? CATEGORY_TO_SECTION[ing.category] ?? "hygien_ovrigt";
|
||
if (estimatedPrice == null && ing.defaultPriceMinorPerKg != null) {
|
||
const grams = convert(input.quantity, input.unit, "GRAM", {
|
||
densityGPerMl: ing.densityGPerMl,
|
||
gramsPerPiece: ing.gramsPerPiece,
|
||
});
|
||
if (grams != null)
|
||
estimatedPrice = Math.round((grams / 1000) * ing.defaultPriceMinorPerKg * 10) / 10;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Slå ihop med befintlig rad för samma ingrediens (spec §27)
|
||
if (input.canonicalIngredientId) {
|
||
const [existing] = await app.db
|
||
.select()
|
||
.from(schema.shoppingListItems)
|
||
.where(
|
||
and(
|
||
eq(schema.shoppingListItems.shoppingListId, id),
|
||
eq(schema.shoppingListItems.canonicalIngredientId, input.canonicalIngredientId),
|
||
eq(schema.shoppingListItems.unit, input.unit),
|
||
eq(schema.shoppingListItems.checked, false),
|
||
),
|
||
)
|
||
.limit(1);
|
||
if (existing) {
|
||
const [merged] = await app.db
|
||
.update(schema.shoppingListItems)
|
||
.set({
|
||
quantity: existing.quantity + input.quantity,
|
||
estimatedPriceMinor:
|
||
existing.estimatedPriceMinor != null && estimatedPrice != null
|
||
? existing.estimatedPriceMinor + estimatedPrice
|
||
: (existing.estimatedPriceMinor ?? estimatedPrice ?? null),
|
||
})
|
||
.where(eq(schema.shoppingListItems.id, existing.id))
|
||
.returning();
|
||
return reply.send({ item: merged, merged: true });
|
||
}
|
||
}
|
||
|
||
const [item] = await app.db
|
||
.insert(schema.shoppingListItems)
|
||
.values({
|
||
shoppingListId: id,
|
||
canonicalIngredientId: input.canonicalIngredientId ?? null,
|
||
displayName: input.displayName,
|
||
quantity: input.quantity,
|
||
unit: input.unit,
|
||
storeSection: section ?? "hygien_ovrigt",
|
||
estimatedPriceMinor: estimatedPrice ?? null,
|
||
addedByUserId: req.userId,
|
||
origin: "manual",
|
||
})
|
||
.returning();
|
||
return reply.status(201).send({ item, merged: false });
|
||
});
|
||
|
||
app.patch("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => {
|
||
const params = req.params as { id: string; itemId: string };
|
||
await getOwnedList(app, params.id, req.userId);
|
||
const input = parse(updateShoppingItemInputSchema, req.body);
|
||
const [item] = await app.db
|
||
.update(schema.shoppingListItems)
|
||
.set(input)
|
||
.where(
|
||
and(
|
||
eq(schema.shoppingListItems.id, params.itemId),
|
||
eq(schema.shoppingListItems.shoppingListId, params.id),
|
||
),
|
||
)
|
||
.returning();
|
||
if (!item) throw errors.notFound();
|
||
return item;
|
||
});
|
||
|
||
app.delete("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => {
|
||
const params = req.params as { id: string; itemId: string };
|
||
await getOwnedList(app, params.id, req.userId);
|
||
await app.db
|
||
.delete(schema.shoppingListItems)
|
||
.where(
|
||
and(
|
||
eq(schema.shoppingListItems.id, params.itemId),
|
||
eq(schema.shoppingListItems.shoppingListId, params.id),
|
||
),
|
||
);
|
||
return { ok: true };
|
||
});
|
||
|
||
/** Avsluta köprundan: bockade varor in i lagret (spec §27). */
|
||
app.post("/v1/shopping-lists/:id/complete", auth, async (req) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
const list = await getOwnedList(app, id, req.userId);
|
||
const input = parse(completeShoppingInputSchema, req.body);
|
||
|
||
const items = await app.db
|
||
.select()
|
||
.from(schema.shoppingListItems)
|
||
.where(
|
||
and(
|
||
eq(schema.shoppingListItems.shoppingListId, id),
|
||
eq(schema.shoppingListItems.checked, true),
|
||
),
|
||
);
|
||
|
||
let added = 0;
|
||
if (input.addToInventory && items.length > 0) {
|
||
const overrides = new Map(input.storageDefaults.map((s) => [s.shoppingListItemId, s]));
|
||
const fallback =
|
||
input.defaultStorageLocationId ??
|
||
(
|
||
await app.db
|
||
.select({ id: schema.storageLocations.id })
|
||
.from(schema.storageLocations)
|
||
.where(
|
||
and(
|
||
eq(schema.storageLocations.householdId, list.householdId),
|
||
eq(schema.storageLocations.type, "fridge"),
|
||
),
|
||
)
|
||
.limit(1)
|
||
)[0]?.id;
|
||
if (!fallback) throw errors.badRequest("Ingen standardplats (kyl) hittades i hushållet.");
|
||
|
||
for (const item of items) {
|
||
const override = overrides.get(item.id);
|
||
const [inv] = await app.db
|
||
.insert(schema.inventoryItems)
|
||
.values({
|
||
householdId: list.householdId,
|
||
canonicalIngredientId: item.canonicalIngredientId,
|
||
displayName: item.displayName,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
storageLocationId: override?.storageLocationId ?? fallback,
|
||
purchasedAt: todayIso(),
|
||
bestBeforeDate: override?.bestBeforeDate ?? null,
|
||
priceMinor: override?.priceMinor ?? item.estimatedPriceMinor,
|
||
source: "manual_search",
|
||
confidence: 1,
|
||
verifiedByUser: true,
|
||
lastVerifiedAt: new Date(),
|
||
})
|
||
.returning();
|
||
await app.db.insert(schema.inventoryTransactions).values({
|
||
householdId: list.householdId,
|
||
inventoryItemId: inv!.id,
|
||
type: "purchase",
|
||
quantityDelta: item.quantity,
|
||
unit: item.unit,
|
||
refType: "shopping",
|
||
refId: id,
|
||
actorUserId: req.userId,
|
||
valueMinor: override?.priceMinor ?? item.estimatedPriceMinor,
|
||
});
|
||
added += 1;
|
||
}
|
||
}
|
||
|
||
await app.db
|
||
.update(schema.shoppingLists)
|
||
.set({ status: "completed", updatedAt: new Date() })
|
||
.where(eq(schema.shoppingLists.id, id));
|
||
|
||
await emitEvent(app.db, {
|
||
type: "SHOPPING_COMPLETED",
|
||
payload: { shoppingListId: id, itemsAdded: added },
|
||
userId: req.userId,
|
||
householdId: list.householdId,
|
||
correlationId: req.correlationId,
|
||
});
|
||
return { ok: true, itemsAddedToInventory: added };
|
||
});
|
||
}
|
||
|
||
async function getOwnedList(app: FastifyInstance, listId: string, userId: string) {
|
||
const [list] = await app.db
|
||
.select()
|
||
.from(schema.shoppingLists)
|
||
.where(eq(schema.shoppingLists.id, listId))
|
||
.limit(1);
|
||
if (!list) throw errors.notFound("Listan finns inte.");
|
||
await requireMembership(app.db, list.householdId, userId);
|
||
return list;
|
||
}
|
||
|
||
/** Aggregera receptbehov från plan, dra av befintligt lager, skapa rader. */
|
||
async function generateItemsFromPlan(
|
||
app: FastifyInstance,
|
||
listId: string,
|
||
weekPlanId: string,
|
||
householdId: string,
|
||
userId: string,
|
||
) {
|
||
const entries = await app.db
|
||
.select()
|
||
.from(schema.weekPlanEntries)
|
||
.where(
|
||
and(
|
||
eq(schema.weekPlanEntries.weekPlanId, weekPlanId),
|
||
eq(schema.weekPlanEntries.status, "planned"),
|
||
),
|
||
);
|
||
|
||
const recipeIds = [...new Set(entries.filter((e) => e.recipeId).map((e) => e.recipeId!))];
|
||
if (recipeIds.length === 0) return;
|
||
|
||
const allIngredients = await app.db
|
||
.select()
|
||
.from(schema.recipeIngredients)
|
||
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
|
||
const recipes = await app.db
|
||
.select({ id: schema.recipes.id, portions: schema.recipes.portions })
|
||
.from(schema.recipes)
|
||
.where(inArray(schema.recipes.id, recipeIds));
|
||
const portionsMap = new Map(recipes.map((r) => [r.id, r.portions]));
|
||
|
||
// Aggregera behov per ingrediens (i gram där möjligt)
|
||
const needs = new Map<string, { name: string; grams: number }>();
|
||
const infoRows = await app.db
|
||
.select()
|
||
.from(schema.canonicalIngredients)
|
||
.where(
|
||
inArray(schema.canonicalIngredients.id, [
|
||
...new Set(allIngredients.map((i) => i.canonicalIngredientId)),
|
||
]),
|
||
);
|
||
const infoMap = new Map(infoRows.map((r) => [r.id, r]));
|
||
|
||
for (const entry of entries) {
|
||
if (!entry.recipeId) continue;
|
||
const basePortions = portionsMap.get(entry.recipeId) ?? 4;
|
||
const factor = entry.portions / basePortions;
|
||
for (const ing of allIngredients.filter((i) => i.recipeId === entry.recipeId && !i.optional)) {
|
||
const info = infoMap.get(ing.canonicalIngredientId);
|
||
const grams = convert(ing.quantity * factor, ing.unit, "GRAM", {
|
||
densityGPerMl: info?.densityGPerMl,
|
||
gramsPerPiece: info?.gramsPerPiece,
|
||
});
|
||
if (grams == null) continue;
|
||
const current = needs.get(ing.canonicalIngredientId) ?? { name: ing.displayNameSv, grams: 0 };
|
||
current.grams += grams;
|
||
needs.set(ing.canonicalIngredientId, current);
|
||
}
|
||
}
|
||
|
||
// Dra av lager
|
||
const stock = await app.db
|
||
.select()
|
||
.from(schema.inventoryItems)
|
||
.where(
|
||
and(
|
||
eq(schema.inventoryItems.householdId, householdId),
|
||
isNull(schema.inventoryItems.depletedAt),
|
||
gt(schema.inventoryItems.quantity, 0),
|
||
inArray(schema.inventoryItems.canonicalIngredientId, [...needs.keys()]),
|
||
),
|
||
);
|
||
for (const item of stock) {
|
||
if (!item.canonicalIngredientId) continue;
|
||
const need = needs.get(item.canonicalIngredientId);
|
||
if (!need) continue;
|
||
const info = infoMap.get(item.canonicalIngredientId);
|
||
const grams = convert(item.quantity, item.unit, "GRAM", {
|
||
densityGPerMl: info?.densityGPerMl,
|
||
gramsPerPiece: info?.gramsPerPiece,
|
||
});
|
||
if (grams != null) need.grams = Math.max(0, need.grams - grams);
|
||
}
|
||
|
||
// Skapa rader för det som saknas
|
||
let sortOrder = 0;
|
||
for (const [ingredientId, need] of needs) {
|
||
if (need.grams < 5) continue;
|
||
const info = infoMap.get(ingredientId);
|
||
const section = info
|
||
? (CATEGORY_TO_SECTION[info.category] ?? "hygien_ovrigt")
|
||
: "hygien_ovrigt";
|
||
// Konvertera tillbaka till naturlig enhet
|
||
const targetUnit = info?.defaultUnit ?? "GRAM";
|
||
const qty =
|
||
convert(need.grams, "GRAM", targetUnit, {
|
||
densityGPerMl: info?.densityGPerMl,
|
||
gramsPerPiece: info?.gramsPerPiece,
|
||
}) ?? need.grams;
|
||
const rounded = targetUnit === "COUNT" ? Math.ceil(qty) : Math.ceil(qty * 10) / 10;
|
||
const estimatedPrice =
|
||
info?.defaultPriceMinorPerKg != null
|
||
? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg * 10) / 10
|
||
: null;
|
||
|
||
await app.db.insert(schema.shoppingListItems).values({
|
||
shoppingListId: listId,
|
||
canonicalIngredientId: ingredientId,
|
||
displayName: need.name,
|
||
quantity: rounded,
|
||
unit: targetUnit,
|
||
storeSection: section,
|
||
estimatedPriceMinor: estimatedPrice,
|
||
addedByUserId: userId,
|
||
origin: "plan",
|
||
sortOrder: sortOrder++,
|
||
});
|
||
}
|
||
}
|