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 { latestTypeUnitPrices } from "@/features/prices/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 { StockCountModel } from "@/models/stock-count"; import { StockMovementModel } from "@/models/stock-movement"; import { todayIstanbul } from "@/shared/dates"; import { shiftMonth } from "@/shared/budget"; import { QUANTITY_SCALE } from "@/shared/quantity"; import type { ProductTypeForm, StockCategoryForm, StockCountForm, StockCountSheet, StockMovementForm, } from "@/shared/schemas/stock"; import { compareTr, foldTr } from "@/shared/search"; import { countSchedule, currentQuantity, expiringLots, expiryStatus, fifoLots, formatStock, monthlyUsage, roundForDisplay, signedDelta, stockLevel, suggestPurchase, type CountSchedule, type ExpiringLot, type ExpiryStatus, type MonthUsage, type PurchaseSuggestion, 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; bulky?: boolean; archivedAt: Date | null; createdAt: Date; }; 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; bulky: boolean; archived: boolean; current: number; level: StockLevel; suggestedQuantity: number; /** Öneri ayrıntısı: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */ suggestion: PurchaseSuggestion; /** Periyodik sayım (sıklık ürün tipinden, yoksa kategoriden). */ count: CountSchedule | null; expiring: ExpiringLot[]; lastMovementDate: string | null; movementCount: number; }; /** Sayımı gelen ürünlerin kategoriye göre özeti. */ export type CountDueGroup = { categoryId: string; path: string; products: number; /** En çok geciken ürünün gecikmesi (gün). */ overdueDays: number; }; export type StockView = { categories: StockCategoryView[]; items: ProductTypeView[]; suggestions: ProductTypeView[]; expiring: { item: ProductTypeView; lot: ExpiringLot }[]; countDue: CountDueGroup[]; canWrite: boolean; today: string; }; 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, }; }); } /** Ürün başına son sayım tarihi (sayım oturumlarından). */ async function loadLastCounts(householdId: Types.ObjectId, productIds: Types.ObjectId[]) { const rows = await StockCountModel.aggregate<{ _id: Types.ObjectId; date: string }>([ { $match: { householdId, "items.productTypeId": { $in: productIds } } }, { $unwind: "$items" }, { $match: { "items.productTypeId": { $in: productIds } } }, { $group: { _id: "$items.productTypeId", date: { $max: "$date" } } }, ]); return new Map(rows.map((r) => [String(r._id), r.date])); } type ViewContext = { categoryPath: string; /** Kategoriden devralınan sayım sıklığı. */ categoryCountDays: number | null; lastCount: string | null; today: string; }; const ymdOf = (d: Date) => todayIstanbul(d); function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTypeView { const { today } = ctx; const current = currentQuantity(moves); const level = stockLevel(current, p.targetQuantity, p.thresholdPct); const suggestion = suggestPurchase({ current, target: p.targetQuantity, step: p.step, moves, today, }); return { id: String(p._id), name: p.name, categoryId: String(p.categoryId), categoryPath: ctx.categoryPath, unit: p.unit, targetQuantity: p.targetQuantity, thresholdPct: p.thresholdPct, location: p.location, step: p.step, countEveryDays: p.countEveryDays, note: p.note, bulky: Boolean(p.bulky), archived: Boolean(p.archivedAt), current, level, suggestedQuantity: suggestion.quantity, suggestion, count: countSchedule( p.countEveryDays ?? ctx.categoryCountDays, ctx.lastCount, moves[0]?.date ?? ymdOf(p.createdAt), today, ), expiring: expiringLots(fifoLots(moves), today), lastMovementDate: moves.at(-1)?.date ?? null, movementCount: moves.length, }; } // --- 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 catOf = new Map(categories.map((c) => [c.id, c])); const ids = products.map((p) => p._id); const [moves, lastCounts] = await Promise.all([ loadMoves(m.householdId, ids), loadLastCounts(m.householdId, ids), ]); const items = products .map((p) => { const cat = catOf.get(String(p.categoryId)); return toView(p, moves.get(String(p._id)) ?? [], { categoryPath: cat?.path ?? "", categoryCountDays: cat?.effectiveCountDays ?? null, lastCount: lastCounts.get(String(p._id)) ?? null, today, }); }) .sort( (a, b) => (order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) || 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, countDue: groupCountDue(items), canWrite: hasHouseholdRole(m.role, "member"), today, }; } function groupCountDue(items: ProductTypeView[]): CountDueGroup[] { const by = new Map(); for (const i of items) { if (!i.count?.due) continue; const g = by.get(i.categoryId) ?? { categoryId: i.categoryId, path: i.categoryPath, products: 0, overdueDays: 0, }; g.products++; g.overdueDays = Math.max(g.overdueDays, i.count.overdueDays); by.set(i.categoryId, g); } return [...by.values()].sort( (a, b) => b.overdueDays - a.overdueDays || compareTr(a.path, b.path), ); } async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) { const id = toObjectId(productTypeId); if (!id) return null; 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 cat = categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find( (c) => c.id === String(p.categoryId), ); const [movesBy, lastCounts] = await Promise.all([ loadMoves(m.householdId, [p._id]), loadLastCounts(m.householdId, [p._id]), ]); const moves = movesBy.get(String(p._id)) ?? []; let running = 0; const rows = moves.map((mv): StockMovementRow => { const delta = signedDelta(mv); 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, { categoryPath: cat?.path ?? "", categoryCountDays: cat?.effectiveCountDays ?? null, lastCount: lastCounts.get(String(p._id)) ?? null, today, }), movements: rows.reverse(), lots: fifoLots(moves).map((l) => ({ date: l.date, 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, bulky: input.bulky, }); 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ı (ürün detayından): toplu sayımın tek satırlık hâli. Fark yoksa düzeltme * oluşmaz ama sayım kaydı tutulur. Farkı (işaretli) döner. */ export async function countStock( actorId: string, householdId: string, productTypeId: string, input: StockCountForm, ): Promise> { const r = await submitCount(actorId, householdId, { date: input.date, categoryId: null, note: input.note, rows: [ { productTypeId, actualQuantity: input.actualQuantity, wasteQuantity: null, wasteReason: null, }, ], }); if (!r.ok) return r; return ok({ diff: r.data.diffs[0] ?? 0 }); } export async function deleteStockMovement( 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); } // --- Periyodik sayım (Faz 11b) --- export type CountSheetRow = { id: string; name: string; categoryId: string; categoryPath: string; unit: string; step: number; location: string; /** Kayıtlara göre bugün beklenen miktar. */ expected: number; count: CountSchedule | null; }; export type CountSheet = { today: string; category: { id: string; path: string } | null; /** Filtre seçenekleri: ürünü olan kategoriler ve sayımı gelen ürün sayısı. */ categories: { id: string; path: string; depth: number; due: number }[]; rows: CountSheetRow[]; canWrite: boolean; }; /** /stok/sayim: beklenen miktarlar (kategori seçiliyse alt ağacıyla). */ export async function getCountSheet( userId: string, householdId: string, categoryId: string | null, ): Promise { const data = await getStock(userId, householdId); if (!data) return null; const all = data.categories; const category = categoryId ? (all.find((c) => c.id === categoryId) ?? null) : null; const inScope = category ? new Set([ category.id, ...descendantIds( all.map((c) => ({ id: c.id, kind: "expense" as const, name: c.name, parentId: c.parentId, order: 0, archived: false, })), category.id, ), ]) : null; const due = new Map(data.countDue.map((g) => [g.categoryId, g.products])); const withItems = new Set(data.items.map((i) => i.categoryId)); // Ürünü olan kategoriler ve ataları seçenek olarak görünür. const visible = new Set(); for (const c of all) if (withItems.has(c.id)) { visible.add(c.id); let parent = c.parentId; while (parent) { visible.add(parent); parent = all.find((x) => x.id === parent)?.parentId ?? null; } } return { today: data.today, category: category ? { id: category.id, path: category.path } : null, categories: all .filter((c) => visible.has(c.id)) .map((c) => ({ id: c.id, path: c.path, depth: c.depth, due: due.get(c.id) ?? 0 })), rows: data.items .filter((i) => !inScope || inScope.has(i.categoryId)) .map((i) => ({ id: i.id, name: i.name, categoryId: i.categoryId, categoryPath: i.categoryPath, unit: i.unit, step: i.step, location: i.location, expected: i.current, count: i.count, })), canWrite: data.canWrite, }; } /** * Toplu sayım (tek transaction): her sayılan ürün için o tarihe kadarki kayıtlara göre beklenen * hesaplanır; eksiğin fire olarak işaretlenen kısmı `waste`, kalan fark `count_adjust` olur. * Fark sıfır olsa da sayım oturumuna yazılır. Fire, eksikten fazla olamaz. */ export async function submitCount( actorId: string, householdId: string, input: StockCountSheet, ): Promise> { const m = await resolveMembership(actorId, householdId, "member"); if (!m) return fail(tr.common.unauthorized); const rows = input.rows.filter((r) => r.actualQuantity !== null); if (rows.length === 0) return fail(t.validation.nothingCounted); if (new Set(rows.map((r) => r.productTypeId)).size !== rows.length) return fail(tr.common.notFound); const ids = rows.map((r) => new Types.ObjectId(r.productTypeId)); const products = await ProductTypeModel.find({ _id: { $in: ids }, householdId: m.householdId, }).lean(); const byId = new Map(products.map((p) => [String(p._id), p])); const moves = await loadMoves(m.householdId, ids); const fieldErrors: Record = {}; const plan = rows.map((r) => { const p = byId.get(r.productTypeId); const index = input.rows.indexOf(r); if (!p) { fieldErrors[`rows.${index}.actualQuantity`] = tr.common.notFound; return null; } if (p.archivedAt) { fieldErrors[`rows.${index}.actualQuantity`] = t.validation.archived; return null; } const expected = currentQuantity( (moves.get(r.productTypeId) ?? []).filter((x) => x.date <= input.date), ); const diff = r.actualQuantity! - expected; const wasted = r.wasteQuantity ?? 0; if (wasted > Math.max(0, -diff)) { fieldErrors[`rows.${index}.wasteQuantity`] = t.validation.wasteOverShortage( formatStock(Math.max(0, -diff), p.unit), ); return null; } return { p, row: r, expected, diff, wasted }; }); if (Object.keys(fieldErrors).length) return fail(tr.adminUsers.errors.checkFields, fieldErrors); const items = plan.filter((x) => x !== null); const actor = new Types.ObjectId(actorId); const mongoose = await connectMongoose(); let countId = ""; await mongoose.connection.transaction(async (session) => { const [count] = await StockCountModel.create( [ { householdId: m.householdId, date: input.date, categoryId: input.categoryId ? new Types.ObjectId(input.categoryId) : null, note: input.note, items: items.map((x) => ({ productTypeId: x.p._id, expected: x.expected, actual: x.row.actualQuantity!, wasted: x.wasted, })), createdBy: actor, }, ], { session }, ); countId = String(count!._id); const docs = items.flatMap((x) => { const base = { householdId: m.householdId, productTypeId: x.p._id, date: input.date, countId: count!._id, createdBy: actor, }; const out: Record[] = []; if (x.wasted > 0) out.push({ ...base, kind: "waste", quantity: x.wasted, wasteReason: x.row.wasteReason, note: t.countNote, }); const rest = x.diff + x.wasted; if (rest !== 0) out.push({ ...base, kind: "count_adjust", quantity: rest, note: input.note || t.countNote, }); return out; }); // Sıralı ekleme: aynı tarihte fire, düzeltmeden önce gelir. for (const d of docs) await StockMovementModel.create([d], { session }); }); return ok({ countId, counted: items.length, adjusted: items.filter((x) => x.diff !== 0).length, diffs: items.map((x) => x.diff), }); } // --- Tüketim analizi (Faz 11b) --- /** Analizde gösterilen tam ay sayısı (içinde bulunulan ay ayrıca). */ export const ANALYSIS_MONTHS = 6; export type StockAnalysisRow = { id: string; name: string; categoryId: string; categoryPath: string; unit: string; months: MonthUsage[]; /** Ürünün var olduğu tam aylarda aylık ortalama kullanım (yoksa null). */ avgMonthlyUsed: number | null; avgMonthlyWasted: number | null; /** Fire / (kullanım + fire), %. */ wastePct: number | null; /** Son bilinen birim fiyat (ürün tipi biriminde, kesirli kuruş) ve tarihi. */ unitPrice: { minor: number; date: string } | null; /** Aylık ortalama maliyet tahmini: ortalama tüketim × son birim fiyat (kuruş). */ avgMonthlyCostMinor: number | null; /** Bu ay (bugüne kadar). */ thisMonth: MonthUsage; }; export type StockAnalysis = { months: string[]; thisMonth: string; rows: StockAnalysisRow[]; }; /** /stok/analiz: ürün tipi başına aylık ortalama tüketim ve fire. */ export async function getStockAnalysis( userId: string, householdId: string, ): Promise { const data = await getStock(userId, householdId); if (!data) return null; const thisMonth = data.today.slice(0, 7); const months = Array.from({ length: ANALYSIS_MONTHS }, (_, i) => shiftMonth(thisMonth, i - ANALYSIS_MONTHS), ); const m = (await resolveMembership(userId, householdId, "viewer"))!; const ids = data.items.map((i) => new Types.ObjectId(i.id)); const [moves, prices] = await Promise.all([ loadMoves(m.householdId, ids), latestTypeUnitPrices(m.householdId, ids, data.today), ]); const rows = data.items.map((item): StockAnalysisRow => { const list = moves.get(item.id) ?? []; const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!]; // Ortalama: ilk hareketin ayından sonraki tam aylar (ilk ay eksik olabilir). const first = list[0]?.date.slice(0, 7); const full = first ? usage.filter((u) => u.month > first) : []; const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0); const used = sum("used"); const wasted = sum("wasted"); const price = prices.get(item.id) ?? null; const avgUsed = full.length ? used / full.length : null; return { id: item.id, name: item.name, categoryId: item.categoryId, categoryPath: item.categoryPath, unit: item.unit, months: usage, avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null, avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null, wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null, unitPrice: price ? { minor: price.unitPriceMinor, date: price.date } : null, avgMonthlyCostMinor: price && avgUsed !== null ? Math.round((avgUsed * price.unitPriceMinor) / QUANTITY_SCALE) : null, thisMonth: current, }; }); return { months, thisMonth, rows }; }