8ab2244755
- 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>
1313 lines
42 KiB
TypeScript
1313 lines
42 KiB
TypeScript
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 };
|
||
}
|