Files
butcem/src/features/stock/service.ts
T
suphicakir 8ab2244755 feat(shopping): alışveriş listeleri, stok eşiği önerileri ve akıllı mağaza önerisi motoru
- shoppingLists koleksiyonu (ürün tipi / marka / serbest metin kalemleri), migration 015
- planShopping saf fonksiyonu: paket yuvarlama, zahmet ve hacimli cezası, teslimat, yemek kartı önceliği, ek mağaza eşiği
- eşik önerileri onay/red (red stok yenilenene kadar), ürün tipine hacimli işareti
- fiyat servisinde ortak loadTypeOffers; seed listeleri ve testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:28:54 +03:00

1313 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CatLean[]>();
}
async function loadMoves(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
const docs = await StockMovementModel.find({
householdId,
productTypeId: { $in: productIds },
deletedAt: null,
}).lean<MoveLean[]>();
const by = new Map<string, MoveLean[]>();
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<string, string>,
productCounts: Map<string, number>,
{ 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<StockView | null> {
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<ProductLean[]>(),
]);
const counts = new Map<string, number>();
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<string, CountDueGroup>();
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<ProductLean>();
}
export async function getProductType(
userId: string,
householdId: string,
productTypeId: string,
): Promise<ProductTypeDetail | null> {
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<string, number>();
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<number> {
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<string, unknown> = {
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<ActionResult> {
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<ActionResult | null> {
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult<{ remaining: number }>> {
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<ActionResult<{ diff: number }>> {
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<ActionResult> {
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<MoveLean>();
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<CountSheet | null> {
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<string>();
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<ActionResult<{ countId: string; counted: number; adjusted: number; diffs: number[] }>> {
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<ProductLean[]>();
const byId = new Map(products.map((p) => [String(p._id), p]));
const moves = await loadMoves(m.householdId, ids);
const fieldErrors: Record<string, string> = {};
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<string, unknown>[] = [];
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<StockAnalysis | null> {
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 };
}