From 814bdbbc2aed22273c8f341416ec699c80ccbcac Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 10:58:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(stock):=20say=C4=B1m=20oturumlar=C4=B1,=20?= =?UTF-8?q?fire=20ayarl=C4=B1=20=C3=B6neri=20motoru=20ve=20t=C3=BCketim=20?= =?UTF-8?q?analizi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/012-stock-counts.ts | 92 ++++++ scripts/migrations/index.ts | 2 + scripts/seed/run.ts | 1 + scripts/seed/stock.ts | 89 ++++- src/features/stock/actions.ts | 7 + src/features/stock/service.int.test.ts | 139 ++++++++ src/features/stock/service.ts | 438 ++++++++++++++++++++++--- src/i18n/tr.ts | 79 ++++- src/models/stock-count.ts | 45 +++ src/shared/schemas/stock.ts | 40 +++ src/shared/stock.test.ts | 128 ++++++++ src/shared/stock.ts | 228 ++++++++++++- 12 files changed, 1219 insertions(+), 69 deletions(-) create mode 100644 scripts/migrations/012-stock-counts.ts create mode 100644 src/models/stock-count.ts diff --git a/scripts/migrations/012-stock-counts.ts b/scripts/migrations/012-stock-counts.ts new file mode 100644 index 0000000..735dd7a --- /dev/null +++ b/scripts/migrations/012-stock-counts.ts @@ -0,0 +1,92 @@ +import { Types } from "mongoose"; +import { StockCountModel } from "@/models/stock-count"; +import { StockMovementModel } from "@/models/stock-movement"; +import { signedDelta, type StockMovementKind } from "@/shared/stock"; +import type { Migration } from "./types"; + +type Move = { + _id: Types.ObjectId; + householdId: Types.ObjectId; + productTypeId: Types.ObjectId; + kind: StockMovementKind; + date: string; + quantity: number; + initial: boolean; + countId: Types.ObjectId | null; + createdBy: Types.ObjectId; + createdAt: Date; +}; + +/** + * Faz 11b: sayım oturumları. Faz 11a'da tek ürün sayımıyla oluşan (oturumsuz) sayım + * düzeltmeleri, o ana kadarki kayıtlardan beklenen miktar hesaplanarak hane + tarih + kişi + * başına bir sayım oturumuna bağlanır. Başlangıç miktarları sayım sayılmaz. + */ +export const migration: Migration = { + name: "012-stock-counts", + description: "stockCounts index'leri; oturumsuz sayım düzeltmelerini sayım oturumuna bağla", + async up() { + await StockCountModel.createIndexes(); + const orphans = await StockMovementModel.find({ + kind: "count_adjust", + initial: { $ne: true }, + countId: null, + deletedAt: null, + }).lean(); + if (orphans.length === 0) return; + const products = [...new Set(orphans.map((o) => String(o.productTypeId)))]; + const all = await StockMovementModel.find({ + productTypeId: { $in: products.map((id) => new Types.ObjectId(id)) }, + deletedAt: null, + }).lean(); + const byProduct = new Map(); + for (const m of all) { + const list = byProduct.get(String(m.productTypeId)) ?? []; + list.push(m); + byProduct.set(String(m.productTypeId), list); + } + // Her düzeltme için beklenen = önceki hareketlerin toplamı (tarih, oluşturulma sırası). + const expectedOf = new Map(); + for (const list of byProduct.values()) { + list.sort((a, b) => + a.date === b.date + ? a.createdAt.getTime() - b.createdAt.getTime() + : a.date < b.date + ? -1 + : 1, + ); + let running = 0; + for (const m of list) { + expectedOf.set(String(m._id), running); + running += signedDelta(m); + } + } + const sessions = new Map(); + for (const o of orphans) { + const key = `${o.householdId}:${o.date}:${o.createdBy}`; + sessions.set(key, [...(sessions.get(key) ?? []), o]); + } + for (const moves of sessions.values()) { + const first = moves[0]!; + const count = await StockCountModel.create({ + householdId: first.householdId, + date: first.date, + categoryId: null, + items: moves.map((m) => { + const expected = expectedOf.get(String(m._id)) ?? 0; + return { + productTypeId: m.productTypeId, + expected, + actual: expected + m.quantity, + wasted: 0, + }; + }), + createdBy: first.createdBy, + }); + await StockMovementModel.updateMany( + { _id: { $in: moves.map((m) => m._id) } }, + { $set: { countId: count._id } }, + ); + } + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 0bd3225..57cccf7 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -11,6 +11,7 @@ import { migration as m008 } from "./008-in-kind"; import { migration as m009 } from "./009-assets"; import { migration as m010 } from "./010-budgets"; import { migration as m011 } from "./011-stock"; +import { migration as m012 } from "./012-stock-counts"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ @@ -26,6 +27,7 @@ export const MIGRATIONS: Migration[] = [ m009, m010, m011, + m012, ]; const COLLECTION = "migrations"; diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 633d0d9..6279713 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -83,6 +83,7 @@ const DOMAIN_COLLECTIONS = [ "stockCategories", "productTypes", "stockMovements", + "stockCounts", ]; type AuthApi = ReturnType["api"]; diff --git a/scripts/seed/stock.ts b/scripts/seed/stock.ts index f585c64..f215960 100644 --- a/scripts/seed/stock.ts +++ b/scripts/seed/stock.ts @@ -2,6 +2,7 @@ import { Types } from "mongoose"; import { installDefaultStockCategories } from "@/features/stock/service"; import { ProductTypeModel } from "@/models/product-type"; import { StockCategoryModel } from "@/models/stock-category"; +import { StockCountModel } from "@/models/stock-count"; import { StockMovementModel } from "@/models/stock-movement"; import { todayIstanbul } from "@/shared/dates"; import { addDays } from "@/shared/recurrence"; @@ -9,8 +10,10 @@ import { foldTr } from "@/shared/search"; /** * Örnek ev stoğu (Faz 11). Varsayılan stok kategorileri her haneye; Çakır Ailesi'ne ürün tipleri - * ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, sayım, SKT). Deterministik; - * idempotent: ürün tipi `seedRef` ile bir kez oluşturulur. Miktarlar binde bir. + * ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, periyodik sayım, SKT). + * Sayım: Mutfak 15 günde bir (son sayım 21 gün önce → gecikmiş), Genel Temizlik 30 günde bir + * (son sayım 14 gün önce). Deterministik; idempotent: ürün tipi `seedRef` ile bir kez + * oluşturulur, sayım oturumları `seedRef` ile paylaşılır. Miktarlar binde bir. */ type SeedProduct = { @@ -30,8 +33,8 @@ type SeedProduct = { waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" }; /** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */ stopRefillDaysAgo?: number; - /** Her n haftada bir küçük sayım farkı (−%). */ - countDrift?: { everyWeeks: number; pct: number }; + /** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */ + countDrift?: { everyCounts: number; pct: number }; }; const K = 1000; @@ -188,7 +191,7 @@ export const PRODUCTS: SeedProduct[] = [ pack: 50 * K, perDay: 700, location: "Banyo dolabı", - countDrift: { everyWeeks: 8, pct: 4 }, + countDrift: { everyCounts: 2, pct: 4 }, }, { ref: "yumusatici", @@ -221,7 +224,7 @@ export const PRODUCTS: SeedProduct[] = [ pack: 16 * K, perDay: 600, location: "Banyo dolabı", - countDrift: { everyWeeks: 10, pct: 6 }, + countDrift: { everyCounts: 3, pct: 6 }, }, // Kişisel Temizlik { @@ -259,6 +262,12 @@ export const PRODUCTS: SeedProduct[] = [ const START_DAYS_AGO = 182; +/** Sayım sıklığı (gün) ve sayım günü kuralı (gün önce), ana stok kategorisine göre. */ +const COUNT_PLANS: Record boolean }> = { + mutfak: { everyDays: 15, on: (d) => d >= 21 && d % 14 === 7 }, + "genel-temizlik": { everyDays: 30, on: (d) => d % 28 === 14 }, +}; + type Row = { kind: "purchase" | "consume" | "waste" | "count_adjust"; daysAgo: number; @@ -267,17 +276,24 @@ type Row = { wasteReason?: string | null; note?: string; initial?: boolean; + /** Bu satır hangi sayıma ait (gün önce). */ + countDaysAgo?: number; }; -/** Haftalık simülasyon: tüketim, eşikte alım, fire ve sayım farkı. Stok asla eksiye düşmez. */ -export function simulate(p: SeedProduct, today: string): Row[] { +export type SeedCount = { daysAgo: number; expected: number; actual: number }; + +/** + * Haftalık simülasyon: tüketim, periyodik sayım (farkı nadiren), eşikte alım ve fire. Stok asla + * eksiye düşmez. + */ +export function simulate(p: SeedProduct, today: string): { rows: Row[]; counts: SeedCount[] } { const rows: Row[] = []; + const counts: SeedCount[] = []; + const plan = COUNT_PLANS[p.category.split(".")[0]!]; let stock = p.target; let purchases = 0; rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true }); - let week = 0; for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) { - week++; // Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg). const step = p.step ?? K; const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step)); @@ -285,12 +301,21 @@ export function simulate(p: SeedProduct, today: string): Row[] { rows.push({ kind: "consume", daysAgo: d, quantity: consume }); stock -= consume; } - if (p.countDrift && week % p.countDrift.everyWeeks === 0 && stock > 0) { - const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100)); - if (drift > 0) { - rows.push({ kind: "count_adjust", daysAgo: d, quantity: -drift, note: "Sayım" }); + if (plan?.on(d)) { + const expected = stock; + const nth = counts.length + 1; + if (p.countDrift && nth % p.countDrift.everyCounts === 0 && stock > 0) { + const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100)); + rows.push({ + kind: "count_adjust", + daysAgo: d, + quantity: -drift, + note: "Sayım", + countDaysAgo: d, + }); stock -= drift; } + counts.push({ daysAgo: d, expected, actual: stock }); } const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo; if (canRefill && stock < (p.target * 35) / 100) { @@ -311,7 +336,7 @@ export function simulate(p: SeedProduct, today: string): Row[] { } } } - return rows; + return { rows, counts }; } export async function seedStock( @@ -321,6 +346,12 @@ export async function seedStock( const actor = new Types.ObjectId(users.get("ayse")!); for (const [, id] of householdIds) await installDefaultStockCategories(id, actor); const householdId = householdIds.get("Çakır Ailesi")!; + // Periyodik sayım sıklığı (kullanıcı ayarladıysa dokunulmaz). + for (const [key, plan] of Object.entries(COUNT_PLANS)) + await StockCategoryModel.updateOne( + { householdId, defaultKey: key, countEveryDays: null }, + { $set: { countEveryDays: plan.everyDays } }, + ); const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } }) .select({ defaultKey: 1 }) .lean<{ _id: Types.ObjectId; defaultKey: string }[]>(); @@ -344,9 +375,33 @@ export async function seedStock( updatedBy: actor, }); const base = Date.now() - START_DAYS_AGO * 86_400_000; + const { rows, counts } = simulate(p, today); + // Sayım oturumu kategori başına ortak: ürün satırı oturuma eklenir. + const root = p.category.split(".")[0]!; + const countIds = new Map(); + for (const c of counts) { + const session = await StockCountModel.findOneAndUpdate( + { householdId, seedRef: `${root}:${c.daysAgo}` }, + { + $setOnInsert: { + householdId, + date: addDays(today, -c.daysAgo), + categoryId: catByKey.get(root) ?? null, + note: "", + seedRef: `${root}:${c.daysAgo}`, + createdBy: actor, + }, + $push: { + items: { productTypeId: doc._id, expected: c.expected, actual: c.actual, wasted: 0 }, + }, + }, + { upsert: true, returnDocument: "after" }, + ).lean<{ _id: Types.ObjectId }>(); + countIds.set(c.daysAgo, session!._id); + } // Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur. await StockMovementModel.collection.insertMany( - simulate(p, today).map((r, i) => { + rows.map((r, i) => { const at = new Date(base + i * 1000); return { householdId, @@ -358,7 +413,7 @@ export async function seedStock( expiryDate: r.expiryDate ?? null, note: r.note ?? "", initial: r.initial ?? false, - countId: null, + countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null), deletedAt: null, seedRef: `${p.ref}:${i}`, createdBy: actor, diff --git a/src/features/stock/actions.ts b/src/features/stock/actions.ts index ee652d5..12de698 100644 --- a/src/features/stock/actions.ts +++ b/src/features/stock/actions.ts @@ -9,6 +9,7 @@ import { productTypeFormSchema, stockCategoryFormSchema, stockCountFormSchema, + stockCountSheetSchema, stockMovementFormSchema, } from "@/shared/schemas/stock"; import { @@ -21,6 +22,7 @@ import { quickConsume, setProductTypeArchived, setStockCategoryArchived, + submitCount, updateProductType, updateStockCategory, type StockOption, @@ -106,6 +108,11 @@ export async function countStockAction(productTypeId: unknown, input: unknown) { ); } +/** Toplu sayım (/stok/sayim). */ +export async function submitCountAction(input: unknown) { + return run(stockCountSheetSchema, input, submitCount); +} + export async function deleteStockMovementAction(movementId: unknown) { return withId(movementId, (id) => run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)), diff --git a/src/features/stock/service.int.test.ts b/src/features/stock/service.int.test.ts index 1bbd66b..a66e6b6 100644 --- a/src/features/stock/service.int.test.ts +++ b/src/features/stock/service.int.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { tr } from "@/i18n/tr"; import { AuditLogModel } from "@/models/audit-log"; +import { StockCountModel } from "@/models/stock-count"; import { StockMovementModel } from "@/models/stock-movement"; import { todayIstanbul } from "@/shared/dates"; import { addDays } from "@/shared/recurrence"; @@ -8,6 +9,7 @@ import { productTypeFormSchema, stockCategoryFormSchema, stockCountFormSchema, + stockCountSheetSchema, stockMovementFormSchema, type ProductTypeFormInput, } from "@/shared/schemas/stock"; @@ -18,13 +20,16 @@ import { createProductType, createStockCategory, deleteStockMovement, + getCountSheet, getProductType, getStock, + getStockAnalysis, getStockOptions, listStockCategories, quickConsume, setProductTypeArchived, setStockCategoryArchived, + submitCount, updateStockCategory, } from "./service"; @@ -305,3 +310,137 @@ describe("stok kategorileri", () => { ).toBe(false); }); }); + +describe("periyodik sayım, öneri ve analiz (Faz 11b)", () => { + it("seed: Mutfak sayımı gecikti, Genel Temizlik değil; fire öneriyi düşürür", async () => { + const s = (await getStock(ayse, cakir))!; + const paths = s.countDue.map((g) => g.path); + expect(paths).toEqual(expect.arrayContaining(["Mutfak › Manav", "Mutfak › Bakliyat"])); + expect(paths.some((p) => p.startsWith("Genel Temizlik"))).toBe(false); + // Son sayım 21 gün önce, sıklık 15 → 6 gün gecikti. + expect(s.countDue.find((g) => g.path === "Mutfak › Manav")!.overdueDays).toBe(6); + const domates = s.items.find((i) => i.name === "Domates")!; + expect(domates.count).toMatchObject({ everyDays: 15, lastDate: addDays(today, -21) }); + expect(domates.suggestion.waste?.pct).toBeGreaterThan(0); + const deterjan = s.items.find((i) => i.name === "Toz Deterjan")!; + expect(deterjan.count).toMatchObject({ everyDays: 30, due: false }); + expect(s.items.find((i) => i.name === "Şampuan")!.count).toBeNull(); + // Öneri hedefi aşmaz, tüketim hızı biliniyor. + for (const i of s.suggestions) { + expect(i.suggestedQuantity).toBeGreaterThan(0); + expect(i.suggestedQuantity).toBeLessThanOrEqual( + Math.ceil(i.suggestion.base / i.step) * i.step, + ); + // Bugün eklenen test ürünlerinin geçmişi kısa: kullanım hızı yok. + if (i.movementCount > 5) expect(i.suggestion.usage).not.toBeNull(); + } + }); + + it("sayım listesi kategori alt ağacıyla; toplu sayım fire + düzeltme, oturum kaydı", async () => { + const opts = (await getStockOptions(ayse, cakir))!; + const manav = opts.categories.find((c) => c.path === "Mutfak › Manav")!.id; + const mutfak = opts.categories.find((c) => c.path === "Mutfak")!.id; + const sheet = (await getCountSheet(ayse, cakir, mutfak))!; + expect(sheet.rows.every((r) => r.categoryPath.startsWith("Mutfak"))).toBe(true); + expect(sheet.categories.find((c) => c.id === manav)!.due).toBeGreaterThan(0); + + const patates = sheet.rows.find((r) => r.name === "Patates")!; + const sogan = sheet.rows.find((r) => r.name === "Kuru Soğan")!; + const bad = await submitCount( + ayse, + cakir, + stockCountSheetSchema.parse({ + date: today, + categoryId: mutfak, + rows: [ + { + productTypeId: patates.id, + actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","), + wasteQuantity: "0,6", + wasteReason: "spoiled", + }, + ], + }), + ); + expect(bad).toMatchObject({ + ok: false, + fieldErrors: { "rows.0.wasteQuantity": expect.any(String) }, + }); + + const r = await submitCount( + ayse, + cakir, + stockCountSheetSchema.parse({ + date: today, + categoryId: mutfak, + note: "Ay sonu", + rows: [ + { + productTypeId: patates.id, + actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","), + wasteQuantity: "0,3", + wasteReason: "spoiled", + }, + { + productTypeId: sogan.id, + actualQuantity: String(sogan.expected / 1000).replace(".", ","), + }, + { productTypeId: manav, actualQuantity: "" }, + ].map((x) => ({ wasteQuantity: "", wasteReason: "", ...x })), + }), + ); + expect(r).toMatchObject({ ok: true, data: { counted: 2, adjusted: 1, diffs: [-500, 0] } }); + const countId = r.ok ? r.data.countId : ""; + const session = await StockCountModel.findById(countId).lean(); + expect(session!.items).toHaveLength(2); + expect(session!.items[0]).toMatchObject({ expected: patates.expected, wasted: 300 }); + const moves = await StockMovementModel.find({ countId }).sort({ createdAt: 1 }).lean(); + expect(moves.map((m) => [m.kind, m.quantity, m.wasteReason])).toEqual([ + ["waste", 300, "spoiled"], + ["count_adjust", -200, null], + ]); + + // Fark sıfır olan soğan da sayıldı: takvim yenilendi. + const p = (await getProductType(ayse, cakir, sogan.id))!; + expect(p.count).toMatchObject({ lastDate: today, due: false, nextDate: addDays(today, 15) }); + expect((await getProductType(ayse, cakir, patates.id))!.current).toBe(patates.expected - 500); + + // Hiçbir satır dolu değilse şema reddeder; izleyici sayamaz. + expect( + stockCountSheetSchema.safeParse({ + date: today, + categoryId: "", + rows: [{ productTypeId: sogan.id, actualQuantity: "", wasteQuantity: "", wasteReason: "" }], + }).success, + ).toBe(false); + expect( + ( + await submitCount( + zeynep, + cakir, + stockCountSheetSchema.parse({ + date: today, + categoryId: "", + rows: [ + { productTypeId: sogan.id, actualQuantity: "1", wasteQuantity: "", wasteReason: "" }, + ], + }), + ) + ).ok, + ).toBe(false); + expect(await getCountSheet(fatma, cakir, null)).toBeNull(); + }); + + it("tüketim analizi: aylık ortalama ve fire oranı", async () => { + const a = (await getStockAnalysis(ayse, cakir))!; + expect(a.months).toHaveLength(6); + expect(a.months.at(-1)! < a.thisMonth).toBe(true); + const domates = a.rows.find((r) => r.name === "Domates")!; + expect(domates.avgMonthlyUsed).toBeGreaterThan(5_000); + expect(domates.wastePct).toBeGreaterThan(0); + const sampuan = a.rows.find((r) => r.name === "Şampuan")!; + expect(sampuan.avgMonthlyWasted).toBe(0); + expect(sampuan.wastePct).toBe(0); + expect(await getStockAnalysis(fatma, cakir)).toBeNull(); + }); +}); diff --git a/src/features/stock/service.ts b/src/features/stock/service.ts index f23e57a..229227f 100644 --- a/src/features/stock/service.ts +++ b/src/features/stock/service.ts @@ -20,26 +20,35 @@ import { toObjectId } from "@/lib/object-id"; import { CategoryModel } from "@/models/category"; import { ProductTypeModel } from "@/models/product-type"; import { StockCategoryModel } from "@/models/stock-category"; +import { StockCountModel } from "@/models/stock-count"; import { StockMovementModel } from "@/models/stock-movement"; import { todayIstanbul } from "@/shared/dates"; +import { shiftMonth } from "@/shared/budget"; import type { ProductTypeForm, StockCategoryForm, StockCountForm, + StockCountSheet, StockMovementForm, } from "@/shared/schemas/stock"; import { compareTr, foldTr } from "@/shared/search"; import { + countSchedule, currentQuantity, expiringLots, expiryStatus, fifoLots, formatStock, - refillQuantity, + monthlyUsage, + roundForDisplay, signedDelta, stockLevel, + suggestPurchase, + type CountSchedule, type ExpiringLot, type ExpiryStatus, + type MonthUsage, + type PurchaseSuggestion, type StockLevel, type StockMovementKind, type WasteReason, @@ -77,6 +86,7 @@ type ProductLean = { countEveryDays: number | null; note: string; archivedAt: Date | null; + createdAt: Date; }; type MoveLean = { @@ -124,16 +134,30 @@ export type ProductTypeView = { current: number; level: StockLevel; suggestedQuantity: number; + /** Öneri ayrıntısı: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */ + suggestion: PurchaseSuggestion; + /** Periyodik sayım (sıklık ürün tipinden, yoksa kategoriden). */ + count: CountSchedule | null; expiring: ExpiringLot[]; lastMovementDate: string | null; movementCount: number; }; +/** Sayımı gelen ürünlerin kategoriye göre özeti. */ +export type CountDueGroup = { + categoryId: string; + path: string; + products: number; + /** En çok geciken ürünün gecikmesi (gün). */ + overdueDays: number; +}; + export type StockView = { categories: StockCategoryView[]; items: ProductTypeView[]; suggestions: ProductTypeView[]; expiring: { item: ProductTypeView; lot: ExpiringLot }[]; + countDue: CountDueGroup[]; canWrite: boolean; today: string; }; @@ -261,19 +285,43 @@ function categoryViews( }); } -function toView( - p: ProductLean, - moves: MoveLean[], - categoryPath: string, - today: string, -): ProductTypeView { +/** Ürün başına son sayım tarihi (sayım oturumlarından). */ +async function loadLastCounts(householdId: Types.ObjectId, productIds: Types.ObjectId[]) { + const rows = await StockCountModel.aggregate<{ _id: Types.ObjectId; date: string }>([ + { $match: { householdId, "items.productTypeId": { $in: productIds } } }, + { $unwind: "$items" }, + { $match: { "items.productTypeId": { $in: productIds } } }, + { $group: { _id: "$items.productTypeId", date: { $max: "$date" } } }, + ]); + return new Map(rows.map((r) => [String(r._id), r.date])); +} + +type ViewContext = { + categoryPath: string; + /** Kategoriden devralınan sayım sıklığı. */ + categoryCountDays: number | null; + lastCount: string | null; + today: string; +}; + +const ymdOf = (d: Date) => todayIstanbul(d); + +function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTypeView { + const { today } = ctx; const current = currentQuantity(moves); const level = stockLevel(current, p.targetQuantity, p.thresholdPct); + const suggestion = suggestPurchase({ + current, + target: p.targetQuantity, + step: p.step, + moves, + today, + }); return { id: String(p._id), name: p.name, categoryId: String(p.categoryId), - categoryPath, + categoryPath: ctx.categoryPath, unit: p.unit, targetQuantity: p.targetQuantity, thresholdPct: p.thresholdPct, @@ -284,7 +332,14 @@ function toView( archived: Boolean(p.archivedAt), current, level, - suggestedQuantity: refillQuantity(current, p.targetQuantity), + suggestedQuantity: suggestion.quantity, + suggestion, + count: countSchedule( + p.countEveryDays ?? ctx.categoryCountDays, + ctx.lastCount, + moves[0]?.date ?? ymdOf(p.createdAt), + today, + ), expiring: expiringLots(fifoLots(moves), today), lastMovementDate: moves.at(-1)?.date ?? null, movementCount: moves.length, @@ -308,15 +363,22 @@ export async function getStock(userId: string, householdId: string): Promise [c.id, i])); - const pathOf = new Map(categories.map((c) => [c.id, c.path])); - const moves = await loadMoves( - m.householdId, - products.map((p) => p._id), - ); + const catOf = new Map(categories.map((c) => [c.id, c])); + const ids = products.map((p) => p._id); + const [moves, lastCounts] = await Promise.all([ + loadMoves(m.householdId, ids), + loadLastCounts(m.householdId, ids), + ]); const items = products - .map((p) => - toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today), - ) + .map((p) => { + const cat = catOf.get(String(p.categoryId)); + return toView(p, moves.get(String(p._id)) ?? [], { + categoryPath: cat?.path ?? "", + categoryCountDays: cat?.effectiveCountDays ?? null, + lastCount: lastCounts.get(String(p._id)) ?? null, + today, + }); + }) .sort( (a, b) => (order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) || @@ -333,11 +395,31 @@ export async function getStock(userId: string, householdId: string): Promise(); + for (const i of items) { + if (!i.count?.due) continue; + const g = by.get(i.categoryId) ?? { + categoryId: i.categoryId, + path: i.categoryPath, + products: 0, + overdueDays: 0, + }; + g.products++; + g.overdueDays = Math.max(g.overdueDays, i.count.overdueDays); + by.set(i.categoryId, g); + } + return [...by.values()].sort( + (a, b) => b.overdueDays - a.overdueDays || compareTr(a.path, b.path), + ); +} + async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) { const id = toObjectId(productTypeId); if (!id) return null; @@ -357,11 +439,14 @@ export async function getProductType( if (!p) return null; const today = todayIstanbul(); const cats = await loadCategories(m.householdId); - const path = - categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find( - (c) => c.id === String(p.categoryId), - )?.path ?? ""; - const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? []; + const cat = categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find( + (c) => c.id === String(p.categoryId), + ); + const [movesBy, lastCounts] = await Promise.all([ + loadMoves(m.householdId, [p._id]), + loadLastCounts(m.householdId, [p._id]), + ]); + const moves = movesBy.get(String(p._id)) ?? []; let running = 0; const rows = moves.map((mv): StockMovementRow => { const delta = signedDelta(mv); @@ -379,7 +464,12 @@ export async function getProductType( }; }); return { - ...toView(p, moves, path, today), + ...toView(p, moves, { + categoryPath: cat?.path ?? "", + categoryCountDays: cat?.effectiveCountDays ?? null, + lastCount: lastCounts.get(String(p._id)) ?? null, + today, + }), movements: rows.reverse(), lots: fifoLots(moves).map((l) => ({ date: l.date, @@ -877,8 +967,8 @@ export async function quickConsume( } /** - * Tek ürün sayımı: o tarihe kadarki kayıtlara göre beklenen ile sayılan arasındaki fark sayım - * düzeltmesi olur. Fark yoksa kayıt oluşmaz. Farkı (işaretli) döner. + * Tek ürün sayımı (ürün detayından): toplu sayımın tek satırlık hâli. Fark yoksa düzeltme + * oluşmaz ama sayım kaydı tutulur. Farkı (işaretli) döner. */ export async function countStock( actorId: string, @@ -886,22 +976,21 @@ export async function countStock( productTypeId: string, input: StockCountForm, ): Promise> { - const m = await resolveMembership(actorId, householdId, "member"); - if (!m) return fail(tr.common.unauthorized); - const r = await activeProductWithStock(m, productTypeId, input.date); - if (r.error !== null) return fail(r.error); - const diff = input.actualQuantity - r.current; - if (diff !== 0) - await StockMovementModel.create({ - householdId: m.householdId, - productTypeId: r.p._id, - kind: "count_adjust", - date: input.date, - quantity: diff, - note: input.note, - createdBy: new Types.ObjectId(actorId), - }); - return ok({ diff }); + const r = await submitCount(actorId, householdId, { + date: input.date, + categoryId: null, + note: input.note, + rows: [ + { + productTypeId, + actualQuantity: input.actualQuantity, + wasteQuantity: null, + wasteReason: null, + }, + ], + }); + if (!r.ok) return r; + return ok({ diff: r.data.diffs[0] ?? 0 }); } export async function deleteStockMovement( @@ -936,3 +1025,270 @@ export async function deleteStockMovement( }); return ok(undefined); } + +// --- Periyodik sayım (Faz 11b) --- + +export type CountSheetRow = { + id: string; + name: string; + categoryId: string; + categoryPath: string; + unit: string; + step: number; + location: string; + /** Kayıtlara göre bugün beklenen miktar. */ + expected: number; + count: CountSchedule | null; +}; + +export type CountSheet = { + today: string; + category: { id: string; path: string } | null; + /** Filtre seçenekleri: ürünü olan kategoriler ve sayımı gelen ürün sayısı. */ + categories: { id: string; path: string; depth: number; due: number }[]; + rows: CountSheetRow[]; + canWrite: boolean; +}; + +/** /stok/sayim: beklenen miktarlar (kategori seçiliyse alt ağacıyla). */ +export async function getCountSheet( + userId: string, + householdId: string, + categoryId: string | null, +): Promise { + const data = await getStock(userId, householdId); + if (!data) return null; + const all = data.categories; + const category = categoryId ? (all.find((c) => c.id === categoryId) ?? null) : null; + const inScope = category + ? new Set([ + category.id, + ...descendantIds( + all.map((c) => ({ + id: c.id, + kind: "expense" as const, + name: c.name, + parentId: c.parentId, + order: 0, + archived: false, + })), + category.id, + ), + ]) + : null; + const due = new Map(data.countDue.map((g) => [g.categoryId, g.products])); + const withItems = new Set(data.items.map((i) => i.categoryId)); + // Ürünü olan kategoriler ve ataları seçenek olarak görünür. + const visible = new Set(); + for (const c of all) + if (withItems.has(c.id)) { + visible.add(c.id); + let parent = c.parentId; + while (parent) { + visible.add(parent); + parent = all.find((x) => x.id === parent)?.parentId ?? null; + } + } + return { + today: data.today, + category: category ? { id: category.id, path: category.path } : null, + categories: all + .filter((c) => visible.has(c.id)) + .map((c) => ({ id: c.id, path: c.path, depth: c.depth, due: due.get(c.id) ?? 0 })), + rows: data.items + .filter((i) => !inScope || inScope.has(i.categoryId)) + .map((i) => ({ + id: i.id, + name: i.name, + categoryId: i.categoryId, + categoryPath: i.categoryPath, + unit: i.unit, + step: i.step, + location: i.location, + expected: i.current, + count: i.count, + })), + canWrite: data.canWrite, + }; +} + +/** + * Toplu sayım (tek transaction): her sayılan ürün için o tarihe kadarki kayıtlara göre beklenen + * hesaplanır; eksiğin fire olarak işaretlenen kısmı `waste`, kalan fark `count_adjust` olur. + * Fark sıfır olsa da sayım oturumuna yazılır. Fire, eksikten fazla olamaz. + */ +export async function submitCount( + actorId: string, + householdId: string, + input: StockCountSheet, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const rows = input.rows.filter((r) => r.actualQuantity !== null); + if (rows.length === 0) return fail(t.validation.nothingCounted); + if (new Set(rows.map((r) => r.productTypeId)).size !== rows.length) + return fail(tr.common.notFound); + const ids = rows.map((r) => new Types.ObjectId(r.productTypeId)); + const products = await ProductTypeModel.find({ + _id: { $in: ids }, + householdId: m.householdId, + }).lean(); + const byId = new Map(products.map((p) => [String(p._id), p])); + const moves = await loadMoves(m.householdId, ids); + + const fieldErrors: Record = {}; + const plan = rows.map((r) => { + const p = byId.get(r.productTypeId); + const index = input.rows.indexOf(r); + if (!p) { + fieldErrors[`rows.${index}.actualQuantity`] = tr.common.notFound; + return null; + } + if (p.archivedAt) { + fieldErrors[`rows.${index}.actualQuantity`] = t.validation.archived; + return null; + } + const expected = currentQuantity( + (moves.get(r.productTypeId) ?? []).filter((x) => x.date <= input.date), + ); + const diff = r.actualQuantity! - expected; + const wasted = r.wasteQuantity ?? 0; + if (wasted > Math.max(0, -diff)) { + fieldErrors[`rows.${index}.wasteQuantity`] = t.validation.wasteOverShortage( + formatStock(Math.max(0, -diff), p.unit), + ); + return null; + } + return { p, row: r, expected, diff, wasted }; + }); + if (Object.keys(fieldErrors).length) return fail(tr.adminUsers.errors.checkFields, fieldErrors); + const items = plan.filter((x) => x !== null); + + const actor = new Types.ObjectId(actorId); + const mongoose = await connectMongoose(); + let countId = ""; + await mongoose.connection.transaction(async (session) => { + const [count] = await StockCountModel.create( + [ + { + householdId: m.householdId, + date: input.date, + categoryId: input.categoryId ? new Types.ObjectId(input.categoryId) : null, + note: input.note, + items: items.map((x) => ({ + productTypeId: x.p._id, + expected: x.expected, + actual: x.row.actualQuantity!, + wasted: x.wasted, + })), + createdBy: actor, + }, + ], + { session }, + ); + countId = String(count!._id); + const docs = items.flatMap((x) => { + const base = { + householdId: m.householdId, + productTypeId: x.p._id, + date: input.date, + countId: count!._id, + createdBy: actor, + }; + const out: Record[] = []; + if (x.wasted > 0) + out.push({ + ...base, + kind: "waste", + quantity: x.wasted, + wasteReason: x.row.wasteReason, + note: t.countNote, + }); + const rest = x.diff + x.wasted; + if (rest !== 0) + out.push({ + ...base, + kind: "count_adjust", + quantity: rest, + note: input.note || t.countNote, + }); + return out; + }); + // Sıralı ekleme: aynı tarihte fire, düzeltmeden önce gelir. + for (const d of docs) await StockMovementModel.create([d], { session }); + }); + return ok({ + countId, + counted: items.length, + adjusted: items.filter((x) => x.diff !== 0).length, + diffs: items.map((x) => x.diff), + }); +} + +// --- Tüketim analizi (Faz 11b) --- + +/** Analizde gösterilen tam ay sayısı (içinde bulunulan ay ayrıca). */ +export const ANALYSIS_MONTHS = 6; + +export type StockAnalysisRow = { + id: string; + name: string; + categoryId: string; + categoryPath: string; + unit: string; + months: MonthUsage[]; + /** Ürünün var olduğu tam aylarda aylık ortalama kullanım (yoksa null). */ + avgMonthlyUsed: number | null; + avgMonthlyWasted: number | null; + /** Fire / (kullanım + fire), %. */ + wastePct: number | null; + /** Bu ay (bugüne kadar). */ + thisMonth: MonthUsage; +}; + +export type StockAnalysis = { + months: string[]; + thisMonth: string; + rows: StockAnalysisRow[]; +}; + +/** /stok/analiz: ürün tipi başına aylık ortalama tüketim ve fire. */ +export async function getStockAnalysis( + userId: string, + householdId: string, +): Promise { + const data = await getStock(userId, householdId); + if (!data) return null; + const thisMonth = data.today.slice(0, 7); + const months = Array.from({ length: ANALYSIS_MONTHS }, (_, i) => + shiftMonth(thisMonth, i - ANALYSIS_MONTHS), + ); + const m = (await resolveMembership(userId, householdId, "viewer"))!; + const moves = await loadMoves( + m.householdId, + data.items.map((i) => new Types.ObjectId(i.id)), + ); + const rows = data.items.map((item): StockAnalysisRow => { + const list = moves.get(item.id) ?? []; + const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!]; + // Ortalama: ilk hareketin ayından sonraki tam aylar (ilk ay eksik olabilir). + const first = list[0]?.date.slice(0, 7); + const full = first ? usage.filter((u) => u.month > first) : []; + const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0); + const used = sum("used"); + const wasted = sum("wasted"); + return { + id: item.id, + name: item.name, + categoryId: item.categoryId, + categoryPath: item.categoryPath, + unit: item.unit, + months: usage, + avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null, + avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null, + wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null, + thisMonth: current, + }; + }); + return { months, thisMonth, rows }; +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index b5be36b..0ea2d92 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -935,6 +935,67 @@ export const tr = { "Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.", newProduct: "Yeni ürün tipi", categoriesLink: "Stok kategorileri", + countLink: "Sayım", + analysisLink: "Tüketim analizi", + countNote: "Sayım", + countDue: { + title: "Sayımı gelenler", + description: + "Sayım sıklığı dolan kategoriler. Sıklık ürün tipinde veya stok kategorisinde ayarlanır.", + products: (n: number) => `${n} ürün`, + overdue: (days: number) => (days > 0 ? `${days} gün gecikti` : "Bugün"), + start: "Sayıma başla", + }, + count: { + title: "Sayım", + description: + "Evdeki gerçek miktarları girin. Kayıtlarla arasındaki fark sayım düzeltmesi olur; eksiğin bir kısmını fire olarak işaretleyebilirsiniz. Boş bıraktığınız ürünler sayılmaz.", + back: "Ev stoğuna dön", + allCategories: "Tüm stok", + category: "Kategori", + dueCount: (n: number) => `${n} sayımı gelen`, + onlyDue: "Yalnızca sayımı gelenler", + expected: "Beklenen", + actual: "Sayılan", + diff: "Fark", + same: "Aynı", + markWaste: "Eksiğin bir kısmı fire", + wasteQuantity: "Fire miktarı", + wasteMax: (q: string) => `En çok ${q}`, + dueBadge: (days: number) => (days > 0 ? `Sayım ${days} gün gecikti` : "Sayım bugün"), + lastCount: (date: string) => `Son sayım ${date}`, + neverCounted: "Hiç sayılmadı", + fillExpected: "Beklenenle doldur", + fillExpectedHint: "Sayılmayan satırlara beklenen miktarı yazar; değişenleri düzeltin.", + clear: "Temizle", + note: "Not", + counted: (n: number) => `${n} ürün sayılacak`, + submit: "Sayımı kaydet", + empty: "Bu kategoride sayılacak ürün yok.", + done: (counted: number, adjusted: number) => + adjusted + ? `${counted} ürün sayıldı; ${adjusted} üründe fark düzeltildi.` + : `${counted} ürün sayıldı; kayıtlarla aynı.`, + }, + analysis: { + title: "Tüketim analizi", + description: + "Ürün tipi başına aylık tüketim ve fire. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet, ürün fiyatları (Faz 12) gelince eklenecek.", + back: "Ev stoğuna dön", + product: "Ürün tipi", + avgUsed: "Aylık ort. tüketim", + avgWasted: "Aylık ort. fire", + wastePct: "Fire oranı", + thisMonth: "Bu ay", + trend: (n: number) => `Son ${n} ay`, + noData: "—", + empty: "Analiz için ürün tipi yok.", + chartLabel: (name: string) => `${name}: aylık tüketim`, + monthValue: (month: string, used: string, wasted: string) => + `${month}: tüketim ${used}, fire ${wasted}`, + highWaste: "Fire oranı yüksek", + table: "Tablo", + }, readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.", summary: { products: "Ürün tipi", @@ -947,6 +1008,10 @@ export const tr = { description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.", empty: "Eşiğin altına düşen ürün yok.", suggested: (q: string) => `Öneri: ${q}`, + wasteReduced: (purchases: number, pct: number) => + `Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`, + roundedByUsage: (week: string) => `Haftalık tüketime (${week}) göre yuvarlandı.`, + covers: (days: number) => `Mevcutla birlikte yaklaşık ${days} gün yeter.`, }, expiring: { title: "Son kullanma tarihi", @@ -1031,6 +1096,13 @@ export const tr = { target: "Hedef", threshold: "Eşik", suggested: "Önerilen alım", + usage: "Tüketim hızı", + perWeek: (q: string) => `Haftada ~${q}`, + noUsage: "Henüz yeterli geçmiş yok", + countSchedule: "Periyodik sayım", + countEvery: (n: number) => `${n} günde bir`, + nextCount: (date: string) => `Sonraki sayım ${date}`, + noCountSchedule: "Sayım sıklığı ayarlanmadı", location: "Saklama yeri", lots: "Evdeki partiler", lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).", @@ -1103,17 +1175,20 @@ export const tr = { wasteReason: "Fire nedenini seçin", expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz", notEnough: (q: string) => `Stokta yalnızca ${q} var.`, + nothingCounted: "En az bir ürünün sayılan miktarını girin.", + wasteOverShortage: (q: string) => `Fire, sayımda çıkan eksikten (${q}) fazla olamaz.`, emptyStock: "Stok zaten boş.", duplicate: "Bu adla bir ürün tipi zaten var.", categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.", archived: "Ürün tipi arşivli.", }, dashboard: { - title: "Azalan stoklar", - description: (low: number, expiring: number) => + title: "Stok uyarıları", + description: (low: number, expiring: number, countDue = 0) => [ low ? `${low} ürün eşiğin altında` : null, expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null, + countDue ? `${countDue} kategoride sayım zamanı` : null, ] .filter(Boolean) .join(" · "), diff --git a/src/models/stock-count.ts b/src/models/stock-count.ts new file mode 100644 index 0000000..d0c9a8a --- /dev/null +++ b/src/models/stock-count.ts @@ -0,0 +1,45 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; + +/** + * Sayım oturumu (Bölüm 6.10, periyodik sayım). Her sayılan ürün için beklenen ve sayılan miktar, + * fireye ayrılan kısım. Fark sıfır olsa da kayıt tutulur: "son sayım" ve sayım takvimi buradan + * türetilir. Oluşan hareketler `countId` ile bağlanır. + */ +const stockCountItemSchema = new Schema( + { + productTypeId: { type: Schema.Types.ObjectId, required: true }, + expected: { type: Number, required: true }, + actual: { type: Number, required: true }, + /** Eksiğin fire olarak işaretlenen kısmı (≥ 0). */ + wasted: { type: Number, default: 0 }, + }, + { _id: false }, +); + +const stockCountSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + /** "YYYY-MM-DD" (İstanbul). */ + date: { type: String, required: true }, + /** Sayılan stok kategorisi (alt ağacıyla); null: tüm stok veya tek ürün. */ + categoryId: { type: Schema.Types.ObjectId, default: null }, + items: { type: [stockCountItemSchema], default: [] }, + note: { type: String, default: "", maxlength: 300 }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "stockCounts" }, +); + +stockCountSchema.index({ householdId: 1, "items.productTypeId": 1, date: -1 }); +stockCountSchema.index({ householdId: 1, date: -1 }); +stockCountSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type StockCount = InferSchemaType; + +export const StockCountModel: Model = + (models.StockCount as Model | undefined) ?? + model("StockCount", stockCountSchema); diff --git a/src/shared/schemas/stock.ts b/src/shared/schemas/stock.ts index 144be9f..440baa9 100644 --- a/src/shared/schemas/stock.ts +++ b/src/shared/schemas/stock.ts @@ -118,3 +118,43 @@ export const stockCountFormSchema = z.object({ }); export type StockCountFormInput = z.input; export type StockCountForm = z.output; + +/** + * Toplu sayım satırı: sayılan miktar boşsa ürün sayılmamış sayılır (atlanır). Eksiğin bir kısmı + * fire olarak işaretlenebilir (neden zorunlu). + */ +const stockCountRowSchema = z + .object({ + productTypeId: z.string().regex(/^[a-f0-9]{24}$/i), + actualQuantity: optionalQuantity, + wasteQuantity: optionalQuantity.default(null), + wasteReason: z.preprocess( + (x) => (x === "" || x === undefined ? null : x), + z.enum(WASTE_REASONS).nullable(), + ), + }) + .superRefine((x, ctx) => { + if (x.wasteQuantity && !x.wasteReason) + ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason }); + if (x.wasteQuantity && x.actualQuantity === null) + ctx.addIssue({ code: "custom", path: ["actualQuantity"], message: s.quantity }); + }) + .transform((x) => ({ + ...x, + wasteQuantity: x.wasteQuantity || null, + wasteReason: x.wasteQuantity ? x.wasteReason : null, + })); + +export const stockCountSheetSchema = z + .object({ + date: ymd, + categoryId: optionalId, + note: z.string().trim().max(300).default(""), + rows: z.array(stockCountRowSchema).max(500), + }) + .superRefine((x, ctx) => { + if (!x.rows.some((r) => r.actualQuantity !== null)) + ctx.addIssue({ code: "custom", path: ["rows"], message: s.nothingCounted }); + }); +export type StockCountSheetInput = z.input; +export type StockCountSheet = z.output; diff --git a/src/shared/stock.test.ts b/src/shared/stock.test.ts index 7e6c20f..3210cec 100644 --- a/src/shared/stock.test.ts +++ b/src/shared/stock.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from "vitest"; import { + countSchedule, currentQuantity, expiringLots, expiryStatus, fifoLots, + monthlyUsage, quantityInput, + recentWaste, + roundForDisplay, refillQuantity, stepLabel, stockLevel, + suggestPurchase, + usageRate, type StockMove, } from "./stock"; @@ -94,8 +100,130 @@ describe("eşik ve öneri", () => { expect(quantityInput(250)).toBe("0,25"); }); + it("gösterim yuvarlaması", () => { + expect(roundForDisplay(18_368)).toBe(18_400); + expect(roundForDisplay(1_672)).toBe(1_670); + expect(roundForDisplay(441.4)).toBe(440); + }); + it("azaltma adımı etiketi", () => { expect(stepLabel(1_000, "adet")).toBe("−1 adet"); expect(stepLabel(500, "kg")).toBe("−0,5 kg"); }); }); + +describe("fire oranı ve önerilen alım", () => { + const wasteful = [ + m("purchase", "2026-11-01", 3_000), + m("waste", "2026-11-02", 900), + m("purchase", "2026-11-03", 3_000), + m("waste", "2026-11-04", 900), + m("consume", "2026-11-05", 4_200), + ]; + + it("fire FIFO ile alımlara yazılır; son alımların oranı", () => { + expect(recentWaste(wasteful)).toEqual({ + purchases: 2, + purchased: 6_000, + wasted: 1_800, + pct: 30, + }); + // Başlangıç partisinden düşen fire alım oranına girmez. + expect( + recentWaste([ + { ...m("count_adjust", "2026-10-01", 1_000), initial: true } as StockMove, + m("waste", "2026-10-02", 500), + m("purchase", "2026-10-03", 2_000), + ]), + ).toMatchObject({ purchases: 1, wasted: 0, pct: 0 }); + expect(recentWaste([m("consume", "2026-10-01", 1)])).toBeNull(); + }); + + it("%30 fire öneriyi düşürür, adıma yuvarlanır", () => { + const s = suggestPurchase({ + current: 0, + target: 3_000, + step: 500, + moves: wasteful, + today: "2026-11-06", + }); + // 3 kg × %70 = 2,1 kg → 0,5'in katı 2 kg. Geçmiş 14 günden kısa: kullanım yok. + expect(s).toMatchObject({ base: 3_000, quantity: 2_000, roundTo: 500, usage: null }); + expect(s.waste?.pct).toBe(30); + }); + + it("tüketim hızına (haftalık kullanımın katına) yuvarlanır, hedefi aşmaz", () => { + const moves = [ + { ...m("count_adjust", "2026-10-10", 10_000), initial: true }, + m("consume", "2026-11-01", 8_400), + ]; + expect(usageRate(moves, "2026-11-06")).toEqual({ perDay: 300, days: 28 }); + const s = suggestPurchase({ + current: 1_600, + target: 6_000, + step: 500, + moves, + today: "2026-11-06", + }); + // Haftalık 2,1 → 2 kg; taban 4,4 → 2 hafta = 4 kg; (1,6 + 4) / 0,3 = 18 gün. + expect(s).toMatchObject({ + base: 4_400, + quantity: 4_000, + roundTo: 2_000, + roundedByUsage: true, + waste: null, + coverDays: 18, + }); + // Taban küçükse en az bir adım; hedef doluysa öneri yok. + expect( + suggestPurchase({ current: 950, target: 1_000, step: 100, moves: [], today: "2026-11-06" }) + .quantity, + ).toBe(100); + expect( + suggestPurchase({ current: 1_000, target: 1_000, step: 100, moves: [], today: "2026-11-06" }) + .quantity, + ).toBe(0); + }); +}); + +describe("sayım takvimi ve aylık kullanım", () => { + it("son sayımdan n gün sonra sayım gelir", () => { + expect(countSchedule(15, "2026-10-20", "2026-01-01", "2026-11-06")).toEqual({ + everyDays: 15, + lastDate: "2026-10-20", + nextDate: "2026-11-04", + due: true, + overdueDays: 2, + }); + expect(countSchedule(15, null, "2026-10-25", "2026-11-06")).toMatchObject({ + nextDate: "2026-11-09", + due: false, + overdueDays: 0, + }); + expect(countSchedule(7, "2026-10-30", "2026-10-01", "2026-11-06")).toMatchObject({ + due: true, + overdueDays: 0, + }); + expect(countSchedule(null, null, "2026-10-01", "2026-11-06")).toBeNull(); + }); + + it("kullanım = tüketim + sayım eksiği; fire ayrı", () => { + expect( + monthlyUsage( + [ + { ...m("count_adjust", "2026-10-01", 5_000), initial: true }, + m("consume", "2026-10-05", 1_000), + m("count_adjust", "2026-10-10", -200), + m("waste", "2026-10-12", 300), + m("count_adjust", "2026-10-15", 100), + m("consume", "2026-11-02", 500), + ], + ["2026-09", "2026-10", "2026-11"], + ), + ).toEqual([ + { month: "2026-09", used: 0, wasted: 0 }, + { month: "2026-10", used: 1_200, wasted: 300 }, + { month: "2026-11", used: 500, wasted: 0 }, + ]); + }); +}); diff --git a/src/shared/stock.ts b/src/shared/stock.ts index f011d46..27e13b0 100644 --- a/src/shared/stock.ts +++ b/src/shared/stock.ts @@ -1,4 +1,5 @@ import { daysBetween } from "./budget"; +import { addDays } from "./recurrence"; import { formatQuantity, QUANTITY_SCALE } from "./quantity"; /** @@ -58,13 +59,11 @@ export type Lot = { remaining: number; }; -/** - * FIFO partileri. `moves` tarih (ve oluşturulma) sırasında olmalı. Girişler (alım, artı sayım - * farkı) parti açar; çıkışlar en eski partiden düşer. Stok geçici olarak eksiye düşerse (geriye - * tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner. - */ -export function fifoLots(moves: readonly StockMove[]): Lot[] { - const lots: Lot[] = []; +type TrackedLot = Lot & { kind: StockMovementKind; wasted: number }; + +/** FIFO çalıştırır; her girişin kalanını ve fireyle düşülen kısmını izler. */ +function runFifo(moves: readonly StockMove[]): TrackedLot[] { + const lots: TrackedLot[] = []; let deficit = 0; moves.forEach((m, index) => { const delta = signedDelta(m); @@ -73,10 +72,12 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] { deficit -= covered; lots.push({ index, + kind: m.kind, date: m.date, expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null, quantity: delta, remaining: delta - covered, + wasted: 0, }); return; } @@ -85,11 +86,29 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] { if (out === 0) break; const take = Math.min(lot.remaining, out); lot.remaining -= take; + if (m.kind === "waste") lot.wasted += take; out -= take; } deficit += out; }); - return lots.filter((l) => l.remaining > 0); + return lots; +} + +/** + * FIFO partileri. `moves` tarih (ve oluşturulma) sırasında olmalı. Girişler (alım, artı sayım + * farkı) parti açar; çıkışlar en eski partiden düşer. Stok geçici olarak eksiye düşerse (geriye + * tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner. + */ +export function fifoLots(moves: readonly StockMove[]): Lot[] { + return runFifo(moves) + .filter((l) => l.remaining > 0) + .map(({ index, date, expiryDate, quantity, remaining }) => ({ + index, + date, + expiryDate, + quantity, + remaining, + })); } export type ExpiryStatus = "expired" | "soon" | "ok"; @@ -142,7 +161,7 @@ export function stockLevel(current: number, target: number, thresholdPct: number }; } -/** Basit öneri: hedefe tamamla (Faz 11b'de tüketim hızı ve fireye göre ayarlanır). */ +/** Hedefe tamamlayan miktar (öneri motorunun tabanı). */ export function refillQuantity(current: number, target: number): number { return Math.max(0, target - Math.max(0, current)); } @@ -157,9 +176,200 @@ export function stepLabel(step: number, unit: string): string { return `−${formatQuantity(step || QUANTITY_SCALE, unit)}`; } +/** Ortalama/hız gösterimi: 10 birimden büyükse bir, değilse iki ondalık (18,368 → 18,4). */ +export function roundForDisplay(quantity: number): number { + const unit = Math.abs(quantity) >= 10 * QUANTITY_SCALE ? 100 : 10; + return Math.round(quantity / unit) * unit; +} + /** Form alanı için gruplamasız metin: 1500 → "1,5"; 1_000_000 → "1000" (parseQuantity ile uyumlu). */ export function quantityInput(quantity: number): string { const int = Math.trunc(quantity / QUANTITY_SCALE); const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, ""); return frac ? `${int},${frac}` : String(int); } + +// --- Öneri, tüketim hızı, sayım ve analiz (Faz 11b) --- + +/** Fire oranı son bu kadar alımdan hesaplanır. */ +export const WASTE_LOOKBACK_PURCHASES = 3; +/** Bu oranın (%) altındaki fire öneriyi değiştirmez. */ +export const WASTE_MIN_PCT = 5; +/** Tüketim hızı son bu kadar günden hesaplanır. */ +export const USAGE_WINDOW_DAYS = 90; +/** Bundan kısa geçmişte tüketim hızı hesaplanmaz. */ +export const USAGE_MIN_DAYS = 14; + +export type WasteStat = { + /** Hesaba katılan alım sayısı. */ + purchases: number; + purchased: number; + wasted: number; + /** Fire oranı (%, tamsayı). */ + pct: number; +}; + +/** + * Son alımların fire oranı: fireler FIFO ile hangi alımdan düşüldüyse ona yazılır + * (ör. iki alımın 6 kg'ından 1,8 kg'ı bozuldu → %30). Alım yoksa null. + */ +export function recentWaste( + moves: readonly StockMove[], + lookback = WASTE_LOOKBACK_PURCHASES, +): WasteStat | null { + const purchases = runFifo(moves) + .filter((l) => l.kind === "purchase") + .slice(-lookback); + if (purchases.length === 0) return null; + const purchased = purchases.reduce((s, l) => s + l.quantity, 0); + const wasted = purchases.reduce((s, l) => s + l.wasted, 0); + return { + purchases: purchases.length, + purchased, + wasted, + pct: purchased > 0 ? Math.round((wasted * 100) / purchased) : 0, + }; +} + +/** Kullanım: tüketim + sayımda çıkan eksik (başlangıç hariç). Fire ayrı tutulur. */ +function usageOf(m: StockMove & { initial?: boolean }): number { + if (m.kind === "consume") return m.quantity; + if (m.kind === "count_adjust" && !m.initial && m.quantity < 0) return -m.quantity; + return 0; +} + +export type UsageRate = { + /** Günlük ortalama kullanım (binde bir, kesirli olabilir). */ + perDay: number; + /** Hesaba katılan gün sayısı. */ + days: number; +}; + +/** + * Son `window` günün günlük ortalama kullanımı. Pencere, ilk hareketten önce başlıyorsa ilk + * hareketten sayılır. Geçmiş `USAGE_MIN_DAYS`'ten kısaysa null. + */ +export function usageRate( + moves: readonly (StockMove & { initial?: boolean })[], + today: string, + window = USAGE_WINDOW_DAYS, +): UsageRate | null { + const first = moves[0]?.date; + if (!first) return null; + const windowStart = addDays(today, -(window - 1)); + const start = first > windowStart ? first : windowStart; + if (start > today) return null; + const days = daysBetween(start, today); + if (days < USAGE_MIN_DAYS) return null; + const used = moves + .filter((m) => m.date >= start && m.date <= today) + .reduce((s, m) => s + usageOf(m), 0); + return { perDay: used / days, days }; +} + +export type PurchaseSuggestion = { + /** Önerilen alım (binde bir). */ + quantity: number; + /** Hedefe tamamlayan ham miktar. */ + base: number; + /** Öneriyi düşüren fire (oran WASTE_MIN_PCT'nin altındaysa null). */ + waste: WasteStat | null; + /** Yuvarlama birimi: haftalık kullanım (adımın katı) veya adım. */ + roundTo: number; + roundedByUsage: boolean; + usage: UsageRate | null; + /** Önerilen alım + mevcut kaç gün yeter (kullanım bilinmiyorsa null). */ + coverDays: number | null; +}; + +const roundUp = (x: number, unit: number) => Math.ceil(x / unit) * unit; + +/** + * Önerilen alım miktarı (Bölüm 6.10): + * 1. Taban = hedef − mevcut. + * 2. Son alımlarda fire varsa (≥ %5) taban fire oranı kadar düşürülür. + * 3. Tüketim hızına göre yuvarlanır: haftalık kullanım adımdan büyükse haftalık kullanımın + * (adıma yuvarlanmış) katına, değilse adıma en yakın kata. Sonuç tabanı (adıma yukarı + * yuvarlanmış) aşmaz; taban > 0 iken en az bir adımdır. + */ +export function suggestPurchase(input: { + current: number; + target: number; + step: number; + moves: readonly (StockMove & { initial?: boolean })[]; + today: string; +}): PurchaseSuggestion { + const step = Math.max(1, input.step); + const base = refillQuantity(input.current, input.target); + const stat = recentWaste(input.moves); + const waste = stat && stat.pct >= WASTE_MIN_PCT && stat.wasted > 0 ? stat : null; + const usage = usageRate(input.moves, input.today); + const weekly = usage ? Math.round((usage.perDay * 7) / step) * step : 0; + const roundTo = weekly > step ? weekly : step; + let quantity = 0; + if (base > 0) { + const reduced = waste ? (base * (100 - waste.pct)) / 100 : base; + quantity = Math.round(reduced / roundTo) * roundTo; + if (quantity === 0) quantity = roundUp(reduced, step); + quantity = Math.max(step, Math.min(quantity, roundUp(base, step))); + } + const after = Math.max(0, input.current) + quantity; + return { + quantity, + base, + waste, + roundTo, + roundedByUsage: roundTo !== step, + usage, + coverDays: usage && usage.perDay > 0 ? Math.floor(after / usage.perDay) : null, + }; +} + +export type CountSchedule = { + everyDays: number; + /** Son sayım (yoksa null). */ + lastDate: string | null; + nextDate: string; + due: boolean; + /** Sayım günü geçtiyse kaç gün geçti (bugün sayım günüyse 0). */ + overdueDays: number; +}; + +/** + * Periyodik sayım takvimi: son sayımdan (hiç sayılmadıysa `since`'ten, ör. ilk hareket) + * `everyDays` gün sonra sayım gelir. Sıklık yoksa null. + */ +export function countSchedule( + everyDays: number | null, + lastDate: string | null, + since: string, + today: string, +): CountSchedule | null { + if (!everyDays) return null; + const nextDate = addDays(lastDate ?? since, everyDays); + const due = nextDate <= today; + return { + everyDays, + lastDate, + nextDate, + due, + overdueDays: due ? daysBetween(nextDate, today) - 1 : 0, + }; +} + +export type MonthUsage = { month: string; used: number; wasted: number }; + +/** Ay ay ("YYYY-MM") kullanım ve fire. */ +export function monthlyUsage( + moves: readonly (StockMove & { initial?: boolean })[], + months: readonly string[], +): MonthUsage[] { + const by = new Map(months.map((m) => [m, { month: m, used: 0, wasted: 0 }])); + for (const mv of moves) { + const row = by.get(mv.date.slice(0, 7)); + if (!row) continue; + row.used += usageOf(mv); + if (mv.kind === "waste") row.wasted += mv.quantity; + } + return months.map((m) => by.get(m)!); +}