From d9de29c18dcb1b91eca0abc60222350cabe90a86 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 08:10:10 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(stock):=20stok=20kategorileri,=20?= =?UTF-8?q?=C3=BCr=C3=BCn=20tipleri,=20hareketler=20ve=20FIFO=20SKT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/011-stock.ts | 20 + scripts/migrations/index.ts | 15 +- scripts/seed/run.int.test.ts | 2 + scripts/seed/run.ts | 8 + scripts/seed/stock.ts | 373 ++++++++++ src/features/households/service.ts | 3 + src/features/stock/actions.ts | 123 ++++ src/features/stock/defaults.ts | 45 ++ src/features/stock/service.int.test.ts | 307 ++++++++ src/features/stock/service.ts | 938 +++++++++++++++++++++++++ src/i18n/tr.ts | 192 +++++ src/models/audit-log.ts | 5 + src/models/product-type.ts | 43 ++ src/models/stock-category.ts | 37 + src/models/stock-movement.ts | 48 ++ src/shared/schemas/stock.ts | 120 ++++ src/shared/stock.test.ts | 101 +++ src/shared/stock.ts | 165 +++++ 18 files changed, 2544 insertions(+), 1 deletion(-) create mode 100644 scripts/migrations/011-stock.ts create mode 100644 scripts/seed/stock.ts create mode 100644 src/features/stock/actions.ts create mode 100644 src/features/stock/defaults.ts create mode 100644 src/features/stock/service.int.test.ts create mode 100644 src/features/stock/service.ts create mode 100644 src/models/product-type.ts create mode 100644 src/models/stock-category.ts create mode 100644 src/models/stock-movement.ts create mode 100644 src/shared/schemas/stock.ts create mode 100644 src/shared/stock.test.ts create mode 100644 src/shared/stock.ts diff --git a/scripts/migrations/011-stock.ts b/scripts/migrations/011-stock.ts new file mode 100644 index 0000000..26d455e --- /dev/null +++ b/scripts/migrations/011-stock.ts @@ -0,0 +1,20 @@ +import type { Types } from "mongoose"; +import { installDefaultStockCategories } from "@/features/stock/service"; +import { HouseholdModel } from "@/models/household"; +import { ProductTypeModel } from "@/models/product-type"; +import { StockCategoryModel } from "@/models/stock-category"; +import { StockMovementModel } from "@/models/stock-movement"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "011-stock", + description: "stok koleksiyonları index'leri; mevcut hanelere varsayılan stok kategorileri", + async up() { + await StockCategoryModel.createIndexes(); + await ProductTypeModel.createIndexes(); + await StockMovementModel.createIndexes(); + const households = await HouseholdModel.find({}).select({ createdBy: 1 }).lean(); + for (const h of households) + await installDefaultStockCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 8a1b043..0bd3225 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -10,10 +10,23 @@ import { migration as m007 } from "./007-contacts"; 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 type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ -export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010]; +export const MIGRATIONS: Migration[] = [ + m001, + m002, + m003, + m004, + m005, + m006, + m007, + m008, + m009, + m010, + m011, +]; const COLLECTION = "migrations"; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index a60c5d7..55d6f25 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -34,6 +34,7 @@ describe("seed", () => { expect(summary.created.transactions).toBeGreaterThan(150); expect(summary.created.assets).toBe(4); expect(summary.created.budgets).toBe(2); + expect(summary.created.stock).toBe(19); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); @@ -53,6 +54,7 @@ describe("seed", () => { inKind: 0, assets: 0, budgets: 0, + stock: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 165cbfa..633d0d9 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -13,6 +13,7 @@ import { runMigrations } from "../migrations"; import { seedContacts } from "./contacts"; import { seedAssets } from "./assets"; import { seedBudgets } from "./budgets"; +import { seedStock } from "./stock"; import { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; import { seedRecurring } from "./recurring"; @@ -55,6 +56,7 @@ export type SeedSummary = { inKind: number; assets: number; budgets: number; + stock: number; }; accounts: SeedAccountRow[]; }; @@ -78,6 +80,9 @@ const DOMAIN_COLLECTIONS = [ "assets", "assetMovements", "budgets", + "stockCategories", + "productTypes", + "stockMovements", ]; type AuthApi = ReturnType["api"]; @@ -133,6 +138,7 @@ export async function runSeed({ inKind: 0, assets: 0, budgets: 0, + stock: 0, }, accounts: [], }; @@ -315,6 +321,8 @@ export async function runSeed({ if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`); summary.created.budgets = await seedBudgets(householdIds, userIdMap); if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`); + summary.created.stock = await seedStock(householdIds, userIdMap); + if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`); // 7) Geliştirme hesapları tablosu const all = [ diff --git a/scripts/seed/stock.ts b/scripts/seed/stock.ts new file mode 100644 index 0000000..f585c64 --- /dev/null +++ b/scripts/seed/stock.ts @@ -0,0 +1,373 @@ +import { Types } from "mongoose"; +import { installDefaultStockCategories } from "@/features/stock/service"; +import { ProductTypeModel } from "@/models/product-type"; +import { StockCategoryModel } from "@/models/stock-category"; +import { StockMovementModel } from "@/models/stock-movement"; +import { todayIstanbul } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; +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. + */ + +type SeedProduct = { + ref: string; + category: string; + name: string; + unit: string; + target: number; + /** Alımda paket miktarı (alım = hedefe tamamlayan paket sayısı × paket). */ + pack: number; + perDay: number; + location: string; + step?: number; + threshold?: number; + shelfDays?: number; + /** Her n. alımdan sonra fire (oran %). */ + 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 }; +}; + +const K = 1000; + +export const PRODUCTS: SeedProduct[] = [ + // Mutfak › Bakliyat + { + ref: "kirmizi-mercimek", + category: "mutfak.bakliyat", + name: "Kırmızı Mercimek", + unit: "kg", + target: 2 * K, + pack: 1 * K, + perDay: 40, + location: "Kiler", + step: 100, + stopRefillDaysAgo: 40, + }, + { + ref: "kuru-fasulye", + category: "mutfak.bakliyat", + name: "Kuru Fasulye", + unit: "kg", + target: 2 * K, + pack: 1 * K, + perDay: 25, + location: "Kiler", + step: 250, + }, + { + ref: "nohut", + category: "mutfak.bakliyat", + name: "Nohut", + unit: "kg", + target: 2 * K, + pack: 1 * K, + perDay: 20, + location: "Kiler", + step: 250, + }, + { + ref: "pirinc", + category: "mutfak.bakliyat", + name: "Baldo Pirinç", + unit: "kg", + target: 5 * K, + pack: 2_500, + perDay: 90, + location: "Kiler", + step: 500, + }, + // Mutfak › Manav + { + ref: "domates", + category: "mutfak.manav", + name: "Domates", + unit: "kg", + target: 3 * K, + pack: 1 * K, + perDay: 300, + location: "Buzdolabı", + step: 500, + shelfDays: 8, + waste: { every: 2, pct: 30, reason: "spoiled" }, + }, + { + ref: "patates", + category: "mutfak.manav", + name: "Patates", + unit: "kg", + target: 5 * K, + pack: 1 * K, + perDay: 250, + location: "Kiler", + step: 500, + waste: { every: 4, pct: 10, reason: "spoiled" }, + }, + { + ref: "sogan", + category: "mutfak.manav", + name: "Kuru Soğan", + unit: "kg", + target: 3 * K, + pack: 1 * K, + perDay: 120, + location: "Kiler", + step: 500, + }, + // Mutfak › Şarküteri + { + ref: "beyaz-peynir", + category: "mutfak.sarkuteri", + name: "Beyaz Peynir", + unit: "kg", + target: 1 * K, + pack: 500, + perDay: 30, + location: "Buzdolabı", + step: 100, + shelfDays: 45, + }, + { + ref: "yumurta", + category: "mutfak.sarkuteri", + name: "Yumurta", + unit: "adet", + target: 30 * K, + pack: 15 * K, + perDay: 3 * K, + location: "Buzdolabı", + shelfDays: 25, + }, + { + ref: "sut", + category: "mutfak.sarkuteri", + name: "Süt", + unit: "lt", + target: 6 * K, + pack: 1 * K, + perDay: 700, + location: "Buzdolabı", + shelfDays: 12, + waste: { every: 6, pct: 15, reason: "expired" }, + }, + // Genel Temizlik + { + ref: "tablet-deterjan", + category: "genel-temizlik.bulasik", + name: "Tablet Deterjan", + unit: "tablet", + target: 60 * K, + pack: 40 * K, + perDay: 720, + location: "Lavabo altı", + stopRefillDaysAgo: 40, + }, + { + ref: "elde-yikama", + category: "genel-temizlik.bulasik", + name: "Elde Yıkama Deterjanı", + unit: "lt", + target: 1_500, + pack: 750, + perDay: 15, + location: "Lavabo altı", + step: 100, + }, + { + ref: "toz-deterjan", + category: "genel-temizlik.camasir", + name: "Toz Deterjan", + unit: "yıkama", + target: 80 * K, + pack: 50 * K, + perDay: 700, + location: "Banyo dolabı", + countDrift: { everyWeeks: 8, pct: 4 }, + }, + { + ref: "yumusatici", + category: "genel-temizlik.camasir", + name: "Yumuşatıcı", + unit: "lt", + target: 3 * K, + pack: 1_440, + perDay: 40, + location: "Banyo dolabı", + step: 100, + }, + { + ref: "camasir-suyu", + category: "genel-temizlik.genel", + name: "Çamaşır Suyu", + unit: "lt", + target: 3 * K, + pack: 1 * K, + perDay: 25, + location: "Balkon", + step: 250, + }, + { + ref: "tuvalet-kagidi", + category: "genel-temizlik.genel", + name: "Tuvalet Kağıdı", + unit: "rulo", + target: 32 * K, + pack: 16 * K, + perDay: 600, + location: "Banyo dolabı", + countDrift: { everyWeeks: 10, pct: 6 }, + }, + // Kişisel Temizlik + { + ref: "sampuan", + category: "kisisel-temizlik.sampuan", + name: "Şampuan", + unit: "adet", + target: 3 * K, + pack: 1 * K, + perDay: 50, + location: "Banyo dolabı", + }, + { + ref: "tiras-bicagi", + category: "kisisel-temizlik.tiras", + name: "Tıraş Bıçağı", + unit: "adet", + target: 8 * K, + pack: 4 * K, + perDay: 150, + location: "Banyo dolabı", + }, + { + ref: "dis-macunu", + category: "kisisel-temizlik.kisisel-bakim", + name: "Diş Macunu", + unit: "adet", + target: 4 * K, + pack: 2 * K, + perDay: 70, + location: "Banyo dolabı", + stopRefillDaysAgo: 60, + }, +]; + +const START_DAYS_AGO = 182; + +type Row = { + kind: "purchase" | "consume" | "waste" | "count_adjust"; + daysAgo: number; + quantity: number; + expiryDate?: string | null; + wasteReason?: string | null; + note?: string; + initial?: boolean; +}; + +/** 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[] { + const rows: Row[] = []; + 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)); + if (consume > 0) { + 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" }); + stock -= drift; + } + } + const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo; + if (canRefill && stock < (p.target * 35) / 100) { + const packs = Math.max(1, Math.ceil((p.target - stock) / p.pack)); + const quantity = packs * p.pack; + purchases++; + rows.push({ + kind: "purchase", + daysAgo: d, + quantity, + expiryDate: p.shelfDays ? addDays(addDays(today, -d), p.shelfDays) : null, + }); + stock += quantity; + if (p.waste && purchases % p.waste.every === 0 && d >= 3) { + const w = Math.min(stock, Math.round((quantity * p.waste.pct) / 100)); + rows.push({ kind: "waste", daysAgo: d - 3, quantity: w, wasteReason: p.waste.reason }); + stock -= w; + } + } + } + return rows; +} + +export async function seedStock( + householdIds: Map, + users: Map, +): Promise { + const actor = new Types.ObjectId(users.get("ayse")!); + for (const [, id] of householdIds) await installDefaultStockCategories(id, actor); + const householdId = householdIds.get("Çakır Ailesi")!; + const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } }) + .select({ defaultKey: 1 }) + .lean<{ _id: Types.ObjectId; defaultKey: string }[]>(); + const catByKey = new Map(cats.map((c) => [c.defaultKey, c._id])); + const today = todayIstanbul(); + let created = 0; + for (const p of PRODUCTS) { + if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue; + const doc = await ProductTypeModel.create({ + householdId, + categoryId: catByKey.get(p.category)!, + name: p.name, + nameKey: foldTr(p.name), + unit: p.unit, + targetQuantity: p.target, + thresholdPct: p.threshold ?? 20, + location: p.location, + step: p.step ?? K, + seedRef: p.ref, + createdBy: actor, + updatedBy: actor, + }); + const base = Date.now() - START_DAYS_AGO * 86_400_000; + // Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur. + await StockMovementModel.collection.insertMany( + simulate(p, today).map((r, i) => { + const at = new Date(base + i * 1000); + return { + householdId, + productTypeId: doc._id, + kind: r.kind, + date: addDays(today, -r.daysAgo), + quantity: r.quantity, + wasteReason: r.wasteReason ?? null, + expiryDate: r.expiryDate ?? null, + note: r.note ?? "", + initial: r.initial ?? false, + countId: null, + deletedAt: null, + seedRef: `${p.ref}:${i}`, + createdBy: actor, + createdAt: at, + updatedAt: at, + }; + }), + ); + created++; + } + return created; +} diff --git a/src/features/households/service.ts b/src/features/households/service.ts index cac42f2..4bd5fef 100644 --- a/src/features/households/service.ts +++ b/src/features/households/service.ts @@ -7,6 +7,7 @@ import { getDb } from "@/lib/db/mongo"; import { connectMongoose } from "@/lib/db/mongoose"; import { toObjectId } from "@/lib/object-id"; import { installDefaultCategories } from "@/features/categories/service"; +import { installDefaultStockCategories } from "@/features/stock/service"; import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import type { HouseholdSettings } from "@/shared/schemas/household"; @@ -324,6 +325,8 @@ export async function adminCreateHousehold( ); // Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2). await installDefaultCategories(household!._id, actor, session); + // Varsayılan stok kategorileri (gider eşlemesi yukarıdaki kategorilerden). + await installDefaultStockCategories(household!._id, actor, session); }); return ok({ id: createdId }); } diff --git a/src/features/stock/actions.ts b/src/features/stock/actions.ts new file mode 100644 index 0000000..ee652d5 --- /dev/null +++ b/src/features/stock/actions.ts @@ -0,0 +1,123 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { + productTypeFormSchema, + stockCategoryFormSchema, + stockCountFormSchema, + stockMovementFormSchema, +} from "@/shared/schemas/stock"; +import { + addStockMovement, + countStock, + createProductType, + createStockCategory, + deleteStockMovement, + getStockOptions, + quickConsume, + setProductTypeArchived, + setStockCategoryArchived, + updateProductType, + updateStockCategory, + type StockOption, +} from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = schema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await fn(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +function withId( + raw: unknown, + fn: (id: string) => Promise>, +): Promise> { + const id = idSchema.safeParse(raw); + if (!id.success) return Promise.resolve(fail(tr.common.notFound)); + return fn(id.data); +} + +/** Yeni (id boş) veya mevcut stok kategorisi. */ +export async function saveStockCategoryAction(categoryId: unknown, input: unknown) { + if (categoryId) + return withId(categoryId, (id) => + run(stockCategoryFormSchema, input, (u, h, d) => updateStockCategory(u, h, id, d)), + ); + return run(stockCategoryFormSchema, input, createStockCategory); +} + +export async function setStockCategoryArchivedAction(categoryId: unknown, archived: unknown) { + return withId(categoryId, (id) => + run(z.boolean(), archived, (u, h, a) => setStockCategoryArchived(u, h, id, a)), + ); +} + +/** Yeni (id boş) veya mevcut ürün tipi. */ +export async function saveProductTypeAction(productTypeId: unknown, input: unknown) { + if (productTypeId) + return withId(productTypeId, (id) => + run(productTypeFormSchema, input, (u, h, d) => updateProductType(u, h, id, d)), + ); + return run(productTypeFormSchema, input, createProductType); +} + +export async function setProductTypeArchivedAction(productTypeId: unknown, archived: unknown) { + return withId(productTypeId, (id) => + run(z.boolean(), archived, (u, h, a) => setProductTypeArchived(u, h, id, a)), + ); +} + +export async function addStockMovementAction(productTypeId: unknown, input: unknown) { + return withId(productTypeId, (id) => + run(stockMovementFormSchema, input, (u, h, d) => addStockMovement(u, h, id, d)), + ); +} + +export async function quickConsumeAction(productTypeId: unknown) { + return withId(productTypeId, (id) => run(z.unknown(), null, (u, h) => quickConsume(u, h, id))); +} + +export async function countStockAction(productTypeId: unknown, input: unknown) { + return withId(productTypeId, (id) => + run(stockCountFormSchema, input, (u, h, d) => countStock(u, h, id, d)), + ); +} + +export async function deleteStockMovementAction(movementId: unknown) { + return withId(movementId, (id) => + run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)), + ); +} + +/** Ürün tipi ve stok kategorisi formları için seçenekler. */ +export async function getStockOptionsAction(): Promise< + ActionResult<{ categories: StockOption[]; expenseCategories: StockOption[] }> +> { + const ctx = await getHouseholdActionContext("viewer"); + if (!ctx) return fail(tr.common.unauthorized); + const data = await getStockOptions(ctx.user.id, ctx.household.id); + return data ? ok(data) : fail(tr.common.unauthorized); +} diff --git a/src/features/stock/defaults.ts b/src/features/stock/defaults.ts new file mode 100644 index 0000000..3894d19 --- /dev/null +++ b/src/features/stock/defaults.ts @@ -0,0 +1,45 @@ +/** + * Varsayılan stok kategori seti (Bölüm 6.10). `key` sabittir (tekrar yüklemede kopya olmaz). + * `expenseKey`: eşlenen gider kategorisinin varsayılan anahtarı (Faz 13'te alışveriş giderleri). + * Parantez içindeki örnekler (Kırmızı Mercimek, Toz Deterjan …) ürün tipidir; birim ve hedef + * haneye göre değiştiği için otomatik eklenmez (seed örnek verisinde vardır). + */ +export type DefaultStockCategory = { + key: string; + name: string; + expenseKey?: string; + children?: Omit[]; +}; + +export const DEFAULT_STOCK_CATEGORIES: DefaultStockCategory[] = [ + { + key: "mutfak", + name: "Mutfak", + expenseKey: "market.gida", + children: [ + { key: "mutfak.manav", name: "Manav" }, + { key: "mutfak.sarkuteri", name: "Şarküteri" }, + { key: "mutfak.bakliyat", name: "Bakliyat" }, + ], + }, + { + key: "genel-temizlik", + name: "Genel Temizlik", + expenseKey: "market.temizlik", + children: [ + { key: "genel-temizlik.bulasik", name: "Bulaşık" }, + { key: "genel-temizlik.camasir", name: "Çamaşır" }, + { key: "genel-temizlik.genel", name: "Genel" }, + ], + }, + { + key: "kisisel-temizlik", + name: "Kişisel Temizlik", + expenseKey: "market.kisisel-bakim", + children: [ + { key: "kisisel-temizlik.sampuan", name: "Şampuan" }, + { key: "kisisel-temizlik.tiras", name: "Tıraş" }, + { key: "kisisel-temizlik.kisisel-bakim", name: "Kişisel Bakım" }, + ], + }, +]; diff --git a/src/features/stock/service.int.test.ts b/src/features/stock/service.int.test.ts new file mode 100644 index 0000000..1bbd66b --- /dev/null +++ b/src/features/stock/service.int.test.ts @@ -0,0 +1,307 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { tr } from "@/i18n/tr"; +import { AuditLogModel } from "@/models/audit-log"; +import { StockMovementModel } from "@/models/stock-movement"; +import { todayIstanbul } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; +import { + productTypeFormSchema, + stockCategoryFormSchema, + stockCountFormSchema, + stockMovementFormSchema, + type ProductTypeFormInput, +} from "@/shared/schemas/stock"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + addStockMovement, + countStock, + createProductType, + createStockCategory, + deleteStockMovement, + getProductType, + getStock, + getStockOptions, + listStockCategories, + quickConsume, + setProductTypeArchived, + setStockCategoryArchived, + updateStockCategory, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let zeynep = ""; +let fatma = ""; +const today = todayIstanbul(); + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + zeynep = ids.user("zeynep"); + fatma = ids.user("fatma"); +}); +afterAll(teardownDb); + +async function categoryId(path: string) { + const o = (await getStockOptions(ayse, cakir))!; + return o.categories.find((c) => c.path === path)!.id; +} + +function product(input: Partial & { categoryId: string }) { + return productTypeFormSchema.parse({ + name: "Test Ürün", + unit: "kg", + targetQuantity: "4", + thresholdPct: "20", + step: "0,5", + ...input, + }); +} + +describe("seed ve görünüm", () => { + it("varsayılan ağaç, gider eşlemesi devralınır; azalanlar ve SKT", async () => { + const s = (await getStock(ayse, cakir))!; + expect(s.items).toHaveLength(19); + const bakliyat = s.categories.find((c) => c.path === "Mutfak › Bakliyat")!; + expect(bakliyat.depth).toBe(1); + expect(bakliyat.expense).toEqual( + expect.objectContaining({ path: "Market › Gıda", inherited: true }), + ); + expect(s.categories.find((c) => c.path === "Mutfak")!.expense?.inherited).toBe(false); + // Bir süredir alınmayanlar eşiğin altında. + const low = s.suggestions.map((i) => i.name); + expect(low).toEqual(expect.arrayContaining(["Kırmızı Mercimek", "Tablet Deterjan"])); + for (const i of s.suggestions) { + expect(i.level.low).toBe(true); + expect(i.suggestedQuantity).toBe(i.targetQuantity - Math.max(0, i.current)); + } + // Hiçbir stok eksiye düşmez; süt partilerinin SKT'si yakın. + expect(s.items.every((i) => i.current >= 0)).toBe(true); + expect(s.expiring.some((e) => e.item.name === "Süt")).toBe(true); + // Kategori sırası: Mutfak ürünleri temizlikten önce. + expect(s.items.findIndex((i) => i.name === "Domates")).toBeLessThan( + s.items.findIndex((i) => i.name === "Toz Deterjan"), + ); + + const annemler = (await getStock(fatma, ids.household("Annemler")))!; + expect(annemler.items).toHaveLength(0); + expect(annemler.categories.length).toBe(12); + }); + + it("izolasyon: üye olmayan göremez, izleyici yazamaz", async () => { + expect(await getStock(fatma, cakir)).toBeNull(); + const s = (await getStock(zeynep, cakir))!; + expect(s.canWrite).toBe(false); + const any = s.items[0]!; + expect((await quickConsume(zeynep, cakir, any.id)).ok).toBe(false); + expect(await getProductType(fatma, cakir, any.id)).toBeNull(); + }); +}); + +describe("ürün tipi ve hareketler", () => { + it("başlangıç miktarı, tüketim sınırı, 1 azalt, fire, SKT FIFO, sayım, silme", async () => { + const cat = await categoryId("Mutfak › Bakliyat"); + const created = await createProductType( + ayse, + cakir, + product({ categoryId: cat, name: "Bulgur", initialQuantity: "1,5" }), + ); + expect(created.ok).toBe(true); + const id = created.ok ? created.data.id : ""; + let p = (await getProductType(ayse, cakir, id))!; + expect(p.current).toBe(1_500); + expect(p.movements[0]).toMatchObject({ kind: "count_adjust", initial: true, delta: 1_500 }); + // 1,5 < %20 × 4 = 0,8? hayır → azalmadı. + expect(p.level.low).toBe(false); + + const dup = await createProductType(ayse, cakir, product({ categoryId: cat, name: "bulgur" })); + expect(dup.ok).toBe(false); + + const tooMuch = await addStockMovement( + ayse, + cakir, + id, + stockMovementFormSchema.parse({ kind: "consume", date: today, quantity: "2" }), + ); + expect(tooMuch).toMatchObject({ ok: false, fieldErrors: { quantity: expect.any(String) } }); + + const q = await quickConsume(ayse, cakir, id); + expect(q).toEqual({ ok: true, data: { remaining: 1_000 } }); + + // Alım (SKT'li) sonra fire: fire en eski partiden (başlangıç) düşer. + await addStockMovement( + ayse, + cakir, + id, + stockMovementFormSchema.parse({ + kind: "purchase", + date: today, + quantity: "1", + expiryDate: addDays(today, 3), + }), + ); + expect( + stockMovementFormSchema.safeParse({ kind: "waste", date: today, quantity: "1" }).success, + ).toBe(false); + await addStockMovement( + ayse, + cakir, + id, + stockMovementFormSchema.parse({ + kind: "waste", + date: today, + quantity: "0,8", + wasteReason: "spoiled", + }), + ); + p = (await getProductType(ayse, cakir, id))!; + expect(p.current).toBe(1_200); + expect(p.lots).toEqual([ + expect.objectContaining({ remaining: 200, expiryDate: null }), + expect.objectContaining({ remaining: 1_000, expiryDate: addDays(today, 3), status: "soon" }), + ]); + expect(p.expiring).toHaveLength(1); + + // Sayım: 0,5 sayıldı → −0,7 düzeltme; aynı miktar tekrar → fark yok. + const c = await countStock( + ayse, + cakir, + id, + stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }), + ); + expect(c).toEqual({ ok: true, data: { diff: -700 } }); + const same = await countStock( + ayse, + cakir, + id, + stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }), + ); + expect(same).toEqual({ ok: true, data: { diff: 0 } }); + p = (await getProductType(ayse, cakir, id))!; + expect(p.current).toBe(500); + expect(p.level.low).toBe(true); + expect(p.suggestedQuantity).toBe(3_500); + + // Hareket silme: yeniden hesap + denetim kaydı. + const adj = p.movements[0]!; + expect(adj.kind).toBe("count_adjust"); + expect((await deleteStockMovement(ayse, cakir, adj.id)).ok).toBe(true); + expect((await getProductType(ayse, cakir, id))!.current).toBe(1_200); + expect( + await AuditLogModel.exists({ action: "stock.movement.delete", targetId: adj.id }), + ).toBeTruthy(); + expect(await StockMovementModel.exists({ _id: adj.id, deletedAt: null })).toBeNull(); + + // Arşivli üründe hareket yok; arşivden çıkınca devam. + expect((await setProductTypeArchived(ayse, cakir, id, true)).ok).toBe(true); + expect((await quickConsume(ayse, cakir, id)).ok).toBe(false); + expect((await getStock(ayse, cakir))!.items.some((i) => i.id === id)).toBe(false); + expect((await setProductTypeArchived(ayse, cakir, id, false)).ok).toBe(true); + }); + + it("boş stokta 1 azalt reddedilir; özel birim", async () => { + const cat = await categoryId("Genel Temizlik › Çamaşır"); + const r = await createProductType( + ayse, + cakir, + product({ categoryId: cat, name: "Leke Çıkarıcı", unit: "yıkama", step: "1" }), + ); + const id = r.ok ? r.data.id : ""; + expect(await quickConsume(ayse, cakir, id)).toEqual({ + ok: false, + error: tr.stock.validation.emptyStock, + }); + expect((await getProductType(ayse, cakir, id))!.unit).toBe("yıkama"); + }); +}); + +describe("stok kategorileri", () => { + it("oluştur, gider eşle, taşıma kuralları, arşiv koşulu", async () => { + const opts = (await getStockOptions(ayse, cakir))!; + const market = opts.expenseCategories.find((c) => c.path === "Market")!; + const parent = await createStockCategory( + ayse, + cakir, + stockCategoryFormSchema.parse({ + name: "Bebek", + parentId: "", + expenseCategoryId: market.id, + countEveryDays: "30", + }), + ); + const parentId = parent.ok ? parent.data.id : ""; + const child = await createStockCategory( + ayse, + cakir, + stockCategoryFormSchema.parse({ + name: "Bez", + parentId, + expenseCategoryId: "", + countEveryDays: "", + }), + ); + const childId = child.ok ? child.data.id : ""; + let list = (await listStockCategories(ayse, cakir))!.categories; + const bez = list.find((c) => c.id === childId)!; + expect(bez).toMatchObject({ + path: "Bebek › Bez", + effectiveCountDays: 30, + expense: expect.objectContaining({ path: "Market", inherited: true }), + }); + + // Kendi altına taşınamaz; aynı adlı kardeş olamaz. + const loop = await updateStockCategory( + ayse, + cakir, + parentId, + stockCategoryFormSchema.parse({ + name: "Bebek", + parentId: childId, + expenseCategoryId: "", + countEveryDays: "", + }), + ); + expect(loop.ok).toBe(false); + const dup = await createStockCategory( + ayse, + cakir, + stockCategoryFormSchema.parse({ + name: "mutfak", + parentId: "", + expenseCategoryId: "", + countEveryDays: "", + }), + ); + expect(dup.ok).toBe(false); + + // Gelir kategorisi eşlenemez. + const income = await createStockCategory(ayse, cakir, { + name: "Yanlış", + parentId: null, + expenseCategoryId: "0".repeat(24), + countEveryDays: null, + }); + expect(income.ok).toBe(false); + + // Ürün varken arşivlenemez; ürün yokken alt ağaçla arşivlenir. + const r = await createProductType( + ayse, + cakir, + product({ categoryId: childId, name: "Islak Mendil" }), + ); + expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(false); + await setProductTypeArchived(ayse, cakir, r.ok ? r.data.id : "", true); + expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(true); + list = (await listStockCategories(ayse, cakir))!.categories; + expect(list.find((c) => c.id === childId)!.archived).toBe(true); + expect((await getStockOptions(ayse, cakir))!.categories.some((c) => c.id === childId)).toBe( + false, + ); + // Arşivli kategoriye ürün eklenemez. + expect( + (await createProductType(ayse, cakir, product({ categoryId: childId, name: "Pudra" }))).ok, + ).toBe(false); + }); +}); diff --git a/src/features/stock/service.ts b/src/features/stock/service.ts new file mode 100644 index 0000000..f23e57a --- /dev/null +++ b/src/features/stock/service.ts @@ -0,0 +1,938 @@ +import "server-only"; +import { Types, type ClientSession } from "mongoose"; +import { + ancestorIds, + buildTree, + checkCreate, + checkMove, + descendantIds, + toOptions, + type FlatCategory, + type MoveViolation, +} from "@/features/categories/tree"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { resolveMembership, type Membership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { toObjectId } from "@/lib/object-id"; +import { CategoryModel } from "@/models/category"; +import { ProductTypeModel } from "@/models/product-type"; +import { StockCategoryModel } from "@/models/stock-category"; +import { StockMovementModel } from "@/models/stock-movement"; +import { todayIstanbul } from "@/shared/dates"; +import type { + ProductTypeForm, + StockCategoryForm, + StockCountForm, + StockMovementForm, +} from "@/shared/schemas/stock"; +import { compareTr, foldTr } from "@/shared/search"; +import { + currentQuantity, + expiringLots, + expiryStatus, + fifoLots, + formatStock, + refillQuantity, + signedDelta, + stockLevel, + type ExpiringLot, + type ExpiryStatus, + type StockLevel, + type StockMovementKind, + type WasteReason, +} from "@/shared/stock"; +import { DEFAULT_STOCK_CATEGORIES } from "./defaults"; + +/** + * Ev stoğu (Bölüm 6.10). Okuma: viewer; yazma: member. Stok hane ortaktır (gizlilik yok). + * Mevcut miktar hareketlerden türetilir; SKT uyarıları FIFO partilerinden. + */ + +const t = tr.stock; + +type CatLean = { + _id: Types.ObjectId; + name: string; + parentId: Types.ObjectId | null; + ancestors: Types.ObjectId[]; + order: number; + archivedAt: Date | null; + expenseCategoryId: Types.ObjectId | null; + countEveryDays: number | null; + defaultKey: string | null; +}; + +type ProductLean = { + _id: Types.ObjectId; + categoryId: Types.ObjectId; + name: string; + unit: string; + targetQuantity: number; + thresholdPct: number; + location: string; + step: number; + countEveryDays: number | null; + note: string; + archivedAt: Date | null; +}; + +type MoveLean = { + _id: Types.ObjectId; + productTypeId: Types.ObjectId; + kind: StockMovementKind; + date: string; + quantity: number; + wasteReason: WasteReason | null; + expiryDate: string | null; + note: string; + initial: boolean; + createdAt: Date; +}; + +export type StockCategoryView = { + id: string; + name: string; + parentId: string | null; + depth: number; + path: string; + archived: boolean; + isDefault: boolean; + expenseCategoryId: string | null; + /** Etkin gider kategorisi (kendisi veya üstten devralınan). */ + expense: { id: string; path: string; inherited: boolean } | null; + countEveryDays: number | null; + effectiveCountDays: number | null; + productCount: number; +}; + +export type ProductTypeView = { + id: string; + name: string; + categoryId: string; + categoryPath: string; + unit: string; + targetQuantity: number; + thresholdPct: number; + location: string; + step: number; + countEveryDays: number | null; + note: string; + archived: boolean; + current: number; + level: StockLevel; + suggestedQuantity: number; + expiring: ExpiringLot[]; + lastMovementDate: string | null; + movementCount: number; +}; + +export type StockView = { + categories: StockCategoryView[]; + items: ProductTypeView[]; + suggestions: ProductTypeView[]; + expiring: { item: ProductTypeView; lot: ExpiringLot }[]; + canWrite: boolean; + today: string; +}; + +export type StockMovementRow = { + id: string; + kind: StockMovementKind; + date: string; + /** İşaretli etki. */ + delta: number; + wasteReason: WasteReason | null; + expiryDate: string | null; + note: string; + initial: boolean; + quantityAfter: number; +}; + +export type LotRow = { + date: string; + expiryDate: string | null; + remaining: number; + status: ExpiryStatus | null; +}; + +export type ProductTypeDetail = ProductTypeView & { + movements: StockMovementRow[]; + lots: LotRow[]; + canWrite: boolean; +}; + +export type StockOption = { id: string; label: string; path: string; depth: number }; + +// --- Yükleme yardımcıları --- + +/** Ağaç fonksiyonları tür alanı ister; stok ağacı tek türlüdür. */ +const flat = (c: CatLean): FlatCategory => ({ + id: String(c._id), + kind: "expense", + name: c.name, + parentId: c.parentId ? String(c.parentId) : null, + order: c.order, + archived: Boolean(c.archivedAt), +}); + +const sortMoves = (a: MoveLean, b: MoveLean) => + a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1; + +async function loadCategories(householdId: Types.ObjectId, session?: ClientSession) { + return StockCategoryModel.find({ householdId }) + .session(session ?? null) + .lean(); +} + +async function loadMoves(householdId: Types.ObjectId, productIds: Types.ObjectId[]) { + const docs = await StockMovementModel.find({ + householdId, + productTypeId: { $in: productIds }, + deletedAt: null, + }).lean(); + const by = new Map(); + for (const m of docs) { + const list = by.get(String(m.productTypeId)) ?? []; + list.push(m); + by.set(String(m.productTypeId), list); + } + for (const list of by.values()) list.sort(sortMoves); + return by; +} + +/** Gider kategorisi yolları ("Market › Gıda"), arşivliler dahil. */ +async function expensePaths(householdId: Types.ObjectId) { + const rows = await CategoryModel.find({ householdId, kind: "expense" }).lean< + { + _id: Types.ObjectId; + kind: "expense"; + name: string; + parentId: Types.ObjectId | null; + order: number; + archivedAt: Date | null; + }[] + >(); + const items = rows.map((c) => ({ + id: String(c._id), + kind: c.kind, + name: c.name, + parentId: c.parentId ? String(c.parentId) : null, + order: c.order, + archived: Boolean(c.archivedAt), + })); + return new Map(toOptions(buildTree(items), { includeArchived: true }).map((o) => [o.id, o.path])); +} + +function categoryViews( + cats: CatLean[], + expense: Map, + productCounts: Map, + { includeArchived = false } = {}, +): StockCategoryView[] { + const items = cats.map(flat); + const byId = new Map(cats.map((c) => [String(c._id), c])); + const options = toOptions(buildTree(items), { includeArchived }); + return options.map((o) => { + const c = byId.get(o.id)!; + const chain = [...ancestorIds(items, o.id), o.id].map((id) => byId.get(id)!).reverse(); + const mapped = chain.find((x) => x.expenseCategoryId); + const days = chain.find((x) => x.countEveryDays)?.countEveryDays ?? null; + const expenseId = mapped?.expenseCategoryId ? String(mapped.expenseCategoryId) : null; + return { + id: o.id, + name: c.name, + parentId: c.parentId ? String(c.parentId) : null, + depth: o.depth, + path: o.path, + archived: Boolean(c.archivedAt), + isDefault: Boolean(c.defaultKey), + expenseCategoryId: c.expenseCategoryId ? String(c.expenseCategoryId) : null, + expense: + expenseId && expense.has(expenseId) + ? { id: expenseId, path: expense.get(expenseId)!, inherited: mapped !== c } + : null, + countEveryDays: c.countEveryDays, + effectiveCountDays: days, + productCount: productCounts.get(o.id) ?? 0, + }; + }); +} + +function toView( + p: ProductLean, + moves: MoveLean[], + categoryPath: string, + today: string, +): ProductTypeView { + const current = currentQuantity(moves); + const level = stockLevel(current, p.targetQuantity, p.thresholdPct); + return { + id: String(p._id), + name: p.name, + categoryId: String(p.categoryId), + categoryPath, + unit: p.unit, + targetQuantity: p.targetQuantity, + thresholdPct: p.thresholdPct, + location: p.location, + step: p.step, + countEveryDays: p.countEveryDays, + note: p.note, + archived: Boolean(p.archivedAt), + current, + level, + suggestedQuantity: refillQuantity(current, p.targetQuantity), + expiring: expiringLots(fifoLots(moves), today), + lastMovementDate: moves.at(-1)?.date ?? null, + movementCount: moves.length, + }; +} + +// --- Okuma --- + +export async function getStock(userId: string, householdId: string): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const today = todayIstanbul(); + const [cats, products] = await Promise.all([ + loadCategories(m.householdId), + ProductTypeModel.find({ householdId: m.householdId, archivedAt: null }).lean(), + ]); + const counts = new Map(); + for (const p of products) + counts.set(String(p.categoryId), (counts.get(String(p.categoryId)) ?? 0) + 1); + const categories = categoryViews(cats, await expensePaths(m.householdId), counts, { + includeArchived: true, + }); + const order = new Map(categories.map((c, i) => [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 items = products + .map((p) => + toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today), + ) + .sort( + (a, b) => + (order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) || + compareTr(a.name, b.name), + ); + const suggestions = items + .filter((i) => i.level.low) + .sort((a, b) => (a.level.pct ?? 0) - (b.level.pct ?? 0) || compareTr(a.name, b.name)); + const expiring = items + .flatMap((item) => item.expiring.map((lot) => ({ item, lot }))) + .sort((a, b) => (a.lot.expiryDate < b.lot.expiryDate ? -1 : 1)); + return { + categories: categories.filter((c) => !c.archived), + items, + suggestions, + expiring, + canWrite: hasHouseholdRole(m.role, "member"), + today, + }; +} + +async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) { + const id = toObjectId(productTypeId); + if (!id) return null; + return ProductTypeModel.findOne({ _id: id, householdId: m.householdId }) + .session(session ?? null) + .lean(); +} + +export async function getProductType( + userId: string, + householdId: string, + productTypeId: string, +): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const p = await loadProduct(m, productTypeId); + 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)) ?? []; + let running = 0; + const rows = moves.map((mv): StockMovementRow => { + const delta = signedDelta(mv); + running += delta; + return { + id: String(mv._id), + kind: mv.kind, + date: mv.date, + delta, + wasteReason: mv.wasteReason, + expiryDate: mv.expiryDate, + note: mv.note, + initial: mv.initial, + quantityAfter: running, + }; + }); + return { + ...toView(p, moves, path, today), + movements: rows.reverse(), + lots: fifoLots(moves).map((l) => ({ + date: l.date, + expiryDate: l.expiryDate, + remaining: l.remaining, + status: l.expiryDate ? expiryStatus(l.expiryDate, today) : null, + })), + canWrite: hasHouseholdRole(m.role, "member"), + }; +} + +/** Form seçenekleri: aktif stok kategorileri ve gider kategorileri. */ +export async function getStockOptions( + userId: string, + householdId: string, +): Promise<{ categories: StockOption[]; expenseCategories: StockOption[] } | null> { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const cats = await loadCategories(m.householdId); + const expense = await CategoryModel.find({ + householdId: m.householdId, + kind: "expense", + archivedAt: null, + }).lean< + { _id: Types.ObjectId; name: string; parentId: Types.ObjectId | null; order: number }[] + >(); + const expenseOptions = toOptions( + buildTree( + expense.map((c) => ({ + id: String(c._id), + kind: "expense" as const, + name: c.name, + parentId: c.parentId ? String(c.parentId) : null, + order: c.order, + archived: false, + })), + ), + ); + const pick = (o: { id: string; label: string; path: string; depth: number }) => ({ + id: o.id, + label: o.label, + path: o.path, + depth: o.depth, + }); + return { + categories: toOptions(buildTree(cats.map(flat))).map(pick), + expenseCategories: expenseOptions.map(pick), + }; +} + +/** /stok/kategoriler: arşivliler dahil tüm stok kategorileri. */ +export async function listStockCategories( + userId: string, + householdId: string, +): Promise<{ categories: StockCategoryView[]; canWrite: boolean } | null> { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const [cats, products] = await Promise.all([ + loadCategories(m.householdId), + ProductTypeModel.find({ householdId: m.householdId, archivedAt: null }) + .select({ categoryId: 1 }) + .lean<{ categoryId: Types.ObjectId }[]>(), + ]); + const counts = new Map(); + for (const p of products) + counts.set(String(p.categoryId), (counts.get(String(p.categoryId)) ?? 0) + 1); + return { + categories: categoryViews(cats, await expensePaths(m.householdId), counts, { + includeArchived: true, + }), + canWrite: hasHouseholdRole(m.role, "member"), + }; +} + +// --- Varsayılan kategoriler --- + +/** + * Varsayılan stok kategorilerini yükler (idempotent, defaultKey'e göre). Gider eşlemesi + * hanenin varsayılan gider kategorilerinden (varsa). Eklenen kategori sayısını döner. + */ +export async function installDefaultStockCategories( + householdId: Types.ObjectId, + actor: Types.ObjectId, + session?: ClientSession, +): Promise { + await connectMongoose(); + const expense = await CategoryModel.find({ + householdId, + kind: "expense", + defaultKey: { $type: "string" }, + }) + .session(session ?? null) + .select({ defaultKey: 1 }) + .lean<{ _id: Types.ObjectId; defaultKey: string }[]>(); + const expenseByKey = new Map(expense.map((c) => [c.defaultKey, c._id])); + let inserted = 0; + const upsert = async ( + def: { key: string; name: string; expenseKey?: string }, + order: number, + parent: { _id: Types.ObjectId; ancestors: Types.ObjectId[] } | null, + ) => { + const res = await StockCategoryModel.findOneAndUpdate( + { householdId, defaultKey: def.key }, + { + $setOnInsert: { + householdId, + name: def.name, + nameKey: foldTr(def.name), + parentId: parent?._id ?? null, + ancestors: parent ? [...parent.ancestors, parent._id] : [], + order, + expenseCategoryId: def.expenseKey ? (expenseByKey.get(def.expenseKey) ?? null) : null, + defaultKey: def.key, + createdBy: actor, + updatedBy: actor, + }, + }, + { upsert: true, returnDocument: "after", includeResultMetadata: true, session }, + ); + if (!res.lastErrorObject?.updatedExisting) inserted++; + return res.value!; + }; + for (const [i, def] of DEFAULT_STOCK_CATEGORIES.entries()) { + const parent = await upsert(def, i, null); + for (const [j, child] of (def.children ?? []).entries()) + await upsert(child, j, { _id: parent._id, ancestors: parent.ancestors }); + } + return inserted; +} + +// --- Stok kategorisi yazma --- + +const violation = (v: MoveViolation) => tr.categories.violations[v]; + +function hasSiblingNamed(all: CatLean[], parentId: string | null, name: string, except?: string) { + const key = foldTr(name); + return all.some( + (c) => + !c.archivedAt && + String(c._id) !== except && + (c.parentId ? String(c.parentId) : null) === parentId && + foldTr(c.name) === key, + ); +} + +async function checkExpenseCategory(m: Membership, id: string | null) { + if (!id) return true; + return Boolean( + await CategoryModel.exists({ + _id: new Types.ObjectId(id), + householdId: m.householdId, + kind: "expense", + archivedAt: null, + }), + ); +} + +export async function createStockCategory( + actorId: string, + householdId: string, + input: StockCategoryForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const all = await loadCategories(m.householdId); + const v = checkCreate(all.map(flat), "expense", input.parentId); + if (v) return fail(violation(v), { parentId: violation(v) }); + if (hasSiblingNamed(all, input.parentId, input.name)) + return fail(t.categories.duplicate, { name: t.categories.duplicate }); + if (!(await checkExpenseCategory(m, input.expenseCategoryId))) + return fail(t.categories.expenseInvalid, { expenseCategoryId: t.categories.expenseInvalid }); + const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId)! : null; + const siblings = all.filter((c) => (c.parentId ? String(c.parentId) : null) === input.parentId); + const actor = new Types.ObjectId(actorId); + const doc = await StockCategoryModel.create({ + householdId: m.householdId, + name: input.name, + nameKey: foldTr(input.name), + parentId: parent?._id ?? null, + ancestors: parent ? [...parent.ancestors, parent._id] : [], + order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0, + expenseCategoryId: input.expenseCategoryId ? new Types.ObjectId(input.expenseCategoryId) : null, + countEveryDays: input.countEveryDays, + createdBy: actor, + updatedBy: actor, + }); + return ok({ id: String(doc._id) }); +} + +export async function updateStockCategory( + actorId: string, + householdId: string, + categoryId: string, + input: StockCategoryForm, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const all = await loadCategories(m.householdId); + const current = all.find((c) => String(c._id) === categoryId); + if (!current) return fail(tr.common.notFound); + const currentParent = current.parentId ? String(current.parentId) : null; + const moving = currentParent !== input.parentId; + if (moving) { + const v = checkMove(all.map(flat), categoryId, input.parentId); + if (v) return fail(violation(v), { parentId: violation(v) }); + } + if (hasSiblingNamed(all, input.parentId, input.name, categoryId)) + return fail(t.categories.duplicate, { name: t.categories.duplicate }); + if (!(await checkExpenseCategory(m, input.expenseCategoryId))) + return fail(t.categories.expenseInvalid, { expenseCategoryId: t.categories.expenseInvalid }); + + const actor = new Types.ObjectId(actorId); + const mongoose = await connectMongoose(); + await mongoose.connection.transaction(async (session) => { + const set: Record = { + name: input.name, + nameKey: foldTr(input.name), + expenseCategoryId: input.expenseCategoryId + ? new Types.ObjectId(input.expenseCategoryId) + : null, + countEveryDays: input.countEveryDays, + updatedBy: actor, + }; + if (moving) { + const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId)! : null; + const newAncestors = parent ? [...parent.ancestors, parent._id] : []; + const siblings = all.filter( + (c) => (c.parentId ? String(c.parentId) : null) === input.parentId, + ); + Object.assign(set, { + parentId: parent?._id ?? null, + ancestors: newAncestors, + order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0, + }); + for (const d of all.filter((c) => c.ancestors.some((a) => a.equals(current._id)))) { + const idx = d.ancestors.findIndex((a) => a.equals(current._id)); + await StockCategoryModel.updateOne( + { _id: d._id, householdId: m.householdId }, + { $set: { ancestors: [...newAncestors, current._id, ...d.ancestors.slice(idx + 1)] } }, + { session }, + ); + } + } + await StockCategoryModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: set }, + { session }, + ); + }); + return ok(undefined); +} + +/** Arşivler (alt ağaçla; içinde aktif ürün tipi yoksa) veya arşivden çıkarır (üstü aktifse). */ +export async function setStockCategoryArchived( + actorId: string, + householdId: string, + categoryId: string, + archived: boolean, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const all = await loadCategories(m.householdId); + const current = all.find((c) => String(c._id) === categoryId); + if (!current) return fail(tr.common.notFound); + const ids = [ + current._id, + ...descendantIds(all.map(flat), categoryId).map((id) => new Types.ObjectId(id)), + ]; + if (archived) { + if ( + await ProductTypeModel.exists({ + householdId: m.householdId, + categoryId: { $in: ids }, + archivedAt: null, + }) + ) + return fail(t.categories.hasProducts); + } else { + const parent = current.parentId ? all.find((c) => c._id.equals(current.parentId!)) : null; + if (parent?.archivedAt) return fail(tr.categories.violations.parentArchived); + const parentId = current.parentId ? String(current.parentId) : null; + if (hasSiblingNamed(all, parentId, current.name, categoryId)) + return fail(t.categories.duplicate); + } + const actor = new Types.ObjectId(actorId); + await StockCategoryModel.updateMany( + { _id: { $in: archived ? ids : [current._id] }, householdId: m.householdId }, + { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: archived ? "stock.category.archive" : "stock.category.unarchive", + actorId: actor, + householdId: m.householdId, + targetType: "stockCategory", + targetId: categoryId, + diff: { name: current.name, affected: archived ? ids.length : 1 }, + }); + return ok(undefined); +} + +// --- Ürün tipi yazma --- + +async function checkProductInput( + m: Membership, + input: ProductTypeForm, + exceptId?: Types.ObjectId, +): Promise { + const cat = await StockCategoryModel.exists({ + _id: new Types.ObjectId(input.categoryId), + householdId: m.householdId, + archivedAt: null, + }); + if (!cat) + return fail(t.validation.categoryArchived, { categoryId: t.validation.categoryArchived }); + const dup = await ProductTypeModel.exists({ + householdId: m.householdId, + nameKey: foldTr(input.name), + archivedAt: null, + ...(exceptId ? { _id: { $ne: exceptId } } : {}), + }); + if (dup) return fail(t.validation.duplicate, { name: t.validation.duplicate }); + return null; +} + +const productFields = (input: ProductTypeForm) => ({ + categoryId: new Types.ObjectId(input.categoryId), + name: input.name, + nameKey: foldTr(input.name), + unit: input.unit, + targetQuantity: input.targetQuantity, + thresholdPct: input.thresholdPct, + location: input.location, + step: input.step, + countEveryDays: input.countEveryDays, + note: input.note, +}); + +export async function createProductType( + actorId: string, + householdId: string, + input: ProductTypeForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const bad = await checkProductInput(m, input); + if (bad) return bad as ActionResult<{ id: string }>; + const actor = new Types.ObjectId(actorId); + const mongoose = await connectMongoose(); + let id = ""; + await mongoose.connection.transaction(async (session) => { + const [doc] = await ProductTypeModel.create( + [{ householdId: m.householdId, ...productFields(input), createdBy: actor, updatedBy: actor }], + { session }, + ); + id = String(doc!._id); + if (input.initialQuantity) + await StockMovementModel.create( + [ + { + householdId: m.householdId, + productTypeId: doc!._id, + kind: "count_adjust", + date: todayIstanbul(), + quantity: input.initialQuantity, + initial: true, + createdBy: actor, + }, + ], + { session }, + ); + }); + return ok({ id }); +} + +export async function updateProductType( + actorId: string, + householdId: string, + productTypeId: string, + input: ProductTypeForm, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const p = await loadProduct(m, productTypeId); + if (!p) return fail(tr.common.notFound); + const bad = await checkProductInput(m, input, p._id); + if (bad) return bad; + await ProductTypeModel.updateOne( + { _id: p._id, householdId: m.householdId }, + { $set: { ...productFields(input), updatedBy: new Types.ObjectId(actorId) } }, + ); + return ok(undefined); +} + +export async function setProductTypeArchived( + actorId: string, + householdId: string, + productTypeId: string, + archived: boolean, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const p = await loadProduct(m, productTypeId); + if (!p) return fail(tr.common.notFound); + if (!archived) { + const dup = await ProductTypeModel.exists({ + householdId: m.householdId, + nameKey: foldTr(p.name), + archivedAt: null, + _id: { $ne: p._id }, + }); + if (dup) return fail(t.validation.duplicate); + const cat = await StockCategoryModel.exists({ _id: p.categoryId, archivedAt: null }); + if (!cat) return fail(t.validation.categoryArchived); + } + const actor = new Types.ObjectId(actorId); + await ProductTypeModel.updateOne( + { _id: p._id, householdId: m.householdId }, + { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: archived ? "stock.product.archive" : "stock.product.unarchive", + actorId: actor, + householdId: m.householdId, + targetType: "productType", + targetId: productTypeId, + diff: { name: p.name }, + }); + return ok(undefined); +} + +// --- Hareketler --- + +async function activeProductWithStock( + m: Membership, + productTypeId: string, + upTo?: string, +): Promise<{ error: string } | { error: null; p: ProductLean; current: number }> { + const p = await loadProduct(m, productTypeId); + if (!p) return { error: tr.common.notFound }; + if (p.archivedAt) return { error: t.validation.archived }; + const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? []; + const current = currentQuantity(upTo ? moves.filter((x) => x.date <= upTo) : moves); + return { error: null, p, current }; +} + +export async function addStockMovement( + actorId: string, + householdId: string, + productTypeId: string, + input: StockMovementForm, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const r = await activeProductWithStock(m, productTypeId); + if (r.error !== null) return fail(r.error); + if (input.kind !== "purchase" && input.quantity > Math.max(0, r.current)) { + const msg = t.validation.notEnough(formatStock(Math.max(0, r.current), r.p.unit)); + return fail(msg, { quantity: msg }); + } + await StockMovementModel.create({ + householdId: m.householdId, + productTypeId: r.p._id, + kind: input.kind, + date: input.date, + quantity: input.quantity, + wasteReason: input.wasteReason, + expiryDate: input.expiryDate, + note: input.note, + createdBy: new Types.ObjectId(actorId), + }); + return ok(undefined); +} + +/** "1 azalt": adım kadar (stok daha azsa kalanı) bugün tüketim. Kalan miktarı döner. */ +export async function quickConsume( + actorId: string, + householdId: string, + productTypeId: string, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const r = await activeProductWithStock(m, productTypeId); + if (r.error !== null) return fail(r.error); + if (r.current <= 0) return fail(t.validation.emptyStock); + const quantity = Math.min(r.p.step, r.current); + await StockMovementModel.create({ + householdId: m.householdId, + productTypeId: r.p._id, + kind: "consume", + date: todayIstanbul(), + quantity, + createdBy: new Types.ObjectId(actorId), + }); + return ok({ remaining: r.current - quantity }); +} + +/** + * 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. + */ +export async function countStock( + actorId: string, + householdId: string, + 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 }); +} + +export async function deleteStockMovement( + actorId: string, + householdId: string, + movementId: string, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(movementId); + if (!id) return fail(tr.common.notFound); + const mv = await StockMovementModel.findOne({ + _id: id, + householdId: m.householdId, + deletedAt: null, + }).lean(); + if (!mv) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + await StockMovementModel.updateOne({ _id: mv._id }, { $set: { deletedAt: new Date() } }); + await writeAudit({ + action: "stock.movement.delete", + actorId: actor, + householdId: m.householdId, + targetType: "stockMovement", + targetId: movementId, + diff: { + productTypeId: String(mv.productTypeId), + kind: mv.kind, + date: mv.date, + quantity: mv.quantity, + }, + }); + return ok(undefined); +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index b5465dd..b5be36b 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -929,6 +929,198 @@ export const tr = { category: "Kategori seçin", }, }, + stock: { + title: "Ev stoğu", + description: + "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", + readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.", + summary: { + products: "Ürün tipi", + low: "Azalan", + expiring: "SKT yaklaşan", + expired: "SKT geçmiş", + }, + suggestions: { + title: "Alınması önerilenler", + 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}`, + }, + expiring: { + title: "Son kullanma tarihi", + description: "Evde kalan miktarı olan ve SKT'si 7 gün içinde dolan veya geçmiş partiler.", + expired: "Geçti", + soon: "Yaklaşıyor", + on: (date: string) => `SKT ${date}`, + }, + list: { + empty: "Henüz ürün tipi yok. “Yeni ürün tipi” ile evdeki ürünleri ekleyin.", + emptyCategory: "Bu kategoride ürün yok.", + search: "Ürün ara", + onlyLow: "Yalnızca azalanlar", + noMatch: "Aramaya uyan ürün yok.", + lowBadge: "Azaldı", + outBadge: "Bitti", + of: (current: string, target: string) => `${current} / ${target}`, + quickConsume: (name: string) => `${name}: 1 azalt`, + quickDone: (name: string, rest: string) => `${name} azaltıldı, kalan ${rest}.`, + }, + fields: { + name: "Ad", + namePlaceholder: "ör. Toz Deterjan, Kırmızı Mercimek", + category: "Stok kategorisi", + unit: "Temel ölçü birimi", + customUnit: "Özel birim", + customUnitOption: "Özel birim…", + customUnitPlaceholder: "ör. yıkama, tablet", + target: "Hedef (tam) stok", + targetHint: "Evde bulunmasını istediğiniz tam miktar.", + thresholdPct: "Kritik eşik (%)", + thresholdHint: "Mevcut miktar hedefin bu yüzdesinin altına düşerse ürün önerilir.", + location: "Saklama yeri", + locationPlaceholder: "Kiler, banyo dolabı…", + step: "“1 azalt” miktarı", + stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.", + countEveryDays: "Sayım sıklığı (gün)", + countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.", + note: "Not", + initialQuantity: "Evdeki mevcut miktar", + initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.", + date: "Tarih", + quantity: "Miktar", + wasteReason: "Fire nedeni", + expiryDate: "Son kullanma tarihi", + expiryHint: "İsteğe bağlı. Yaklaşınca uyarı verilir.", + actualQuantity: "Sayılan (gerçek) miktar", + expected: (q: string) => `Kayıtlara göre beklenen: ${q}`, + }, + createTitle: "Yeni ürün tipi", + editTitle: "Ürün tipini düzenle", + kinds: { + purchase: "Alım", + consume: "Tüketim", + waste: "Fire / zarar", + count_adjust: "Sayım düzeltmesi", + }, + actions: { + purchase: "Alım ekle", + consume: "Tüketim gir", + waste: "Fire gir", + count: "Sayım yap", + }, + movementTitles: { + purchase: "Alım ekle", + consume: "Tüketim gir", + waste: "Fire / zarar gir", + }, + countTitle: "Sayım yap", + countDescription: + "Evdeki gerçek miktarı girin; kayıtlarla arasındaki fark sayım düzeltmesi olarak işlenir.", + wasteReasons: { + spoiled: "Bozuldu", + expired: "Son kullanma tarihi geçti", + damaged: "Hasar / döküldü", + other: "Diğer", + }, + initial: "Başlangıç miktarı", + detail: { + back: "Ev stoğuna dön", + current: "Mevcut", + target: "Hedef", + threshold: "Eşik", + suggested: "Önerilen alım", + location: "Saklama yeri", + lots: "Evdeki partiler", + lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).", + noLots: "Evde bu üründen yok.", + noExpiry: "SKT yok", + movements: "Hareketler", + noMovements: "Henüz hareket yok.", + after: (q: string) => `Sonra: ${q}`, + archive: "Arşivle", + unarchive: "Arşivden çıkar", + archived: "Arşivli", + archiveTitle: "Ürün tipi arşivlensin mi?", + archiveDescription: (name: string) => + `“${name}” stok listesinde ve önerilerde görünmez. Hareketleri silinmez; istediğinizde arşivden çıkarabilirsiniz.`, + deleteMovement: "Hareketi sil", + deleteMovementTitle: "Hareket silinsin mi?", + deleteMovementDescription: "Mevcut miktar bu hareket olmadan yeniden hesaplanır.", + }, + categories: { + title: "Stok kategorileri", + description: + "Stok kategorileri gider kategorilerinden ayrıdır. Her kategori bir gider kategorisine eşlenebilir; alışveriş tamamlanınca ürünler o gidere yazılır.", + back: "Ev stoğuna dön", + new: "Yeni kategori", + addChild: "Alt kategori ekle", + edit: "Düzenle", + archive: "Arşivle", + unarchive: "Arşivden çıkar", + showArchived: "Arşivi göster", + archived: "Arşivli", + defaultBadge: "Varsayılan", + empty: "Stok kategorisi yok.", + createTitle: "Yeni stok kategorisi", + editTitle: "Stok kategorisini düzenle", + name: "Ad", + parent: "Üst kategori", + noParent: "— Ana kategori —", + expenseCategory: "Gider kategorisi", + expenseInherit: "— Üstten devral —", + expenseHint: "Alışveriş tamamlanınca bu kategorideki ürünlerin yazılacağı gider kategorisi.", + inherited: (name: string) => `${name} (üstten)`, + noExpense: "Eşlenmedi", + countEveryDays: "Sayım sıklığı (gün)", + countEveryDaysHint: "Boş bırakılırsa üst kategoriden devralınır.", + everyDays: (n: number) => `${n} günde bir sayım`, + productCount: (n: number) => `${n} ürün`, + archiveTitle: "Stok kategorisi arşivlensin mi?", + archiveDescription: (name: string) => + `“${name}” ve alt kategorileri yeni ürünlerde seçilemez.`, + hasProducts: + "Bu kategoride veya altında aktif ürün tipi var; önce onları başka kategoriye taşıyın veya arşivleyin.", + duplicate: "Bu düzeyde aynı adlı bir kategori zaten var.", + expenseInvalid: "Geçerli bir gider kategorisi seçin.", + }, + toast: { + saved: "Kaydedildi.", + movementSaved: "Hareket kaydedildi.", + deleted: "Silindi.", + archived: "Arşivlendi.", + unarchived: "Arşivden çıkarıldı.", + counted: (diff: string) => `Sayım kaydedildi; fark ${diff}.`, + noChange: "Sayım kayıtlarla aynı; düzeltme gerekmedi.", + }, + validation: { + selectCategory: "Bir stok kategorisi seçin", + quantity: "Geçerli bir miktar girin (ör. 1,5)", + countDays: "1 ile 365 arasında gün sayısı girin", + unit: "Birim 1–20 karakter olmalı", + thresholdPct: "0 ile 100 arasında tam sayı girin", + wasteReason: "Fire nedenini seçin", + expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz", + notEnough: (q: string) => `Stokta yalnızca ${q} var.`, + 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) => + [ + low ? `${low} ürün eşiğin altında` : null, + expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null, + ] + .filter(Boolean) + .join(" · "), + more: (n: number) => `+${n} ürün daha`, + open: "Ev stoğuna git", + }, + }, prices: { title: "Fiyat tablosu", description: diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index 049f4e3..b9bdc76 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -35,6 +35,11 @@ export const AUDIT_ACTIONS = [ "inkind.delete", "asset.delete", "asset.movement.delete", + "stock.category.archive", + "stock.category.unarchive", + "stock.product.archive", + "stock.product.unarchive", + "stock.movement.delete", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/product-type.ts b/src/models/product-type.ts new file mode 100644 index 0000000..18bb305 --- /dev/null +++ b/src/models/product-type.ts @@ -0,0 +1,43 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; + +/** + * Ürün tipi (genel ürün, markadan bağımsız ihtiyaç: "Toz Deterjan"). Stok bu seviyede tutulur. + * Miktarlar binde bir (QUANTITY_SCALE). Birim hazır listeden ya da özel ("yıkama"). + */ +const productTypeSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + categoryId: { type: Schema.Types.ObjectId, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + nameKey: { type: String, required: true }, + unit: { type: String, required: true, trim: true, maxlength: 20 }, + /** Hedef (tam) stok miktarı. */ + targetQuantity: { type: Number, required: true, min: 0 }, + /** Kritik eşik, hedefin yüzdesi (varsayılan 20). */ + thresholdPct: { type: Number, default: 20, min: 0, max: 100 }, + /** Saklama yeri (kiler, banyo dolabı …). */ + location: { type: String, default: "", maxlength: 80 }, + /** "1 azalt" düğmesinin düşeceği miktar. */ + step: { type: Number, default: 1000, min: 1 }, + /** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */ + countEveryDays: { type: Number, default: null, min: 1, max: 365 }, + note: { type: String, default: "", maxlength: 300 }, + archivedAt: { type: Date, default: null }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "productTypes" }, +); + +productTypeSchema.index({ householdId: 1, archivedAt: 1, categoryId: 1 }); +productTypeSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type ProductType = InferSchemaType; + +export const ProductTypeModel: Model = + (models.ProductType as Model | undefined) ?? + model("ProductType", productTypeSchema); diff --git a/src/models/stock-category.ts b/src/models/stock-category.ts new file mode 100644 index 0000000..10e0164 --- /dev/null +++ b/src/models/stock-category.ts @@ -0,0 +1,37 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; + +/** + * Stok kategori ağacı (Bölüm 6.10), gelir/gider kategorilerinden ayrı. Silme yok; arşivleme. + * `expenseCategoryId`: alışveriş tamamlanınca (Faz 13) kalemlerin yazılacağı gider kategorisi; + * null ise üst kategoriden devralınır. `countEveryDays`: periyodik sayım sıklığı (Faz 11b); + * null ise üstten devralınır. + */ +const stockCategorySchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + nameKey: { type: String, required: true }, + parentId: { type: Schema.Types.ObjectId, default: null }, + ancestors: { type: [Schema.Types.ObjectId], default: [] }, + order: { type: Number, default: 0 }, + expenseCategoryId: { type: Schema.Types.ObjectId, default: null }, + countEveryDays: { type: Number, default: null, min: 1, max: 365 }, + archivedAt: { type: Date, default: null }, + defaultKey: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "stockCategories" }, +); + +stockCategorySchema.index({ householdId: 1, parentId: 1, order: 1 }); +stockCategorySchema.index( + { householdId: 1, defaultKey: 1 }, + { unique: true, partialFilterExpression: { defaultKey: { $type: "string" } } }, +); + +export type StockCategory = InferSchemaType; + +export const StockCategoryModel: Model = + (models.StockCategory as Model | undefined) ?? + model("StockCategory", stockCategorySchema); diff --git a/src/models/stock-movement.ts b/src/models/stock-movement.ts new file mode 100644 index 0000000..01ce86f --- /dev/null +++ b/src/models/stock-movement.ts @@ -0,0 +1,48 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { STOCK_MOVEMENT_KINDS, WASTE_REASONS } from "@/shared/stock"; + +/** + * Stok hareketi: alım, tüketim, fire (neden ile), sayım düzeltmesi. Mevcut miktar hareketlerden + * türetilir. Miktar binde bir; yalnızca sayım düzeltmesi işaretlidir. Sıra: tarih, oluşturulma. + */ +const stockMovementSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + productTypeId: { type: Schema.Types.ObjectId, required: true }, + kind: { type: String, enum: STOCK_MOVEMENT_KINDS, required: true }, + /** "YYYY-MM-DD" (İstanbul). */ + date: { type: String, required: true }, + quantity: { type: Number, required: true }, + wasteReason: { type: String, enum: [...WASTE_REASONS, null], default: null }, + /** Son kullanma tarihi (yalnızca alımda, isteğe bağlı). */ + expiryDate: { type: String, default: null }, + note: { type: String, default: "", maxlength: 300 }, + /** İlk kurulumda girilen başlangıç miktarı (sayım düzeltmesi olarak). */ + initial: { type: Boolean, default: false }, + /** Sayım oturumu (Faz 11b). */ + countId: { type: Schema.Types.ObjectId, default: null }, + deletedAt: { type: Date, default: null }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "stockMovements" }, +); + +stockMovementSchema.index({ + householdId: 1, + productTypeId: 1, + deletedAt: 1, + date: 1, + createdAt: 1, +}); +stockMovementSchema.index({ householdId: 1, kind: 1, date: -1 }); +stockMovementSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type StockMovement = InferSchemaType; + +export const StockMovementModel: Model = + (models.StockMovement as Model | undefined) ?? + model("StockMovement", stockMovementSchema); diff --git a/src/shared/schemas/stock.ts b/src/shared/schemas/stock.ts new file mode 100644 index 0000000..144be9f --- /dev/null +++ b/src/shared/schemas/stock.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { parseQuantity } from "../quantity"; +import { WASTE_REASONS } from "../stock"; + +const v = tr.validation; +const s = tr.stock.validation; +const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date); +const optionalId = z + .string() + .regex(/^([a-f0-9]{24})?$/i) + .transform((x) => (x === "" ? null : x)); +const requiredId = z.string().regex(/^[a-f0-9]{24}$/i, s.selectCategory); + +/** Miktar metni → binde bir tamsayı. `allowZero` sayım ve başlangıç için. */ +const quantity = (allowZero = false) => + z + .string() + .trim() + .transform((x, ctx) => { + const q = parseQuantity(x); + if (q === null || q < 0 || (!allowZero && q === 0)) { + ctx.addIssue({ code: "custom", message: s.quantity }); + return z.NEVER; + } + return q; + }); + +const optionalQuantity = z.preprocess( + (x) => (x === undefined || x === null || String(x).trim() === "" ? null : x), + quantity(true).nullable(), +); + +/** Boş → null; dolu → 1..365 gün. */ +const countEveryDays = z.preprocess( + (x) => (x === undefined || x === null || String(x).trim() === "" ? null : x), + z + .string() + .trim() + .transform((x, ctx) => { + const n = Number(x); + if (!/^\d+$/.test(x) || n < 1 || n > 365) { + ctx.addIssue({ code: "custom", message: s.countDays }); + return z.NEVER; + } + return n; + }) + .nullable(), +); + +export const stockCategoryFormSchema = z.object({ + name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax), + parentId: optionalId, + expenseCategoryId: optionalId, + countEveryDays, +}); +export type StockCategoryFormInput = z.input; +export type StockCategoryForm = z.output; + +export const productTypeFormSchema = z.object({ + name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax), + categoryId: requiredId, + unit: z.string().trim().min(1, s.unit).max(20, s.unit), + targetQuantity: quantity(), + thresholdPct: z + .string() + .trim() + .transform((x, ctx) => { + const n = Number(x); + if (!/^\d+$/.test(x) || n > 100) { + ctx.addIssue({ code: "custom", message: s.thresholdPct }); + return z.NEVER; + } + return n; + }), + location: z.string().trim().max(80).default(""), + step: quantity(), + countEveryDays, + note: z.string().trim().max(300).default(""), + /** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */ + initialQuantity: optionalQuantity.default(null), +}); +export type ProductTypeFormInput = z.input; +export type ProductTypeForm = z.output; + +/** Alım, tüketim veya fire. Sayım düzeltmesi ayrı formdan (gerçek miktar). */ +export const stockMovementFormSchema = z + .object({ + kind: z.enum(["purchase", "consume", "waste"]), + date: ymd, + quantity: quantity(), + wasteReason: z.preprocess( + (x) => (x === "" || x === undefined ? null : x), + z.enum(WASTE_REASONS).nullable(), + ), + expiryDate: z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()), + note: z.string().trim().max(300).default(""), + }) + .superRefine((x, ctx) => { + if (x.kind === "waste" && !x.wasteReason) + ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason }); + if (x.expiryDate && x.expiryDate < x.date) + ctx.addIssue({ code: "custom", path: ["expiryDate"], message: s.expiryBeforeDate }); + }) + .transform((x) => ({ + ...x, + wasteReason: x.kind === "waste" ? x.wasteReason : null, + expiryDate: x.kind === "purchase" ? x.expiryDate : null, + })); +export type StockMovementFormInput = z.input; +export type StockMovementForm = z.output; + +/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi olur. */ +export const stockCountFormSchema = z.object({ + date: ymd, + actualQuantity: quantity(true), + note: z.string().trim().max(300).default(""), +}); +export type StockCountFormInput = z.input; +export type StockCountForm = z.output; diff --git a/src/shared/stock.test.ts b/src/shared/stock.test.ts new file mode 100644 index 0000000..7e6c20f --- /dev/null +++ b/src/shared/stock.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + currentQuantity, + expiringLots, + expiryStatus, + fifoLots, + quantityInput, + refillQuantity, + stepLabel, + stockLevel, + type StockMove, +} from "./stock"; + +const m = ( + kind: StockMove["kind"], + date: string, + quantity: number, + expiryDate: string | null = null, +): StockMove => ({ kind, date, quantity, expiryDate }); + +describe("mevcut miktar", () => { + it("alım − tüketim − fire ± sayım", () => { + expect( + currentQuantity([ + m("purchase", "2026-10-01", 5_000), + m("consume", "2026-10-05", 1_500), + m("waste", "2026-10-06", 500), + m("count_adjust", "2026-10-10", -250), + m("count_adjust", "2026-10-11", 100), + ]), + ).toBe(2_850); + expect(currentQuantity([])).toBe(0); + }); +}); + +describe("FIFO partileri ve SKT", () => { + it("çıkışlar en eski partiden düşer", () => { + const lots = fifoLots([ + m("purchase", "2026-10-01", 2_000, "2026-11-10"), + m("purchase", "2026-10-20", 3_000, "2026-12-01"), + m("consume", "2026-10-25", 2_500), + m("count_adjust", "2026-10-26", 400), + ]); + expect(lots).toEqual([ + { index: 1, date: "2026-10-20", expiryDate: "2026-12-01", quantity: 3_000, remaining: 2_500 }, + { index: 3, date: "2026-10-26", expiryDate: null, quantity: 400, remaining: 400 }, + ]); + }); + + it("geçici eksi stok sonraki girişten kapatılır", () => { + const lots = fifoLots([ + m("consume", "2026-10-01", 1_000), + m("purchase", "2026-10-02", 3_000, "2026-10-30"), + ]); + expect(lots).toEqual([expect.objectContaining({ quantity: 3_000, remaining: 2_000 })]); + expect(fifoLots([m("purchase", "2026-10-01", 1_000), m("waste", "2026-10-02", 1_000)])).toEqual( + [], + ); + }); + + it("SKT durumu: geçmiş, 7 gün içinde, sorunsuz", () => { + expect(expiryStatus("2026-11-05", "2026-11-06")).toBe("expired"); + expect(expiryStatus("2026-11-06", "2026-11-06")).toBe("soon"); + expect(expiryStatus("2026-11-13", "2026-11-06")).toBe("soon"); + expect(expiryStatus("2026-11-14", "2026-11-06")).toBe("ok"); + const lots = fifoLots([ + m("purchase", "2026-10-01", 1_000, "2026-11-20"), + m("purchase", "2026-10-02", 1_000, "2026-11-08"), + m("purchase", "2026-10-03", 1_000, "2026-11-01"), + m("purchase", "2026-10-04", 1_000), + ]); + expect(expiringLots(lots, "2026-11-06").map((l) => [l.expiryDate, l.status])).toEqual([ + ["2026-11-01", "expired"], + ["2026-11-08", "soon"], + ]); + }); +}); + +describe("eşik ve öneri", () => { + it("%20 eşiğinin altı azalan sayılır", () => { + expect(stockLevel(1_000, 5_000, 20)).toEqual({ thresholdQuantity: 1_000, pct: 20, low: false }); + expect(stockLevel(999, 5_000, 20).low).toBe(true); + expect(stockLevel(-200, 5_000, 20)).toMatchObject({ pct: 0, low: true }); + expect(stockLevel(3_000, 10_000, 35)).toMatchObject({ thresholdQuantity: 3_500, low: true }); + expect(stockLevel(0, 0, 20)).toEqual({ thresholdQuantity: 0, pct: null, low: false }); + expect(refillQuantity(800, 5_000)).toBe(4_200); + expect(refillQuantity(-300, 5_000)).toBe(5_000); + expect(refillQuantity(6_000, 5_000)).toBe(0); + }); + + it("form metni gruplamasız ve geri okunabilir", () => { + expect(quantityInput(1_000_000)).toBe("1000"); + expect(quantityInput(1_500)).toBe("1,5"); + expect(quantityInput(250)).toBe("0,25"); + }); + + it("azaltma adımı etiketi", () => { + expect(stepLabel(1_000, "adet")).toBe("−1 adet"); + expect(stepLabel(500, "kg")).toBe("−0,5 kg"); + }); +}); diff --git a/src/shared/stock.ts b/src/shared/stock.ts new file mode 100644 index 0000000..f011d46 --- /dev/null +++ b/src/shared/stock.ts @@ -0,0 +1,165 @@ +import { daysBetween } from "./budget"; +import { formatQuantity, QUANTITY_SCALE } from "./quantity"; + +/** + * Ev stoğu motoru (Bölüm 6.10). Saf fonksiyonlar, framework'ten bağımsız. + * + * Miktarlar binde bir tamsayı (QUANTITY_SCALE). Mevcut miktar hareketlerden türetilir: + * alım (+) − tüketim − fire ± sayım düzeltmesi. + * SKT: alım hareketinde (isteğe bağlı). Çıkışlar (tüketim, fire, eksi sayım farkı) FIFO ile en + * eski girişten düşülür; evde kalan miktarın SKT'si uyarı için kullanılır. + */ + +export const STOCK_MOVEMENT_KINDS = ["purchase", "consume", "waste", "count_adjust"] as const; +export type StockMovementKind = (typeof STOCK_MOVEMENT_KINDS)[number]; + +export const WASTE_REASONS = ["spoiled", "expired", "damaged", "other"] as const; +export type WasteReason = (typeof WASTE_REASONS)[number]; + +/** Hazır birimler; bunların dışındaki her değer özel birimdir ("yıkama", "tablet"). */ +export const STOCK_UNITS = ["adet", "kg", "g", "lt", "ml", "paket"] as const; + +export const DEFAULT_THRESHOLD_PCT = 20; +/** SKT'ye bu kadar gün (dahil) kala uyarı. */ +export const EXPIRY_WARN_DAYS = 7; + +export type StockMove = { + kind: StockMovementKind; + /** "YYYY-MM-DD". */ + date: string; + /** Pozitif; yalnızca sayım düzeltmesi işaretli (+ fazla çıktı, − eksik çıktı). */ + quantity: number; + expiryDate?: string | null; +}; + +/** Hareketin stoka etkisi (işaretli). */ +export function signedDelta(m: Pick): number { + switch (m.kind) { + case "purchase": + return m.quantity; + case "consume": + case "waste": + return -m.quantity; + case "count_adjust": + return m.quantity; + } +} + +export function currentQuantity(moves: readonly Pick[]): number { + return moves.reduce((sum, m) => sum + signedDelta(m), 0); +} + +export type Lot = { + /** Girişin hareket listesindeki sırası. */ + index: number; + date: string; + expiryDate: string | null; + quantity: number; + 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[] = []; + let deficit = 0; + moves.forEach((m, index) => { + const delta = signedDelta(m); + if (delta > 0) { + const covered = Math.min(deficit, delta); + deficit -= covered; + lots.push({ + index, + date: m.date, + expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null, + quantity: delta, + remaining: delta - covered, + }); + return; + } + let out = -delta; + for (const lot of lots) { + if (out === 0) break; + const take = Math.min(lot.remaining, out); + lot.remaining -= take; + out -= take; + } + deficit += out; + }); + return lots.filter((l) => l.remaining > 0); +} + +export type ExpiryStatus = "expired" | "soon" | "ok"; + +export function expiryStatus( + expiryDate: string, + today: string, + warnDays = EXPIRY_WARN_DAYS, +): ExpiryStatus { + if (expiryDate < today) return "expired"; + // daysBetween dahil sayar: bugün = 1. + return daysBetween(today, expiryDate) - 1 <= warnDays ? "soon" : "ok"; +} + +export type ExpiringLot = Lot & { expiryDate: string; status: Exclude }; + +/** Evde kalan ve SKT'si geçmiş/yaklaşan partiler (en erken SKT önce). */ +export function expiringLots( + lots: readonly Lot[], + today: string, + warnDays = EXPIRY_WARN_DAYS, +): ExpiringLot[] { + const out: ExpiringLot[] = []; + for (const lot of lots) { + if (!lot.expiryDate) continue; + const status = expiryStatus(lot.expiryDate, today, warnDays); + if (status !== "ok") out.push({ ...lot, expiryDate: lot.expiryDate, status }); + } + return out.sort((a, b) => + a.expiryDate < b.expiryDate ? -1 : a.expiryDate > b.expiryDate ? 1 : 0, + ); +} + +export type StockLevel = { + /** Eşik miktarı: hedef × eşik yüzdesi. */ + thresholdQuantity: number; + /** Mevcut / hedef (yüzde, tamsayı; hedef yoksa null). */ + pct: number | null; + /** Eşiğin altında mı ("altına düşerse": kesin küçük). */ + low: boolean; +}; + +export function stockLevel(current: number, target: number, thresholdPct: number): StockLevel { + if (target <= 0) return { thresholdQuantity: 0, pct: null, low: false }; + const thresholdQuantity = Math.round((target * thresholdPct) / 100); + return { + thresholdQuantity, + pct: Math.round((Math.max(0, current) * 100) / target), + low: current < thresholdQuantity, + }; +} + +/** Basit öneri: hedefe tamamla (Faz 11b'de tüketim hızı ve fireye göre ayarlanır). */ +export function refillQuantity(current: number, target: number): number { + return Math.max(0, target - Math.max(0, current)); +} + +/** Miktar + birim: 1500, "kg" → "1,5 kg". */ +export function formatStock(quantity: number, unit: string): string { + return formatQuantity(quantity, unit); +} + +/** "1 azalt" düğmesi etiketi: adım 1 birim ise "−1 adet", değilse "−0,5 kg". */ +export function stepLabel(step: number, unit: string): string { + return `−${formatQuantity(step || QUANTITY_SCALE, 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); +} From 868bb23fd949ed0b3ba25276fc73bd7bf305dd9f Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 08:10:10 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(ui):=20ev=20sto=C4=9Fu=20sayfalar?= =?UTF-8?q?=C4=B1=20ve=20dashboard=20azalan=20stoklar=20kart=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 + src/app/(app)/dashboard/page.tsx | 4 + src/app/(app)/stok/[id]/page.tsx | 20 + src/app/(app)/stok/kategoriler/page.tsx | 29 + src/app/(app)/stok/page.tsx | 26 + src/components/layout/nav-config.ts | 4 +- .../stock/components/low-stock-card.tsx | 84 +++ .../stock/components/product-detail-view.tsx | 326 +++++++++++ .../components/stock-categories-manager.tsx | 353 ++++++++++++ .../stock/components/stock-dialogs.tsx | 523 ++++++++++++++++++ src/features/stock/components/stock-view.tsx | 315 +++++++++++ 11 files changed, 1704 insertions(+), 2 deletions(-) create mode 100644 src/app/(app)/stok/[id]/page.tsx create mode 100644 src/app/(app)/stok/kategoriler/page.tsx create mode 100644 src/app/(app)/stok/page.tsx create mode 100644 src/features/stock/components/low-stock-card.tsx create mode 100644 src/features/stock/components/product-detail-view.tsx create mode 100644 src/features/stock/components/stock-categories-manager.tsx create mode 100644 src/features/stock/components/stock-dialogs.tsx create mode 100644 src/features/stock/components/stock-view.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f39b0..2ab2316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +### 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`. +- Ürün tipleri (`productTypes`): kategori, temel ölçü birimi (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik yüzdesi (varsayılan %20), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki başlangıç miktarı. +- Stok hareketleri (`stockMovements`): alım (isteğe bağlı SKT), tüketim, fire/zarar (nedenli) ve sayım düzeltmesi (gerçek miktar girilir, fark işlenir). Mevcut miktar hareketlerden türetilir; stokta olandan fazla tüketim/fire reddedilir. Hareket silme denetim kaydına yazılır. +- SKT takibi FIFO partileriyle: çıkışlar en eski girişten düşer; evde kalan miktarın SKT'si 7 gün içinde dolan veya geçmiş partiler uyarılır. +- `/stok`: özet, "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT uyarıları, kategoriye göre ürün listesi (mevcut/hedef çubuğu ve eşik çizgisi, arama, "yalnızca azalanlar"), tek dokunuşla "1 azalt". `/stok/:id`: partiler ve hareket geçmişi. +- Dashboard "Azalan stoklar" kartı. +- 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ı). diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index b36d3cc..ee2af1c 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -11,6 +11,7 @@ import { getBudget } from "@/features/budgets/service"; import { ContactsDueBanner } from "@/features/contacts/components/due-banner"; import { getNetWorth } from "@/features/net-worth/service"; import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; +import { LowStockCard } from "@/features/stock/components/low-stock-card"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; import { cn } from "@/lib/utils"; @@ -105,6 +106,9 @@ export default async function DashboardPage() { + + + ); } diff --git a/src/app/(app)/stok/[id]/page.tsx b/src/app/(app)/stok/[id]/page.tsx new file mode 100644 index 0000000..dfcc8c5 --- /dev/null +++ b/src/app/(app)/stok/[id]/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { ProductDetailView } from "@/features/stock/components/product-detail-view"; +import { getProductType, getStockOptions } from "@/features/stock/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.stock.detail.movements }; + +/** /stok/:id — ürün tipi: mevcut durum, partiler ve hareketler (başka hanenin kaydı → 404). */ +export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) { + const { user, household } = await requireActiveHousehold("viewer"); + const { id } = await params; + const [data, options] = await Promise.all([ + getProductType(user.id, household.id, id), + getStockOptions(user.id, household.id), + ]); + if (!data || !options) notFound(); + return ; +} diff --git a/src/app/(app)/stok/kategoriler/page.tsx b/src/app/(app)/stok/kategoriler/page.tsx new file mode 100644 index 0000000..dd9b533 --- /dev/null +++ b/src/app/(app)/stok/kategoriler/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { StockCategoriesManager } from "@/features/stock/components/stock-categories-manager"; +import { getStockOptions, listStockCategories } from "@/features/stock/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.stock.categories.title }; + +/** /stok/kategoriler — stok kategori ağacı, gider eşlemesi ve sayım sıklığı. */ +export default async function StockCategoriesPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const [data, options] = await Promise.all([ + listStockCategories(user.id, household.id), + getStockOptions(user.id, household.id), + ]); + if (!data || !options) notFound(); + return ( + <> + + + + ); +} diff --git a/src/app/(app)/stok/page.tsx b/src/app/(app)/stok/page.tsx new file mode 100644 index 0000000..982f939 --- /dev/null +++ b/src/app/(app)/stok/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { StockView } from "@/features/stock/components/stock-view"; +import { getStock, getStockOptions } from "@/features/stock/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.stock.title }; + +/** /stok — ev stoğu: özet, alınması önerilenler, SKT uyarıları, kategoriye göre ürünler. */ +export default async function StockPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const [data, options] = await Promise.all([ + getStock(user.id, household.id), + getStockOptions(user.id, household.id), + ]); + if (!data || !options) notFound(); + return ( + <> + + {!data.canWrite ?

{tr.stock.readOnly}

: null} + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index d5f42f2..3b712d8 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 = 10.2; +export const CURRENT_PHASE = 11.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -40,7 +40,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 }, - { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, + { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 }, diff --git a/src/features/stock/components/low-stock-card.tsx b/src/features/stock/components/low-stock-card.tsx new file mode 100644 index 0000000..e00b39b --- /dev/null +++ b/src/features/stock/components/low-stock-card.tsx @@ -0,0 +1,84 @@ +import { 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"; +import { tr } from "@/i18n/tr"; +import { formatStock } from "@/shared/stock"; +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). */ +export async function LowStockCard({ + userId, + householdId, +}: { + userId: string; + householdId: string; +}) { + const data = await getStock(userId, householdId); + if (!data || (!data.suggestions.length && !data.expiring.length)) return null; + const low = data.suggestions.slice(0, LIMIT); + const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2); + return ( + + + {t.dashboard.title} + + {t.dashboard.description(data.suggestions.length, data.expiring.length)} + + + +
    + {low.map((i) => ( +
  • + + {i.name} + + + {formatStock(Math.max(0, i.current), i.unit)} + + {" · "} + {t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))} + + +
  • + ))} + {expiring.map(({ item, lot }) => ( +
  • + + {item.name} + + + + {t.expiring.on( + new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format( + new Date(`${lot.expiryDate}T12:00:00+03:00`), + ), + )} + +
  • + ))} +
+ {data.suggestions.length > low.length ? ( +

+ {t.dashboard.more(data.suggestions.length - low.length)} +

+ ) : null} + +
+
+ ); +} diff --git a/src/features/stock/components/product-detail-view.tsx b/src/features/stock/components/product-detail-view.tsx new file mode 100644 index 0000000..ce9a43e --- /dev/null +++ b/src/features/stock/components/product-detail-view.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { + Archive, + ArchiveRestore, + ArrowLeft, + ClipboardCheck, + EllipsisVertical, + Minus, + Pencil, + ShoppingBag, + Trash, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { PageHeader } from "@/components/page-header"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatStock } from "@/shared/stock"; +import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions"; +import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service"; +import { + ProductTypeFormDialog, + StockCountDialog, + StockMovementDialog, + type MovementKindInput, +} from "./stock-dialogs"; +import { ExpiryBadge, LevelBadge, QuickConsumeButton, StockBar, ymdLabel } from "./stock-view"; + +const t = tr.stock; + +type Pending = + | { kind: "edit" } + | { kind: "movement"; movement: MovementKindInput } + | { kind: "count" } + | { kind: "archive" } + | { kind: "deleteMovement"; row: StockMovementRow } + | null; + +function Stat({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

{label}

+
{children}
+
+ ); +} + +/** /stok/:id — mevcut durum, partiler (SKT) ve hareketler. */ +export function ProductDetailView({ + data, + categories, +}: { + data: ProductTypeDetail; + categories: StockOption[]; +}) { + const [pending, setPending] = useState(null); + const close = () => setPending(null); + const q = (x: number) => formatStock(x, data.unit); + const active = data.canWrite && !data.archived; + + return ( +
+ + + + + + + setPending({ kind: "edit" })} + > + + {tr.common.edit} + + + {data.archived ? ( + { + const r = await setProductTypeArchivedAction(data.id, false); + if (r.ok) toast.success(t.toast.unarchived); + else toast.error(r.error); + }} + > + + {t.detail.unarchive} + + ) : ( + setPending({ kind: "archive" })} + > + + {t.detail.archive} + + )} + + + ) : null + } + /> +
+ {data.archived ? {t.detail.archived} : null} + +
+ + + +
+ + + {q(Math.max(0, data.current))} + + + {q(data.targetQuantity)} + + {q(data.level.thresholdQuantity)}{" "} + + (%{data.thresholdPct}) + + + {q(data.suggestedQuantity)} +
+ + {data.location ? ( +

+ {t.detail.location}: {data.location} +

+ ) : null} + {data.note ?

{data.note}

: null} + {active ? ( +
+ + + + + +
+ ) : null} +
+
+ + + + {t.detail.lots} +

{t.detail.lotsHint}

+
+ + {data.lots.length === 0 ? ( +

{t.detail.noLots}

+ ) : ( +
    + {data.lots.map((lot, i) => ( +
  • + + {q(lot.remaining)} + · {ymdLabel(lot.date)} + + {lot.expiryDate && lot.status && lot.status !== "ok" ? ( + + ) : ( + + {lot.expiryDate ? t.expiring.on(ymdLabel(lot.expiryDate)) : t.detail.noExpiry} + + )} +
  • + ))} +
+ )} +
+
+ +
+

+ {t.detail.movements} +

+ {data.movements.length === 0 ? ( +

+ {t.detail.noMovements} +

+ ) : ( +
    + {data.movements.map((row) => ( +
  • +
    +

    + {row.initial ? t.initial : t.kinds[row.kind]} + 0 ? "text-income" : "text-expense")} + > + {row.delta > 0 ? "+" : "−"} + {q(Math.abs(row.delta))} + +

    +

    + {ymdLabel(row.date)} + {row.wasteReason ? ` · ${t.wasteReasons[row.wasteReason]}` : ""} + {row.expiryDate ? ` · ${t.expiring.on(ymdLabel(row.expiryDate))}` : ""} + {row.note ? ` · ${row.note}` : ""} +

    +
    + + {t.detail.after(q(row.quantityAfter))} + + {data.canWrite ? ( + + ) : null} +
  • + ))} +
+ )} +
+ + {pending?.kind === "edit" ? ( + + ) : null} + {pending?.kind === "movement" ? ( + + ) : null} + {pending?.kind === "count" ? : null} + !o && close()} + > + + + + {pending?.kind === "archive" ? t.detail.archiveTitle : t.detail.deleteMovementTitle} + + + {pending?.kind === "archive" + ? t.detail.archiveDescription(data.name) + : t.detail.deleteMovementDescription} + + + + {tr.common.cancel} + + + + +
+ ); +} diff --git a/src/features/stock/components/stock-categories-manager.tsx b/src/features/stock/components/stock-categories-manager.tsx new file mode 100644 index 0000000..97a82fa --- /dev/null +++ b/src/features/stock/components/stock-categories-manager.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { + Archive, + ArchiveRestore, + ArrowLeft, + EllipsisVertical, + FolderPlus, + Pencil, + Plus, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +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 { saveStockCategoryAction, setStockCategoryArchivedAction } from "../actions"; +import type { StockCategoryView, StockOption } from "../service"; + +const t = tr.stock.categories; +const NONE = "__none__"; +type Errors = Record; + +type Pending = + | { kind: "create"; parentId: string | null } + | { kind: "edit"; category: StockCategoryView } + | { kind: "archive"; category: StockCategoryView } + | null; + +/** /stok/kategoriler: ağaç, gider eşlemesi, sayım sıklığı, arşiv. */ +export function StockCategoriesManager({ + categories, + expenseCategories, + canWrite, +}: { + categories: StockCategoryView[]; + expenseCategories: StockOption[]; + canWrite: boolean; +}) { + const [pending, setPending] = useState(null); + const [showArchived, setShowArchived] = useState(false); + const close = () => setPending(null); + const visible = categories.filter((c) => showArchived || !c.archived); + + async function unarchive(c: StockCategoryView) { + const r = await setStockCategoryArchivedAction(c.id, false); + if (r.ok) toast.success(tr.stock.toast.unarchived); + else toast.error(r.error); + } + + return ( +
+ +
+ + {canWrite ? ( + + ) : null} +
+ + {visible.length === 0 ? ( +

+ {t.empty} +

+ ) : ( +
    + {visible.map((c) => ( +
  • +
    +

    + {c.name} + {c.isDefault ? {t.defaultBadge} : null} + {c.archived ? {t.archived} : null} +

    +

    + {[ + c.expense + ? c.expense.inherited + ? t.inherited(c.expense.path) + : c.expense.path + : t.noExpense, + c.effectiveCountDays ? t.everyDays(c.effectiveCountDays) : null, + c.productCount ? t.productCount(c.productCount) : null, + ] + .filter(Boolean) + .join(" · ")} +

    +
    + {canWrite ? ( + + + + + + {c.archived ? ( + unarchive(c)}> + + {t.unarchive} + + ) : ( + <> + setPending({ kind: "create", parentId: c.id })} + > + + {t.addChild} + + setPending({ kind: "edit", category: c })} + > + + {t.edit} + + + setPending({ kind: "archive", category: c })} + > + + {t.archive} + + + )} + + + ) : null} +
  • + ))} +
+ )} + + {pending?.kind === "create" || pending?.kind === "edit" ? ( + + ) : null} + !o && close()}> + + + {t.archiveTitle} + + {pending?.kind === "archive" ? t.archiveDescription(pending.category.name) : null} + + + + {tr.common.cancel} + + + + +
+ ); +} + +function StockCategoryDialog({ + category, + parentId, + categories, + expenseCategories, + onClose, +}: { + category: StockCategoryView | null; + parentId: string | null; + categories: StockCategoryView[]; + expenseCategories: StockOption[]; + onClose: () => void; +}) { + const [v, setV] = useState({ + name: category?.name ?? "", + parentId: parentId ?? "", + expenseCategoryId: category?.expenseCategoryId ?? "", + countEveryDays: category?.countEveryDays ? String(category.countEveryDays) : "", + }); + const [errors, setErrors] = useState({}); + const [busy, setBusy] = useState(false); + const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x })); + + // Kendisi ve alt ağacı üst olarak seçilemez. + const parents = useMemo(() => { + if (!category) return categories.filter((c) => !c.archived); + const blocked = new Set([category.id]); + for (const c of categories) if (c.parentId && blocked.has(c.parentId)) blocked.add(c.id); + return categories.filter((c) => !c.archived && !blocked.has(c.id)); + }, [categories, category]); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveStockCategoryAction(category?.id ?? null, v); + setBusy(false); + if (r.ok) { + toast.success(tr.stock.toast.saved); + onClose(); + } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error }); + } + + return ( + !o && onClose()} + title={category ? t.editTitle : t.createTitle} + footer={ + <> + + + + } + > +
+ + + {t.name} + set("name", e.target.value)} + aria-invalid={Boolean(errors.name)} + autoFocus + /> + {errors.name ? {errors.name} : null} + + + {t.parent} + + {errors.parentId ? {errors.parentId} : null} + + + {t.expenseCategory} + + {t.expenseHint} + {errors.expenseCategoryId ? {errors.expenseCategoryId} : null} + + + {t.countEveryDays} + set("countEveryDays", e.target.value)} + className="w-32 text-right tabular-nums" + aria-invalid={Boolean(errors.countEveryDays)} + /> + {t.countEveryDaysHint} + {errors.countEveryDays ? {errors.countEveryDays} : null} + + {errors.root ? {errors.root} : null} + +
+
+ ); +} diff --git a/src/features/stock/components/stock-dialogs.tsx b/src/features/stock/components/stock-dialogs.tsx new file mode 100644 index 0000000..32b78bc --- /dev/null +++ b/src/features/stock/components/stock-dialogs.tsx @@ -0,0 +1,523 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { tr } from "@/i18n/tr"; +import { todayIstanbul } from "@/shared/dates"; +import { + formatStock, + quantityInput, + STOCK_UNITS, + WASTE_REASONS, + type WasteReason, +} from "@/shared/stock"; +import { addStockMovementAction, countStockAction, saveProductTypeAction } from "../actions"; +import type { ProductTypeView, StockOption } from "../service"; + +const t = tr.stock; +type Errors = Record; +const CUSTOM = "__custom__"; + +function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) { + return ( + <> + + + + ); +} + +function errorsOf(r: { fieldErrors?: Partial>; error: string }): Errors { + return { ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error }; +} + +/** Yeni ürün tipi / düzenle. */ +export function ProductTypeFormDialog({ + item, + categories, + defaultCategoryId, + onClose, + onCreated, +}: { + item: ProductTypeView | null; + categories: StockOption[]; + defaultCategoryId?: string; + onClose: () => void; + onCreated?: (id: string) => void; +}) { + const standard = (STOCK_UNITS as readonly string[]).includes(item?.unit ?? "adet"); + const [v, setV] = useState({ + name: item?.name ?? "", + categoryId: item?.categoryId ?? defaultCategoryId ?? "", + unitChoice: standard ? (item?.unit ?? "adet") : CUSTOM, + customUnit: standard ? "" : (item?.unit ?? ""), + targetQuantity: item ? quantityInput(item.targetQuantity) : "", + thresholdPct: String(item?.thresholdPct ?? 20), + location: item?.location ?? "", + step: item ? quantityInput(item.step) : "1", + countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "", + note: item?.note ?? "", + initialQuantity: "", + }); + const [errors, setErrors] = useState({}); + const [busy, setBusy] = useState(false); + const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x })); + const unit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice; + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveProductTypeAction(item?.id ?? null, { + name: v.name, + categoryId: v.categoryId, + unit, + targetQuantity: v.targetQuantity, + thresholdPct: v.thresholdPct, + location: v.location, + step: v.step, + countEveryDays: v.countEveryDays, + note: v.note, + initialQuantity: item ? "" : v.initialQuantity, + }); + setBusy(false); + if (r.ok) { + toast.success(t.toast.saved); + onClose(); + if (!item) onCreated?.((r.data as { id: string }).id); + } else setErrors(errorsOf(r)); + } + + const f = t.fields; + const unitSuffix = unit ? ` (${unit})` : ""; + return ( + !o && onClose()} + title={item ? t.editTitle : t.createTitle} + footer={