From 814bdbbc2aed22273c8f341416ec699c80ccbcac Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 10:58:06 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(stock):=20say=C4=B1m=20oturumlar=C4=B1?= =?UTF-8?q?,=20fire=20ayarl=C4=B1=20=C3=B6neri=20motoru=20ve=20t=C3=BCketi?= =?UTF-8?q?m=20analizi?= 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)!); +} From 07ba531d317fded8c7ce7dc36d07bf9c1b2300a9 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 10:58:12 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(ui):=20say=C4=B1m=20ekran=C4=B1,=20t?= =?UTF-8?q?=C3=BCketim=20analizi=20ve=20say=C4=B1m=20hat=C4=B1rlatmalar?= =?UTF-8?q?=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/stok/analiz/page.tsx | 16 + src/app/(app)/stok/sayim/page.tsx | 19 + src/components/layout/nav-config.ts | 2 +- src/features/stock/components/count-sheet.tsx | 351 ++++++++++++++++++ .../stock/components/low-stock-card.tsx | 30 +- .../stock/components/product-detail-view.tsx | 58 ++- .../stock/components/stock-analysis-view.tsx | 181 +++++++++ src/features/stock/components/stock-view.tsx | 116 +++++- 8 files changed, 749 insertions(+), 24 deletions(-) create mode 100644 src/app/(app)/stok/analiz/page.tsx create mode 100644 src/app/(app)/stok/sayim/page.tsx create mode 100644 src/features/stock/components/count-sheet.tsx create mode 100644 src/features/stock/components/stock-analysis-view.tsx diff --git a/src/app/(app)/stok/analiz/page.tsx b/src/app/(app)/stok/analiz/page.tsx new file mode 100644 index 0000000..127e09e --- /dev/null +++ b/src/app/(app)/stok/analiz/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { StockAnalysisView } from "@/features/stock/components/stock-analysis-view"; +import { getStockAnalysis } from "@/features/stock/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.stock.analysis.title }; + +/** /stok/analiz — ürün tipi başına aylık ortalama tüketim ve fire. */ +export default async function StockAnalysisPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const data = await getStockAnalysis(user.id, household.id); + if (!data) notFound(); + return ; +} diff --git a/src/app/(app)/stok/sayim/page.tsx b/src/app/(app)/stok/sayim/page.tsx new file mode 100644 index 0000000..be5075f --- /dev/null +++ b/src/app/(app)/stok/sayim/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CountSheet } from "@/features/stock/components/count-sheet"; +import { getCountSheet } from "@/features/stock/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.stock.count.title }; + +/** /stok/sayim?kategori= — periyodik sayım: beklenen → sayılan (kategori alt ağacıyla). */ +export default async function StockCountPage({ searchParams }: PageProps<"/stok/sayim">) { + const { user, household } = await requireActiveHousehold("viewer"); + const sp = await searchParams; + const kategori = + typeof sp.kategori === "string" && /^[a-f0-9]{24}$/i.test(sp.kategori) ? sp.kategori : null; + const data = await getCountSheet(user.id, household.id, kategori); + if (!data) notFound(); + return ; +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 3b712d8..e6ee9d1 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 11.1; +export const CURRENT_PHASE = 11.2; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/features/stock/components/count-sheet.tsx b/src/features/stock/components/count-sheet.tsx new file mode 100644 index 0000000..b5c65f3 --- /dev/null +++ b/src/features/stock/components/count-sheet.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { ArrowLeft, ClipboardCheck, Eraser, ListChecks } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { PageHeader } from "@/components/page-header"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { parseQuantity } from "@/shared/quantity"; +import { formatStock, quantityInput, WASTE_REASONS, type WasteReason } from "@/shared/stock"; +import { submitCountAction } from "../actions"; +import type { CountSheet as Data, CountSheetRow } from "../service"; +import { ymdLabel } from "./stock-view"; + +const t = tr.stock; +const ALL = "__all__"; + +type Entry = { actual: string; waste: string; reason: WasteReason | "" }; +const EMPTY: Entry = { actual: "", waste: "", reason: "" }; + +function signed(q: number, unit: string) { + if (q === 0) return t.count.same; + return `${q > 0 ? "+" : "−"}${formatStock(Math.abs(q), unit)}`; +} + +/** /stok/sayim — liste hâlinde beklenen → sayılan; fark sayım düzeltmesi, bir kısmı fire. */ +export function CountSheet({ data }: { data: Data }) { + const router = useRouter(); + const [entries, setEntries] = useState>({}); + const [errors, setErrors] = useState>({}); + const [onlyDue, setOnlyDue] = useState(data.rows.some((r) => r.count?.due)); + const [note, setNote] = useState(""); + const [pending, start] = useTransition(); + + const rows = useMemo( + () => (onlyDue ? data.rows.filter((r) => r.count?.due) : data.rows), + [data.rows, onlyDue], + ); + const groups = useMemo(() => { + const by = new Map(); + for (const r of rows) { + const g = by.get(r.categoryId) ?? { path: r.categoryPath, rows: [] }; + g.rows.push(r); + by.set(r.categoryId, g); + } + return [...by.values()]; + }, [rows]); + const dueTotal = data.rows.filter((r) => r.count?.due).length; + const filled = data.rows.filter((r) => entries[r.id]?.actual.trim()).length; + + const set = (id: string, patch: Partial) => + setEntries((e) => ({ ...e, [id]: { ...(e[id] ?? EMPTY), ...patch } })); + + const fillExpected = () => + setEntries((e) => { + const next = { ...e }; + for (const r of rows) + if (!next[r.id]?.actual.trim()) + next[r.id] = { ...(next[r.id] ?? EMPTY), actual: quantityInput(Math.max(0, r.expected)) }; + return next; + }); + + const submit = () => + start(async () => { + const sent = data.rows.filter((r) => entries[r.id]?.actual.trim()); + const r = await submitCountAction({ + date: data.today, + categoryId: data.category?.id ?? "", + note, + rows: sent.map((row) => { + const e = entries[row.id]!; + // Fire alanı yalnızca sayımda eksik çıkınca geçerlidir. + const actual = parseQuantity(e.actual); + const short = actual !== null && actual < row.expected; + return { + productTypeId: row.id, + actualQuantity: e.actual, + wasteQuantity: short ? e.waste : "", + wasteReason: short ? e.reason : "", + }; + }), + }); + if (r.ok) { + const d = r.data as { counted: number; adjusted: number }; + toast.success(t.count.done(d.counted, d.adjusted)); + setEntries({}); + setErrors({}); + setNote(""); + router.refresh(); + return; + } + const next: Record = {}; + for (const [key, msg] of Object.entries(r.fieldErrors ?? {})) { + const m = /^rows\.(\d+)\.(\w+)$/.exec(key); + const row = m ? sent[Number(m[1])] : undefined; + if (row && msg) next[`${row.id}.${m![2]}`] = msg; + } + setErrors(next); + toast.error(r.fieldErrors?.rows ?? r.error); + }); + + return ( +
+ + + +
+ + {dueTotal > 0 ? ( + + ) : null} + {data.canWrite && rows.length ? ( +
+ + {filled ? ( + + ) : null} +
+ ) : null} +
+ + {rows.length === 0 ? ( +

+ {t.count.empty} +

+ ) : null} + + {groups.map((g) => ( +
+

{g.path}

+
    + {g.rows.map((r) => ( + set(r.id, patch)} + /> + ))} +
+
+ ))} + + {data.canWrite && data.rows.length ? ( +
+ setNote(e.target.value)} + placeholder={t.count.note} + aria-label={t.count.note} + maxLength={300} + className="min-w-40 flex-1" + /> + + {t.count.counted(filled)} + + +
+ ) : null} +
+ ); +} + +function CountRow({ + row, + entry, + errors, + disabled, + onChange, +}: { + row: CountSheetRow; + entry: Entry; + errors: Record; + disabled: boolean; + onChange: (patch: Partial) => void; +}) { + const actual = entry.actual.trim() ? parseQuantity(entry.actual) : null; + const diff = actual === null ? null : actual - row.expected; + const shortage = diff !== null && diff < 0 ? -diff : 0; + const actualError = errors[`${row.id}.actualQuantity`]; + const wasteError = errors[`${row.id}.wasteQuantity`] ?? errors[`${row.id}.wasteReason`]; + const inputId = `count-${row.id}`; + return ( +
  • +
    +
    + +

    + {[ + row.location, + row.count + ? row.count.lastDate + ? t.count.lastCount(ymdLabel(row.count.lastDate)) + : t.count.neverCounted + : null, + ] + .filter(Boolean) + .join(" · ")} +

    +
    +
    +
    +

    {t.count.expected}

    + {formatStock(Math.max(0, row.expected), row.unit)} +
    +
    + + {t.count.actual} ({row.unit}) + + onChange({ actual: e.target.value })} + disabled={disabled} + aria-invalid={Boolean(actualError) || (entry.actual.trim() !== "" && actual === null)} + className="h-8 tabular-nums" + /> +
    +
    +

    {t.count.diff}

    + 0 + ? "text-income" + : diff < 0 + ? "text-expense" + : "text-muted-foreground", + )} + > + {diff === null ? "—" : signed(diff, row.unit)} + +
    +
    +
    + {actualError ?

    {actualError}

    : null} + {shortage > 0 ? ( +
    + {t.count.markWaste}: + onChange({ waste: e.target.value })} + disabled={disabled} + placeholder={t.count.wasteMax(formatStock(shortage, row.unit))} + aria-label={`${row.name}: ${t.count.wasteQuantity}`} + aria-invalid={Boolean(errors[`${row.id}.wasteQuantity`])} + className="h-8 w-36 tabular-nums" + /> + + {wasteError ?

    {wasteError}

    : null} +
    + ) : null} +
  • + ); +} diff --git a/src/features/stock/components/low-stock-card.tsx b/src/features/stock/components/low-stock-card.tsx index e00b39b..67cb422 100644 --- a/src/features/stock/components/low-stock-card.tsx +++ b/src/features/stock/components/low-stock-card.tsx @@ -1,4 +1,4 @@ -import { Clock } from "lucide-react"; +import { ClipboardCheck, Clock } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -9,7 +9,7 @@ import { getStock } from "../service"; const t = tr.stock; const LIMIT = 5; -/** Dashboard: eşiğin altındaki ürünler ve SKT'si yaklaşan partiler (yoksa görünmez). */ +/** Dashboard: eşiğin altındaki ürünler, SKT'si yaklaşan partiler, sayımı gelen kategoriler. */ export async function LowStockCard({ userId, householdId, @@ -18,7 +18,8 @@ export async function LowStockCard({ householdId: string; }) { const data = await getStock(userId, householdId); - if (!data || (!data.suggestions.length && !data.expiring.length)) return null; + if (!data || (!data.suggestions.length && !data.expiring.length && !data.countDue.length)) + return null; const low = data.suggestions.slice(0, LIMIT); const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2); return ( @@ -26,7 +27,11 @@ export async function LowStockCard({ {t.dashboard.title} - {t.dashboard.description(data.suggestions.length, data.expiring.length)} + {t.dashboard.description( + data.suggestions.length, + data.expiring.length, + data.countDue.length, + )} @@ -69,6 +74,23 @@ export async function LowStockCard({ ))} + {data.countDue.slice(0, 3).map((g) => ( +
  • + + {g.path} + + + + {t.count.dueBadge(g.overdueDays)} + +
  • + ))} {data.suggestions.length > low.length ? (

    diff --git a/src/features/stock/components/product-detail-view.tsx b/src/features/stock/components/product-detail-view.tsx index ce9a43e..a957398 100644 --- a/src/features/stock/components/product-detail-view.tsx +++ b/src/features/stock/components/product-detail-view.tsx @@ -37,7 +37,7 @@ import { } from "@/components/ui/dropdown-menu"; import { tr } from "@/i18n/tr"; import { cn } from "@/lib/utils"; -import { formatStock } from "@/shared/stock"; +import { formatStock, roundForDisplay } from "@/shared/stock"; import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions"; import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service"; import { @@ -46,7 +46,14 @@ import { StockMovementDialog, type MovementKindInput, } from "./stock-dialogs"; -import { ExpiryBadge, LevelBadge, QuickConsumeButton, StockBar, ymdLabel } from "./stock-view"; +import { + ExpiryBadge, + LevelBadge, + QuickConsumeButton, + StockBar, + suggestionReasons, + ymdLabel, +} from "./stock-view"; const t = tr.stock; @@ -156,6 +163,53 @@ export function ProductDetailView({ {q(data.suggestedQuantity)} + {suggestionReasons(data).length ? ( +

    + {suggestionReasons(data).join(" ")} +

    + ) : null} +
    + + {data.suggestion.usage ? ( + t.detail.perWeek(q(roundForDisplay(data.suggestion.usage.perDay * 7))) + ) : ( + {t.detail.noUsage} + )} + + + {data.count ? ( + + {t.detail.countEvery(data.count.everyDays)} + + {data.count.due + ? tr.stock.count.dueBadge(data.count.overdueDays) + : t.detail.nextCount(ymdLabel(data.count.nextDate))} + {data.count.lastDate + ? ` · ${tr.stock.count.lastCount(ymdLabel(data.count.lastDate))}` + : ""} + + + ) : ( + + {t.detail.noCountSchedule} + + )} + +
    {data.location ? (

    {t.detail.location}: {data.location} diff --git a/src/features/stock/components/stock-analysis-view.tsx b/src/features/stock/components/stock-analysis-view.tsx new file mode 100644 index 0000000..448ef5d --- /dev/null +++ b/src/features/stock/components/stock-analysis-view.tsx @@ -0,0 +1,181 @@ +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { PageHeader } from "@/components/page-header"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatStock } from "@/shared/stock"; +import type { StockAnalysis, StockAnalysisRow } from "../service"; + +const t = tr.stock.analysis; +/** Bu oranın (%) üstündeki fire vurgulanır. */ +const HIGH_WASTE_PCT = 15; + +const monthShort = (month: string) => + new Intl.DateTimeFormat("tr-TR", { month: "short" }).format(new Date(`${month}-15T12:00:00Z`)); +const monthLong = (month: string) => + new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" }).format( + new Date(`${month}-15T12:00:00Z`), + ); + +/** Ürünün kendi ölçeğinde son aylar: tüketim (alt) + fire (üst), taban çizgisine oturur. */ +function Trend({ row }: { row: StockAnalysisRow }) { + const max = Math.max(1, ...row.months.map((m) => m.used + m.wasted)); + return ( +

    + {row.months.map((m) => { + const total = m.used + m.wasted; + const label = t.monthValue( + monthLong(m.month), + formatStock(m.used, row.unit), + formatStock(m.wasted, row.unit), + ); + return ( +
    + {m.wasted > 0 ? ( +
    + ) : null} + {m.used > 0 ? ( +
    0 ? "" : "rounded-t-[3px]")} + style={{ height: `${(m.used / max) * 100}%`, minHeight: 2 }} + /> + ) : null} + {total === 0 ? {label} : null} +
    + ); + })} +
    + ); +} + +function Num({ value, unit, tone }: { value: number | null; unit: string; tone?: string }) { + return ( + + {value === null ? t.noData : formatStock(value, unit)} + + ); +} + +/** /stok/analiz: ürün tipi başına aylık ortalama tüketim, fire ve son ayların eğilimi. */ +export function StockAnalysisView({ data }: { data: StockAnalysis }) { + const groups = new Map(); + for (const r of data.rows) groups.set(r.categoryPath, [...(groups.get(r.categoryPath) ?? []), r]); + const first = data.months[0]!; + const last = data.months.at(-1)!; + + return ( +
    + + + + {data.rows.length === 0 ? ( +

    + {t.empty} +

    + ) : ( + <> +
    + + {t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)} + + + + {tr.stock.kinds.consume} + + + + {tr.stock.kinds.waste} + +
    + +
    +
    + {t.product} + {t.trend(data.months.length)} + {t.avgUsed} + {t.avgWasted} + {t.wastePct} + {t.thisMonth} +
    + {[...groups.entries()].map(([path, rows]) => ( +
    +

    + {path} +

    +
      + {rows.map((r) => { + const high = r.wastePct !== null && r.wastePct >= HIGH_WASTE_PCT; + return ( +
    • + + {r.name} + + +

      + + {t.avgUsed} + + +

      +

      + + {t.avgWasted} + + +

      +

      + + {t.wastePct} + + + {r.wastePct === null ? t.noData : `%${r.wastePct}`} + +

      +

      + + {t.thisMonth} + + +

      +
    • + ); + })} +
    +
    + ))} +
    + + )} +
    + ); +} diff --git a/src/features/stock/components/stock-view.tsx b/src/features/stock/components/stock-view.tsx index 52a9640..915f4ff 100644 --- a/src/features/stock/components/stock-view.tsx +++ b/src/features/stock/components/stock-view.tsx @@ -1,6 +1,15 @@ "use client"; -import { AlertTriangle, ChevronRight, Clock, FolderTree, Plus, Search } from "lucide-react"; +import { + AlertTriangle, + ChartColumn, + ChevronRight, + ClipboardCheck, + Clock, + FolderTree, + Plus, + Search, +} from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; @@ -111,6 +120,17 @@ export function QuickConsumeButton({ item }: { item: ProductTypeView }) { ); } +/** Önerilen miktarın gerekçesi: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */ +export function suggestionReasons(item: ProductTypeView): string[] { + const s = item.suggestion; + if (s.quantity === 0) return []; + const out: string[] = []; + if (s.waste) out.push(t.suggestions.wasteReduced(s.waste.purchases, s.waste.pct)); + if (s.roundedByUsage) out.push(t.suggestions.roundedByUsage(formatStock(s.roundTo, item.unit))); + if (s.coverDays !== null) out.push(t.suggestions.covers(s.coverDays)); + return out; +} + function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) { return (
    @@ -144,6 +164,18 @@ export function StockView({ data, options }: { data: Data; options: StockOption[ return (
    + + + ) : null} + + ))} + + + + ) : null} {data.expiring.length ? ( From 6cc698f70b70066849a91b5f69cc6a9d3a571d7a Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 10:58:12 +0300 Subject: [PATCH 3/3] chore(release): 0.11.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 35 +++++++++++++++++++++++------------ docs/PROMPT.md | 1 + package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab2316..e659bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +## [0.11.0] - 2026-11-06 + +Faz 11: ev stoğu (11a ve 11b alt fazları). + +### Eklendi — Faz 11b: periyodik sayım, fire ayarlı öneri, tüketim analizi + +- Sayım oturumları (`stockCounts`): her sayımda ürün başına beklenen, sayılan ve fireye ayrılan miktar; fark sıfır olsa da kayıt tutulur ve oluşan hareketler oturuma bağlanır (`countId`). Ürün detayındaki tek ürün sayımı da oturum oluşturur. +- Periyodik sayım ekranı (`/stok/sayim?kategori=…`): kategori (alt ağacıyla) veya tüm stok; liste hâlinde beklenen → sayılan, anlık fark; eksiğin bir kısmı nedenle fire olarak işaretlenebilir (fire, eksikten fazla olamaz), kalan fark sayım düzeltmesi olur. "Yalnızca sayımı gelenler", "Beklenenle doldur", tek transaction'da kayıt. +- Sayım takvimi: sıklık ürün tipinden, yoksa stok kategorisinden (üstten devralınır); son sayımdan (hiç sayılmadıysa ilk hareketten) n gün sonra sayım gelir. `/stok`'ta "Sayımı gelenler" kartı (kategori, ürün sayısı, gecikme) ve dashboard hatırlatması. +- Önerilen alım miktarı motoru: hedef − mevcut; son 3 alımda fire varsa (fireler FIFO ile hangi alımdan düştüyse ona yazılır, ≥ %5) fire oranı kadar düşürülür; haftalık tüketim adımdan büyükse haftalık tüketimin katına, değilse adıma yuvarlanır; hedefi aşmaz. Gerekçe gösterilir ("Son 2 alımda %30 fire, miktar azaltıldı.", "Haftalık tüketime (0,3 kg) göre yuvarlandı.", "Mevcutla birlikte yaklaşık 46 gün yeter."). +- Tüketim hızı (son 90 gün; tüketim + sayımda çıkan eksik, fire hariç) ve periyodik sayım durumu ürün detayında. +- Tüketim analizi (`/stok/analiz`): ürün tipi başına son 6 ayın tüketim/fire çubukları, aylık ortalama tüketim ve fire, fire oranı (≥ %15 vurgulu), bu ayın tüketimi. Maliyet ürün fiyatlarıyla (Faz 12) eklenecek. +- Migration `012-stock-counts`: index'ler; Faz 11a'da oturumsuz kaydedilen sayım düzeltmeleri, beklenen miktar yeniden hesaplanarak sayım oturumlarına bağlanır. +- Seed: Çakır Ailesi'nde Mutfak 15, Genel Temizlik 30 günde bir sayılır; sayım oturumları ve ara sıra sayım farkı; Mutfak'ın sayımı gecikmiş. +- Testler: FIFO fire dağılımı, son alımların fire oranı, öneri (fire indirimi, tüketim hızına yuvarlama, hedef sınırı), sayım takvimi, aylık kullanım (birim); seed sayım takvimi, toplu sayım (fire + düzeltme, oturum, sıfır fark, yetki), tüketim analizi (entegrasyon). + +### Değişti — Faz 11b + +- Dashboard stok kartı "Stok uyarıları" oldu: azalan ürünler, SKT yaklaşan partiler ve sayımı gelen kategoriler. +- Seed'deki sayım farkları artık periyodik sayım oturumlarında oluşur. + ### Eklendi — Faz 11a: ev stoğu - Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç; varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanede ve migration ile mevcut hanelere yüklenir. Her kategori bir gider kategorisine eşlenebilir (alt kategoriler üstten devralır; Faz 13'te alışveriş gideri için) ve sayım sıklığı tutar. Taşıma, arşiv (içinde aktif ürün yoksa) ve arşivden çıkarma; `/stok/kategoriler`. @@ -17,17 +38,6 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır - Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık alım/tüketim/fire/sayım simülasyonu. - Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, izolasyon, hareket kuralları, sayım, kategori kuralları (entegrasyon). -### Eklendi — Faz 11a: ev stoğu - -- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç (en fazla 5 seviye). Varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanelerde ve migration ile mevcut hanelerde kurulur. Her kategori bir gider kategorisine eşlenebilir (üstten devralınır; alışveriş tamamlama için), sayım sıklığı alanı (üstten devralınır). `/stok/kategoriler`: ekle, düzenle, taşı, arşivle (içinde aktif ürün varken arşivlenemez). -- Ürün tipleri (`productTypes`): kategori, temel birim (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik (%20 varsayılan), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki mevcut miktar. Arşivleme. -- Stok hareketleri (`stockMovements`): alım (isteğe bağlı son kullanma tarihi), tüketim, fire/zarar (neden ile), sayım düzeltmesi (gerçek miktar girilir, fark otomatik). Mevcut miktar hareketlerden türetilir; stoktan fazla tüketim/fire reddedilir; hareket silme denetim kaydına yazılır. -- SKT takibi FIFO: çıkışlar en eski alımdan düşülür; evde kalan partilerin SKT'si 7 gün içinde dolan veya geçmiş olanlar uyarılır. -- `/stok`: özet (ürün, azalan, SKT yaklaşan/geçmiş), "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT listesi, kategoriye göre ürünler (mevcut/hedef çubuğu ve eşik çizgisi), arama, "yalnızca azalanlar", tek dokunuşla "1 azalt". `/stok/:id`: durum, partiler, hareketler, alım/tüketim/fire/sayım. -- Dashboard "Azalan stoklar" kartı. -- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık tüketim, alım, fire ve sayım hareketleri. -- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, gider eşlemesi, izolasyon, hareket kuralları, sayım, arşiv ve kategori kuralları (entegrasyon). - ## [0.10.0] - 2026-11-06 Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları). @@ -340,7 +350,8 @@ Faz 1: Framework, klasör yapısı ve altyapı. - `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`. -[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...HEAD +[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.11.0...HEAD +[0.11.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...v0.11.0 [0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0 [0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0 [0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0 diff --git a/docs/PROMPT.md b/docs/PROMPT.md index d33d533..7c790de 100644 --- a/docs/PROMPT.md +++ b/docs/PROMPT.md @@ -322,6 +322,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em - **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir. - **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir. - **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer. +- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. --- diff --git a/package-lock.json b/package-lock.json index bd6ab8b..66f7b18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "butcem", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "butcem", - "version": "0.10.0", + "version": "0.11.0", "dependencies": { "@hookform/resolvers": "^7.0.2", "@tanstack/react-table": "^9.3.1", diff --git a/package.json b/package.json index c941aae..8ed1495 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.10.0", + "version": "0.11.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": {