131 lines
5.0 KiB
TypeScript
131 lines
5.0 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
||
import { and, eq, gte, inArray, sql } from "drizzle-orm";
|
||
import { schema } from "@app/database";
|
||
import { requireActiveHousehold } from "../lib/helpers.js";
|
||
|
||
/** Budget & matsvinn (spec §26): vecka/månad, kostnad per måltid, svinnvärde. */
|
||
export async function budgetRoutes(app: FastifyInstance) {
|
||
const auth = { preHandler: [app.authenticate] };
|
||
|
||
app.get("/v1/budget/summary", auth, async (req) => {
|
||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||
const now = new Date();
|
||
// UTC-kalender (samma policy som motorerna): identiska summor oavsett serverns tidszon.
|
||
const mondayOffset = (now.getUTCDay() + 6) % 7; // 0 = måndag
|
||
const weekStart = new Date(
|
||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - mondayOffset),
|
||
);
|
||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||
|
||
// Inköpskostnad = summa purchase-transaktioner med värde
|
||
const [weekPurchases] = await app.db
|
||
.select({ total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` })
|
||
.from(schema.inventoryTransactions)
|
||
.where(
|
||
and(
|
||
eq(schema.inventoryTransactions.householdId, householdId),
|
||
eq(schema.inventoryTransactions.type, "purchase"),
|
||
gte(schema.inventoryTransactions.createdAt, weekStart),
|
||
),
|
||
);
|
||
const [monthPurchases] = await app.db
|
||
.select({ total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` })
|
||
.from(schema.inventoryTransactions)
|
||
.where(
|
||
and(
|
||
eq(schema.inventoryTransactions.householdId, householdId),
|
||
eq(schema.inventoryTransactions.type, "purchase"),
|
||
gte(schema.inventoryTransactions.createdAt, monthStart),
|
||
),
|
||
);
|
||
|
||
// Matsvinnsvärde = summa discard-transaktioner (spec §12, §26)
|
||
const [weekWaste] = await app.db
|
||
.select({
|
||
total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`,
|
||
count: sql<number>`count(*)`,
|
||
})
|
||
.from(schema.inventoryTransactions)
|
||
.where(
|
||
and(
|
||
eq(schema.inventoryTransactions.householdId, householdId),
|
||
eq(schema.inventoryTransactions.type, "discard"),
|
||
gte(schema.inventoryTransactions.createdAt, weekStart),
|
||
),
|
||
);
|
||
const [monthWaste] = await app.db
|
||
.select({
|
||
total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`,
|
||
count: sql<number>`count(*)`,
|
||
})
|
||
.from(schema.inventoryTransactions)
|
||
.where(
|
||
and(
|
||
eq(schema.inventoryTransactions.householdId, householdId),
|
||
eq(schema.inventoryTransactions.type, "discard"),
|
||
gte(schema.inventoryTransactions.createdAt, monthStart),
|
||
),
|
||
);
|
||
|
||
// Kostnad per hemlagad måltid: lagade recept denna månad med kostnadsdata
|
||
const cooks = await app.db
|
||
.select({
|
||
recipeId: schema.recipeCooks.recipeId,
|
||
portions: schema.recipeCooks.portionsCooked,
|
||
})
|
||
.from(schema.recipeCooks)
|
||
.where(
|
||
and(
|
||
eq(schema.recipeCooks.householdId, householdId),
|
||
gte(schema.recipeCooks.cookedAt, monthStart),
|
||
),
|
||
);
|
||
let mealCostTotal = 0;
|
||
let mealPortions = 0;
|
||
if (cooks.length > 0) {
|
||
const recipes = await app.db
|
||
.select({ id: schema.recipes.id, cost: schema.recipes.estimatedCostMinorPerPortion })
|
||
.from(schema.recipes)
|
||
.where(inArray(schema.recipes.id, [...new Set(cooks.map((c) => c.recipeId))]));
|
||
const costMap = new Map(recipes.map((r) => [r.id, r.cost]));
|
||
for (const cook of cooks) {
|
||
const cost = costMap.get(cook.recipeId);
|
||
if (cost != null) {
|
||
mealCostTotal += cost * cook.portions;
|
||
mealPortions += cook.portions;
|
||
}
|
||
}
|
||
}
|
||
|
||
const [household] = await app.db
|
||
.select({
|
||
budget: schema.households.weeklyBudgetMinor,
|
||
currencyCode: schema.households.currencyCode,
|
||
})
|
||
.from(schema.households)
|
||
.where(eq(schema.households.id, householdId))
|
||
.limit(1);
|
||
|
||
// Alla belopp i minor units (heltal) i hushållets valuta (i18n-spec §20).
|
||
const currency = household?.currencyCode ?? "SEK";
|
||
return {
|
||
currency,
|
||
week: {
|
||
purchasedMinor: Math.round(Number(weekPurchases?.total ?? 0)),
|
||
wasteMinor: Math.round(Number(weekWaste?.total ?? 0)),
|
||
wasteCount: Number(weekWaste?.count ?? 0),
|
||
budgetMinor: household?.budget ?? null,
|
||
},
|
||
month: {
|
||
purchasedMinor: Math.round(Number(monthPurchases?.total ?? 0)),
|
||
wasteMinor: Math.round(Number(monthWaste?.total ?? 0)),
|
||
wasteCount: Number(monthWaste?.count ?? 0),
|
||
estimatedCostPerPortionMinor:
|
||
mealPortions > 0 ? Math.round(mealCostTotal / mealPortions) : null,
|
||
cookedPortions: mealPortions,
|
||
},
|
||
note: "Kostnader bygger på kvitton, angivna priser och schablonpriser – uppskattningar, inte bokföring.",
|
||
};
|
||
});
|
||
}
|