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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user