feat(stock): stok kategorileri, ürün tipleri, hareketler ve FIFO SKT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 08:10:10 +03:00
parent abf2ea6dd7
commit d9de29c18d
18 changed files with 2544 additions and 1 deletions
+938
View File
@@ -0,0 +1,938 @@
import "server-only";
import { Types, type ClientSession } from "mongoose";
import {
ancestorIds,
buildTree,
checkCreate,
checkMove,
descendantIds,
toOptions,
type FlatCategory,
type MoveViolation,
} from "@/features/categories/tree";
import { hasHouseholdRole } from "@/features/households/roles";
import { resolveMembership, type Membership } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { connectMongoose } from "@/lib/db/mongoose";
import { toObjectId } from "@/lib/object-id";
import { CategoryModel } from "@/models/category";
import { ProductTypeModel } from "@/models/product-type";
import { StockCategoryModel } from "@/models/stock-category";
import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates";
import type {
ProductTypeForm,
StockCategoryForm,
StockCountForm,
StockMovementForm,
} from "@/shared/schemas/stock";
import { compareTr, foldTr } from "@/shared/search";
import {
currentQuantity,
expiringLots,
expiryStatus,
fifoLots,
formatStock,
refillQuantity,
signedDelta,
stockLevel,
type ExpiringLot,
type ExpiryStatus,
type StockLevel,
type StockMovementKind,
type WasteReason,
} from "@/shared/stock";
import { DEFAULT_STOCK_CATEGORIES } from "./defaults";
/**
* Ev stoğu (Bölüm 6.10). Okuma: viewer; yazma: member. Stok hane ortaktır (gizlilik yok).
* Mevcut miktar hareketlerden türetilir; SKT uyarıları FIFO partilerinden.
*/
const t = tr.stock;
type CatLean = {
_id: Types.ObjectId;
name: string;
parentId: Types.ObjectId | null;
ancestors: Types.ObjectId[];
order: number;
archivedAt: Date | null;
expenseCategoryId: Types.ObjectId | null;
countEveryDays: number | null;
defaultKey: string | null;
};
type ProductLean = {
_id: Types.ObjectId;
categoryId: Types.ObjectId;
name: string;
unit: string;
targetQuantity: number;
thresholdPct: number;
location: string;
step: number;
countEveryDays: number | null;
note: string;
archivedAt: Date | null;
};
type MoveLean = {
_id: Types.ObjectId;
productTypeId: Types.ObjectId;
kind: StockMovementKind;
date: string;
quantity: number;
wasteReason: WasteReason | null;
expiryDate: string | null;
note: string;
initial: boolean;
createdAt: Date;
};
export type StockCategoryView = {
id: string;
name: string;
parentId: string | null;
depth: number;
path: string;
archived: boolean;
isDefault: boolean;
expenseCategoryId: string | null;
/** Etkin gider kategorisi (kendisi veya üstten devralınan). */
expense: { id: string; path: string; inherited: boolean } | null;
countEveryDays: number | null;
effectiveCountDays: number | null;
productCount: number;
};
export type ProductTypeView = {
id: string;
name: string;
categoryId: string;
categoryPath: string;
unit: string;
targetQuantity: number;
thresholdPct: number;
location: string;
step: number;
countEveryDays: number | null;
note: string;
archived: boolean;
current: number;
level: StockLevel;
suggestedQuantity: number;
expiring: ExpiringLot[];
lastMovementDate: string | null;
movementCount: number;
};
export type StockView = {
categories: StockCategoryView[];
items: ProductTypeView[];
suggestions: ProductTypeView[];
expiring: { item: ProductTypeView; lot: ExpiringLot }[];
canWrite: boolean;
today: string;
};
export type StockMovementRow = {
id: string;
kind: StockMovementKind;
date: string;
/** İşaretli etki. */
delta: number;
wasteReason: WasteReason | null;
expiryDate: string | null;
note: string;
initial: boolean;
quantityAfter: number;
};
export type LotRow = {
date: string;
expiryDate: string | null;
remaining: number;
status: ExpiryStatus | null;
};
export type ProductTypeDetail = ProductTypeView & {
movements: StockMovementRow[];
lots: LotRow[];
canWrite: boolean;
};
export type StockOption = { id: string; label: string; path: string; depth: number };
// --- Yükleme yardımcıları ---
/** Ağaç fonksiyonları tür alanı ister; stok ağacı tek türlüdür. */
const flat = (c: CatLean): FlatCategory => ({
id: String(c._id),
kind: "expense",
name: c.name,
parentId: c.parentId ? String(c.parentId) : null,
order: c.order,
archived: Boolean(c.archivedAt),
});
const sortMoves = (a: MoveLean, b: MoveLean) =>
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
async function loadCategories(householdId: Types.ObjectId, session?: ClientSession) {
return StockCategoryModel.find({ householdId })
.session(session ?? null)
.lean<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,
};
});
}
function toView(
p: ProductLean,
moves: MoveLean[],
categoryPath: string,
today: string,
): ProductTypeView {
const current = currentQuantity(moves);
const level = stockLevel(current, p.targetQuantity, p.thresholdPct);
return {
id: String(p._id),
name: p.name,
categoryId: String(p.categoryId),
categoryPath,
unit: p.unit,
targetQuantity: p.targetQuantity,
thresholdPct: p.thresholdPct,
location: p.location,
step: p.step,
countEveryDays: p.countEveryDays,
note: p.note,
archived: Boolean(p.archivedAt),
current,
level,
suggestedQuantity: refillQuantity(current, p.targetQuantity),
expiring: expiringLots(fifoLots(moves), today),
lastMovementDate: moves.at(-1)?.date ?? null,
movementCount: moves.length,
};
}
// --- Okuma ---
export async function getStock(userId: string, householdId: string): Promise<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 pathOf = new Map(categories.map((c) => [c.id, c.path]));
const moves = await loadMoves(
m.householdId,
products.map((p) => p._id),
);
const items = products
.map((p) =>
toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today),
)
.sort(
(a, b) =>
(order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) ||
compareTr(a.name, b.name),
);
const suggestions = items
.filter((i) => i.level.low)
.sort((a, b) => (a.level.pct ?? 0) - (b.level.pct ?? 0) || compareTr(a.name, b.name));
const expiring = items
.flatMap((item) => item.expiring.map((lot) => ({ item, lot })))
.sort((a, b) => (a.lot.expiryDate < b.lot.expiryDate ? -1 : 1));
return {
categories: categories.filter((c) => !c.archived),
items,
suggestions,
expiring,
canWrite: hasHouseholdRole(m.role, "member"),
today,
};
}
async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) {
const id = toObjectId(productTypeId);
if (!id) return null;
return ProductTypeModel.findOne({ _id: id, householdId: m.householdId })
.session(session ?? null)
.lean<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 path =
categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
(c) => c.id === String(p.categoryId),
)?.path ?? "";
const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? [];
let running = 0;
const rows = moves.map((mv): StockMovementRow => {
const delta = signedDelta(mv);
running += delta;
return {
id: String(mv._id),
kind: mv.kind,
date: mv.date,
delta,
wasteReason: mv.wasteReason,
expiryDate: mv.expiryDate,
note: mv.note,
initial: mv.initial,
quantityAfter: running,
};
});
return {
...toView(p, moves, path, today),
movements: rows.reverse(),
lots: fifoLots(moves).map((l) => ({
date: l.date,
expiryDate: l.expiryDate,
remaining: l.remaining,
status: l.expiryDate ? expiryStatus(l.expiryDate, today) : null,
})),
canWrite: hasHouseholdRole(m.role, "member"),
};
}
/** Form seçenekleri: aktif stok kategorileri ve gider kategorileri. */
export async function getStockOptions(
userId: string,
householdId: string,
): Promise<{ categories: StockOption[]; expenseCategories: StockOption[] } | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const cats = await loadCategories(m.householdId);
const expense = await CategoryModel.find({
householdId: m.householdId,
kind: "expense",
archivedAt: null,
}).lean<
{ _id: Types.ObjectId; name: string; parentId: Types.ObjectId | null; order: number }[]
>();
const expenseOptions = toOptions(
buildTree(
expense.map((c) => ({
id: String(c._id),
kind: "expense" as const,
name: c.name,
parentId: c.parentId ? String(c.parentId) : null,
order: c.order,
archived: false,
})),
),
);
const pick = (o: { id: string; label: string; path: string; depth: number }) => ({
id: o.id,
label: o.label,
path: o.path,
depth: o.depth,
});
return {
categories: toOptions(buildTree(cats.map(flat))).map(pick),
expenseCategories: expenseOptions.map(pick),
};
}
/** /stok/kategoriler: arşivliler dahil tüm stok kategorileri. */
export async function listStockCategories(
userId: string,
householdId: string,
): Promise<{ categories: StockCategoryView[]; canWrite: boolean } | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const [cats, products] = await Promise.all([
loadCategories(m.householdId),
ProductTypeModel.find({ householdId: m.householdId, archivedAt: null })
.select({ categoryId: 1 })
.lean<{ categoryId: Types.ObjectId }[]>(),
]);
const counts = new Map<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,
});
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ı: o tarihe kadarki kayıtlara göre beklenen ile sayılan arasındaki fark sayım
* düzeltmesi olur. Fark yoksa kayıt oluşmaz. Farkı (işaretli) döner.
*/
export async function countStock(
actorId: string,
householdId: string,
productTypeId: string,
input: StockCountForm,
): Promise<ActionResult<{ diff: number }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const r = await activeProductWithStock(m, productTypeId, input.date);
if (r.error !== null) return fail(r.error);
const diff = input.actualQuantity - r.current;
if (diff !== 0)
await StockMovementModel.create({
householdId: m.householdId,
productTypeId: r.p._id,
kind: "count_adjust",
date: input.date,
quantity: diff,
note: input.note,
createdBy: new Types.ObjectId(actorId),
});
return ok({ diff });
}
export async function deleteStockMovement(
actorId: string,
householdId: string,
movementId: string,
): Promise<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);
}