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
+3
View File
@@ -7,6 +7,7 @@ import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { toObjectId } from "@/lib/object-id";
import { installDefaultCategories } from "@/features/categories/service";
import { installDefaultStockCategories } from "@/features/stock/service";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import type { HouseholdSettings } from "@/shared/schemas/household";
@@ -324,6 +325,8 @@ export async function adminCreateHousehold(
);
// Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2).
await installDefaultCategories(household!._id, actor, session);
// Varsayılan stok kategorileri (gider eşlemesi yukarıdaki kategorilerden).
await installDefaultStockCategories(household!._id, actor, session);
});
return ok({ id: createdId });
}
+123
View File
@@ -0,0 +1,123 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import {
productTypeFormSchema,
stockCategoryFormSchema,
stockCountFormSchema,
stockMovementFormSchema,
} from "@/shared/schemas/stock";
import {
addStockMovement,
countStock,
createProductType,
createStockCategory,
deleteStockMovement,
getStockOptions,
quickConsume,
setProductTypeArchived,
setStockCategoryArchived,
updateProductType,
updateStockCategory,
type StockOption,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T, R>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = schema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
function withId<R>(
raw: unknown,
fn: (id: string) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
const id = idSchema.safeParse(raw);
if (!id.success) return Promise.resolve(fail(tr.common.notFound));
return fn(id.data);
}
/** Yeni (id boş) veya mevcut stok kategorisi. */
export async function saveStockCategoryAction(categoryId: unknown, input: unknown) {
if (categoryId)
return withId(categoryId, (id) =>
run(stockCategoryFormSchema, input, (u, h, d) => updateStockCategory(u, h, id, d)),
);
return run(stockCategoryFormSchema, input, createStockCategory);
}
export async function setStockCategoryArchivedAction(categoryId: unknown, archived: unknown) {
return withId(categoryId, (id) =>
run(z.boolean(), archived, (u, h, a) => setStockCategoryArchived(u, h, id, a)),
);
}
/** Yeni (id boş) veya mevcut ürün tipi. */
export async function saveProductTypeAction(productTypeId: unknown, input: unknown) {
if (productTypeId)
return withId(productTypeId, (id) =>
run(productTypeFormSchema, input, (u, h, d) => updateProductType(u, h, id, d)),
);
return run(productTypeFormSchema, input, createProductType);
}
export async function setProductTypeArchivedAction(productTypeId: unknown, archived: unknown) {
return withId(productTypeId, (id) =>
run(z.boolean(), archived, (u, h, a) => setProductTypeArchived(u, h, id, a)),
);
}
export async function addStockMovementAction(productTypeId: unknown, input: unknown) {
return withId(productTypeId, (id) =>
run(stockMovementFormSchema, input, (u, h, d) => addStockMovement(u, h, id, d)),
);
}
export async function quickConsumeAction(productTypeId: unknown) {
return withId(productTypeId, (id) => run(z.unknown(), null, (u, h) => quickConsume(u, h, id)));
}
export async function countStockAction(productTypeId: unknown, input: unknown) {
return withId(productTypeId, (id) =>
run(stockCountFormSchema, input, (u, h, d) => countStock(u, h, id, d)),
);
}
export async function deleteStockMovementAction(movementId: unknown) {
return withId(movementId, (id) =>
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
);
}
/** Ürün tipi ve stok kategorisi formları için seçenekler. */
export async function getStockOptionsAction(): Promise<
ActionResult<{ categories: StockOption[]; expenseCategories: StockOption[] }>
> {
const ctx = await getHouseholdActionContext("viewer");
if (!ctx) return fail(tr.common.unauthorized);
const data = await getStockOptions(ctx.user.id, ctx.household.id);
return data ? ok(data) : fail(tr.common.unauthorized);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Varsayılan stok kategori seti (Bölüm 6.10). `key` sabittir (tekrar yüklemede kopya olmaz).
* `expenseKey`: eşlenen gider kategorisinin varsayılan anahtarı (Faz 13'te alışveriş giderleri).
* Parantez içindeki örnekler (Kırmızı Mercimek, Toz Deterjan …) ürün tipidir; birim ve hedef
* haneye göre değiştiği için otomatik eklenmez (seed örnek verisinde vardır).
*/
export type DefaultStockCategory = {
key: string;
name: string;
expenseKey?: string;
children?: Omit<DefaultStockCategory, "children" | "expenseKey">[];
};
export const DEFAULT_STOCK_CATEGORIES: DefaultStockCategory[] = [
{
key: "mutfak",
name: "Mutfak",
expenseKey: "market.gida",
children: [
{ key: "mutfak.manav", name: "Manav" },
{ key: "mutfak.sarkuteri", name: "Şarküteri" },
{ key: "mutfak.bakliyat", name: "Bakliyat" },
],
},
{
key: "genel-temizlik",
name: "Genel Temizlik",
expenseKey: "market.temizlik",
children: [
{ key: "genel-temizlik.bulasik", name: "Bulaşık" },
{ key: "genel-temizlik.camasir", name: "Çamaşır" },
{ key: "genel-temizlik.genel", name: "Genel" },
],
},
{
key: "kisisel-temizlik",
name: "Kişisel Temizlik",
expenseKey: "market.kisisel-bakim",
children: [
{ key: "kisisel-temizlik.sampuan", name: "Şampuan" },
{ key: "kisisel-temizlik.tiras", name: "Tıraş" },
{ key: "kisisel-temizlik.kisisel-bakim", name: "Kişisel Bakım" },
],
},
];
+307
View File
@@ -0,0 +1,307 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tr } from "@/i18n/tr";
import { AuditLogModel } from "@/models/audit-log";
import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import {
productTypeFormSchema,
stockCategoryFormSchema,
stockCountFormSchema,
stockMovementFormSchema,
type ProductTypeFormInput,
} from "@/shared/schemas/stock";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
addStockMovement,
countStock,
createProductType,
createStockCategory,
deleteStockMovement,
getProductType,
getStock,
getStockOptions,
listStockCategories,
quickConsume,
setProductTypeArchived,
setStockCategoryArchived,
updateStockCategory,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let zeynep = "";
let fatma = "";
const today = todayIstanbul();
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
async function categoryId(path: string) {
const o = (await getStockOptions(ayse, cakir))!;
return o.categories.find((c) => c.path === path)!.id;
}
function product(input: Partial<ProductTypeFormInput> & { categoryId: string }) {
return productTypeFormSchema.parse({
name: "Test Ürün",
unit: "kg",
targetQuantity: "4",
thresholdPct: "20",
step: "0,5",
...input,
});
}
describe("seed ve görünüm", () => {
it("varsayılan ağaç, gider eşlemesi devralınır; azalanlar ve SKT", async () => {
const s = (await getStock(ayse, cakir))!;
expect(s.items).toHaveLength(19);
const bakliyat = s.categories.find((c) => c.path === "Mutfak › Bakliyat")!;
expect(bakliyat.depth).toBe(1);
expect(bakliyat.expense).toEqual(
expect.objectContaining({ path: "Market › Gıda", inherited: true }),
);
expect(s.categories.find((c) => c.path === "Mutfak")!.expense?.inherited).toBe(false);
// Bir süredir alınmayanlar eşiğin altında.
const low = s.suggestions.map((i) => i.name);
expect(low).toEqual(expect.arrayContaining(["Kırmızı Mercimek", "Tablet Deterjan"]));
for (const i of s.suggestions) {
expect(i.level.low).toBe(true);
expect(i.suggestedQuantity).toBe(i.targetQuantity - Math.max(0, i.current));
}
// Hiçbir stok eksiye düşmez; süt partilerinin SKT'si yakın.
expect(s.items.every((i) => i.current >= 0)).toBe(true);
expect(s.expiring.some((e) => e.item.name === "Süt")).toBe(true);
// Kategori sırası: Mutfak ürünleri temizlikten önce.
expect(s.items.findIndex((i) => i.name === "Domates")).toBeLessThan(
s.items.findIndex((i) => i.name === "Toz Deterjan"),
);
const annemler = (await getStock(fatma, ids.household("Annemler")))!;
expect(annemler.items).toHaveLength(0);
expect(annemler.categories.length).toBe(12);
});
it("izolasyon: üye olmayan göremez, izleyici yazamaz", async () => {
expect(await getStock(fatma, cakir)).toBeNull();
const s = (await getStock(zeynep, cakir))!;
expect(s.canWrite).toBe(false);
const any = s.items[0]!;
expect((await quickConsume(zeynep, cakir, any.id)).ok).toBe(false);
expect(await getProductType(fatma, cakir, any.id)).toBeNull();
});
});
describe("ürün tipi ve hareketler", () => {
it("başlangıç miktarı, tüketim sınırı, 1 azalt, fire, SKT FIFO, sayım, silme", async () => {
const cat = await categoryId("Mutfak › Bakliyat");
const created = await createProductType(
ayse,
cakir,
product({ categoryId: cat, name: "Bulgur", initialQuantity: "1,5" }),
);
expect(created.ok).toBe(true);
const id = created.ok ? created.data.id : "";
let p = (await getProductType(ayse, cakir, id))!;
expect(p.current).toBe(1_500);
expect(p.movements[0]).toMatchObject({ kind: "count_adjust", initial: true, delta: 1_500 });
// 1,5 < %20 × 4 = 0,8? hayır → azalmadı.
expect(p.level.low).toBe(false);
const dup = await createProductType(ayse, cakir, product({ categoryId: cat, name: "bulgur" }));
expect(dup.ok).toBe(false);
const tooMuch = await addStockMovement(
ayse,
cakir,
id,
stockMovementFormSchema.parse({ kind: "consume", date: today, quantity: "2" }),
);
expect(tooMuch).toMatchObject({ ok: false, fieldErrors: { quantity: expect.any(String) } });
const q = await quickConsume(ayse, cakir, id);
expect(q).toEqual({ ok: true, data: { remaining: 1_000 } });
// Alım (SKT'li) sonra fire: fire en eski partiden (başlangıç) düşer.
await addStockMovement(
ayse,
cakir,
id,
stockMovementFormSchema.parse({
kind: "purchase",
date: today,
quantity: "1",
expiryDate: addDays(today, 3),
}),
);
expect(
stockMovementFormSchema.safeParse({ kind: "waste", date: today, quantity: "1" }).success,
).toBe(false);
await addStockMovement(
ayse,
cakir,
id,
stockMovementFormSchema.parse({
kind: "waste",
date: today,
quantity: "0,8",
wasteReason: "spoiled",
}),
);
p = (await getProductType(ayse, cakir, id))!;
expect(p.current).toBe(1_200);
expect(p.lots).toEqual([
expect.objectContaining({ remaining: 200, expiryDate: null }),
expect.objectContaining({ remaining: 1_000, expiryDate: addDays(today, 3), status: "soon" }),
]);
expect(p.expiring).toHaveLength(1);
// Sayım: 0,5 sayıldı → −0,7 düzeltme; aynı miktar tekrar → fark yok.
const c = await countStock(
ayse,
cakir,
id,
stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }),
);
expect(c).toEqual({ ok: true, data: { diff: -700 } });
const same = await countStock(
ayse,
cakir,
id,
stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }),
);
expect(same).toEqual({ ok: true, data: { diff: 0 } });
p = (await getProductType(ayse, cakir, id))!;
expect(p.current).toBe(500);
expect(p.level.low).toBe(true);
expect(p.suggestedQuantity).toBe(3_500);
// Hareket silme: yeniden hesap + denetim kaydı.
const adj = p.movements[0]!;
expect(adj.kind).toBe("count_adjust");
expect((await deleteStockMovement(ayse, cakir, adj.id)).ok).toBe(true);
expect((await getProductType(ayse, cakir, id))!.current).toBe(1_200);
expect(
await AuditLogModel.exists({ action: "stock.movement.delete", targetId: adj.id }),
).toBeTruthy();
expect(await StockMovementModel.exists({ _id: adj.id, deletedAt: null })).toBeNull();
// Arşivli üründe hareket yok; arşivden çıkınca devam.
expect((await setProductTypeArchived(ayse, cakir, id, true)).ok).toBe(true);
expect((await quickConsume(ayse, cakir, id)).ok).toBe(false);
expect((await getStock(ayse, cakir))!.items.some((i) => i.id === id)).toBe(false);
expect((await setProductTypeArchived(ayse, cakir, id, false)).ok).toBe(true);
});
it("boş stokta 1 azalt reddedilir; özel birim", async () => {
const cat = await categoryId("Genel Temizlik › Çamaşır");
const r = await createProductType(
ayse,
cakir,
product({ categoryId: cat, name: "Leke Çıkarıcı", unit: "yıkama", step: "1" }),
);
const id = r.ok ? r.data.id : "";
expect(await quickConsume(ayse, cakir, id)).toEqual({
ok: false,
error: tr.stock.validation.emptyStock,
});
expect((await getProductType(ayse, cakir, id))!.unit).toBe("yıkama");
});
});
describe("stok kategorileri", () => {
it("oluştur, gider eşle, taşıma kuralları, arşiv koşulu", async () => {
const opts = (await getStockOptions(ayse, cakir))!;
const market = opts.expenseCategories.find((c) => c.path === "Market")!;
const parent = await createStockCategory(
ayse,
cakir,
stockCategoryFormSchema.parse({
name: "Bebek",
parentId: "",
expenseCategoryId: market.id,
countEveryDays: "30",
}),
);
const parentId = parent.ok ? parent.data.id : "";
const child = await createStockCategory(
ayse,
cakir,
stockCategoryFormSchema.parse({
name: "Bez",
parentId,
expenseCategoryId: "",
countEveryDays: "",
}),
);
const childId = child.ok ? child.data.id : "";
let list = (await listStockCategories(ayse, cakir))!.categories;
const bez = list.find((c) => c.id === childId)!;
expect(bez).toMatchObject({
path: "Bebek › Bez",
effectiveCountDays: 30,
expense: expect.objectContaining({ path: "Market", inherited: true }),
});
// Kendi altına taşınamaz; aynı adlı kardeş olamaz.
const loop = await updateStockCategory(
ayse,
cakir,
parentId,
stockCategoryFormSchema.parse({
name: "Bebek",
parentId: childId,
expenseCategoryId: "",
countEveryDays: "",
}),
);
expect(loop.ok).toBe(false);
const dup = await createStockCategory(
ayse,
cakir,
stockCategoryFormSchema.parse({
name: "mutfak",
parentId: "",
expenseCategoryId: "",
countEveryDays: "",
}),
);
expect(dup.ok).toBe(false);
// Gelir kategorisi eşlenemez.
const income = await createStockCategory(ayse, cakir, {
name: "Yanlış",
parentId: null,
expenseCategoryId: "0".repeat(24),
countEveryDays: null,
});
expect(income.ok).toBe(false);
// Ürün varken arşivlenemez; ürün yokken alt ağaçla arşivlenir.
const r = await createProductType(
ayse,
cakir,
product({ categoryId: childId, name: "Islak Mendil" }),
);
expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(false);
await setProductTypeArchived(ayse, cakir, r.ok ? r.data.id : "", true);
expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(true);
list = (await listStockCategories(ayse, cakir))!.categories;
expect(list.find((c) => c.id === childId)!.archived).toBe(true);
expect((await getStockOptions(ayse, cakir))!.categories.some((c) => c.id === childId)).toBe(
false,
);
// Arşivli kategoriye ürün eklenemez.
expect(
(await createProductType(ayse, cakir, product({ categoryId: childId, name: "Pudra" }))).ok,
).toBe(false);
});
});
+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);
}
+192
View File
@@ -929,6 +929,198 @@ export const tr = {
category: "Kategori seçin",
},
},
stock: {
title: "Ev stoğu",
description:
"Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.",
newProduct: "Yeni ürün tipi",
categoriesLink: "Stok kategorileri",
readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
summary: {
products: "Ürün tipi",
low: "Azalan",
expiring: "SKT yaklaşan",
expired: "SKT geçmiş",
},
suggestions: {
title: "Alınması önerilenler",
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
empty: "Eşiğin altına düşen ürün yok.",
suggested: (q: string) => `Öneri: ${q}`,
},
expiring: {
title: "Son kullanma tarihi",
description: "Evde kalan miktarı olan ve SKT'si 7 gün içinde dolan veya geçmiş partiler.",
expired: "Geçti",
soon: "Yaklaşıyor",
on: (date: string) => `SKT ${date}`,
},
list: {
empty: "Henüz ürün tipi yok. “Yeni ürün tipi” ile evdeki ürünleri ekleyin.",
emptyCategory: "Bu kategoride ürün yok.",
search: "Ürün ara",
onlyLow: "Yalnızca azalanlar",
noMatch: "Aramaya uyan ürün yok.",
lowBadge: "Azaldı",
outBadge: "Bitti",
of: (current: string, target: string) => `${current} / ${target}`,
quickConsume: (name: string) => `${name}: 1 azalt`,
quickDone: (name: string, rest: string) => `${name} azaltıldı, kalan ${rest}.`,
},
fields: {
name: "Ad",
namePlaceholder: "ör. Toz Deterjan, Kırmızı Mercimek",
category: "Stok kategorisi",
unit: "Temel ölçü birimi",
customUnit: "Özel birim",
customUnitOption: "Özel birim…",
customUnitPlaceholder: "ör. yıkama, tablet",
target: "Hedef (tam) stok",
targetHint: "Evde bulunmasını istediğiniz tam miktar.",
thresholdPct: "Kritik eşik (%)",
thresholdHint: "Mevcut miktar hedefin bu yüzdesinin altına düşerse ürün önerilir.",
location: "Saklama yeri",
locationPlaceholder: "Kiler, banyo dolabı…",
step: "“1 azalt” miktarı",
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
countEveryDays: "Sayım sıklığı (gün)",
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
note: "Not",
initialQuantity: "Evdeki mevcut miktar",
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
date: "Tarih",
quantity: "Miktar",
wasteReason: "Fire nedeni",
expiryDate: "Son kullanma tarihi",
expiryHint: "İsteğe bağlı. Yaklaşınca uyarı verilir.",
actualQuantity: "Sayılan (gerçek) miktar",
expected: (q: string) => `Kayıtlara göre beklenen: ${q}`,
},
createTitle: "Yeni ürün tipi",
editTitle: "Ürün tipini düzenle",
kinds: {
purchase: "Alım",
consume: "Tüketim",
waste: "Fire / zarar",
count_adjust: "Sayım düzeltmesi",
},
actions: {
purchase: "Alım ekle",
consume: "Tüketim gir",
waste: "Fire gir",
count: "Sayım yap",
},
movementTitles: {
purchase: "Alım ekle",
consume: "Tüketim gir",
waste: "Fire / zarar gir",
},
countTitle: "Sayım yap",
countDescription:
"Evdeki gerçek miktarı girin; kayıtlarla arasındaki fark sayım düzeltmesi olarak işlenir.",
wasteReasons: {
spoiled: "Bozuldu",
expired: "Son kullanma tarihi geçti",
damaged: "Hasar / döküldü",
other: "Diğer",
},
initial: "Başlangıç miktarı",
detail: {
back: "Ev stoğuna dön",
current: "Mevcut",
target: "Hedef",
threshold: "Eşik",
suggested: "Önerilen alım",
location: "Saklama yeri",
lots: "Evdeki partiler",
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
noLots: "Evde bu üründen yok.",
noExpiry: "SKT yok",
movements: "Hareketler",
noMovements: "Henüz hareket yok.",
after: (q: string) => `Sonra: ${q}`,
archive: "Arşivle",
unarchive: "Arşivden çıkar",
archived: "Arşivli",
archiveTitle: "Ürün tipi arşivlensin mi?",
archiveDescription: (name: string) =>
`“${name}” stok listesinde ve önerilerde görünmez. Hareketleri silinmez; istediğinizde arşivden çıkarabilirsiniz.`,
deleteMovement: "Hareketi sil",
deleteMovementTitle: "Hareket silinsin mi?",
deleteMovementDescription: "Mevcut miktar bu hareket olmadan yeniden hesaplanır.",
},
categories: {
title: "Stok kategorileri",
description:
"Stok kategorileri gider kategorilerinden ayrıdır. Her kategori bir gider kategorisine eşlenebilir; alışveriş tamamlanınca ürünler o gidere yazılır.",
back: "Ev stoğuna dön",
new: "Yeni kategori",
addChild: "Alt kategori ekle",
edit: "Düzenle",
archive: "Arşivle",
unarchive: "Arşivden çıkar",
showArchived: "Arşivi göster",
archived: "Arşivli",
defaultBadge: "Varsayılan",
empty: "Stok kategorisi yok.",
createTitle: "Yeni stok kategorisi",
editTitle: "Stok kategorisini düzenle",
name: "Ad",
parent: "Üst kategori",
noParent: "— Ana kategori —",
expenseCategory: "Gider kategorisi",
expenseInherit: "— Üstten devral —",
expenseHint: "Alışveriş tamamlanınca bu kategorideki ürünlerin yazılacağı gider kategorisi.",
inherited: (name: string) => `${name} (üstten)`,
noExpense: "Eşlenmedi",
countEveryDays: "Sayım sıklığı (gün)",
countEveryDaysHint: "Boş bırakılırsa üst kategoriden devralınır.",
everyDays: (n: number) => `${n} günde bir sayım`,
productCount: (n: number) => `${n} ürün`,
archiveTitle: "Stok kategorisi arşivlensin mi?",
archiveDescription: (name: string) =>
`“${name}” ve alt kategorileri yeni ürünlerde seçilemez.`,
hasProducts:
"Bu kategoride veya altında aktif ürün tipi var; önce onları başka kategoriye taşıyın veya arşivleyin.",
duplicate: "Bu düzeyde aynı adlı bir kategori zaten var.",
expenseInvalid: "Geçerli bir gider kategorisi seçin.",
},
toast: {
saved: "Kaydedildi.",
movementSaved: "Hareket kaydedildi.",
deleted: "Silindi.",
archived: "Arşivlendi.",
unarchived: "Arşivden çıkarıldı.",
counted: (diff: string) => `Sayım kaydedildi; fark ${diff}.`,
noChange: "Sayım kayıtlarla aynı; düzeltme gerekmedi.",
},
validation: {
selectCategory: "Bir stok kategorisi seçin",
quantity: "Geçerli bir miktar girin (ör. 1,5)",
countDays: "1 ile 365 arasında gün sayısı girin",
unit: "Birim 1–20 karakter olmalı",
thresholdPct: "0 ile 100 arasında tam sayı girin",
wasteReason: "Fire nedenini seçin",
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
notEnough: (q: string) => `Stokta yalnızca ${q} var.`,
emptyStock: "Stok zaten boş.",
duplicate: "Bu adla bir ürün tipi zaten var.",
categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.",
archived: "Ürün tipi arşivli.",
},
dashboard: {
title: "Azalan stoklar",
description: (low: number, expiring: number) =>
[
low ? `${low} ürün eşiğin altında` : null,
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
]
.filter(Boolean)
.join(" · "),
more: (n: number) => `+${n} ürün daha`,
open: "Ev stoğuna git",
},
},
prices: {
title: "Fiyat tablosu",
description:
+5
View File
@@ -35,6 +35,11 @@ export const AUDIT_ACTIONS = [
"inkind.delete",
"asset.delete",
"asset.movement.delete",
"stock.category.archive",
"stock.category.unarchive",
"stock.product.archive",
"stock.product.unarchive",
"stock.movement.delete",
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
+43
View File
@@ -0,0 +1,43 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Ürün tipi (genel ürün, markadan bağımsız ihtiyaç: "Toz Deterjan"). Stok bu seviyede tutulur.
* Miktarlar binde bir (QUANTITY_SCALE). Birim hazır listeden ya da özel ("yıkama").
*/
const productTypeSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
categoryId: { type: Schema.Types.ObjectId, required: true },
name: { type: String, required: true, trim: true, maxlength: 80 },
nameKey: { type: String, required: true },
unit: { type: String, required: true, trim: true, maxlength: 20 },
/** Hedef (tam) stok miktarı. */
targetQuantity: { type: Number, required: true, min: 0 },
/** Kritik eşik, hedefin yüzdesi (varsayılan 20). */
thresholdPct: { type: Number, default: 20, min: 0, max: 100 },
/** Saklama yeri (kiler, banyo dolabı …). */
location: { type: String, default: "", maxlength: 80 },
/** "1 azalt" düğmesinin düşeceği miktar. */
step: { type: Number, default: 1000, min: 1 },
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
note: { type: String, default: "", maxlength: 300 },
archivedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "productTypes" },
);
productTypeSchema.index({ householdId: 1, archivedAt: 1, categoryId: 1 });
productTypeSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type ProductType = InferSchemaType<typeof productTypeSchema>;
export const ProductTypeModel: Model<ProductType> =
(models.ProductType as Model<ProductType> | undefined) ??
model<ProductType>("ProductType", productTypeSchema);
+37
View File
@@ -0,0 +1,37 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Stok kategori ağacı (Bölüm 6.10), gelir/gider kategorilerinden ayrı. Silme yok; arşivleme.
* `expenseCategoryId`: alışveriş tamamlanınca (Faz 13) kalemlerin yazılacağı gider kategorisi;
* null ise üst kategoriden devralınır. `countEveryDays`: periyodik sayım sıklığı (Faz 11b);
* null ise üstten devralınır.
*/
const stockCategorySchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
name: { type: String, required: true, trim: true, maxlength: 80 },
nameKey: { type: String, required: true },
parentId: { type: Schema.Types.ObjectId, default: null },
ancestors: { type: [Schema.Types.ObjectId], default: [] },
order: { type: Number, default: 0 },
expenseCategoryId: { type: Schema.Types.ObjectId, default: null },
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
archivedAt: { type: Date, default: null },
defaultKey: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "stockCategories" },
);
stockCategorySchema.index({ householdId: 1, parentId: 1, order: 1 });
stockCategorySchema.index(
{ householdId: 1, defaultKey: 1 },
{ unique: true, partialFilterExpression: { defaultKey: { $type: "string" } } },
);
export type StockCategory = InferSchemaType<typeof stockCategorySchema>;
export const StockCategoryModel: Model<StockCategory> =
(models.StockCategory as Model<StockCategory> | undefined) ??
model<StockCategory>("StockCategory", stockCategorySchema);
+48
View File
@@ -0,0 +1,48 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { STOCK_MOVEMENT_KINDS, WASTE_REASONS } from "@/shared/stock";
/**
* Stok hareketi: alım, tüketim, fire (neden ile), sayım düzeltmesi. Mevcut miktar hareketlerden
* türetilir. Miktar binde bir; yalnızca sayım düzeltmesi işaretlidir. Sıra: tarih, oluşturulma.
*/
const stockMovementSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
productTypeId: { type: Schema.Types.ObjectId, required: true },
kind: { type: String, enum: STOCK_MOVEMENT_KINDS, required: true },
/** "YYYY-MM-DD" (İstanbul). */
date: { type: String, required: true },
quantity: { type: Number, required: true },
wasteReason: { type: String, enum: [...WASTE_REASONS, null], default: null },
/** Son kullanma tarihi (yalnızca alımda, isteğe bağlı). */
expiryDate: { type: String, default: null },
note: { type: String, default: "", maxlength: 300 },
/** İlk kurulumda girilen başlangıç miktarı (sayım düzeltmesi olarak). */
initial: { type: Boolean, default: false },
/** Sayım oturumu (Faz 11b). */
countId: { type: Schema.Types.ObjectId, default: null },
deletedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "stockMovements" },
);
stockMovementSchema.index({
householdId: 1,
productTypeId: 1,
deletedAt: 1,
date: 1,
createdAt: 1,
});
stockMovementSchema.index({ householdId: 1, kind: 1, date: -1 });
stockMovementSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type StockMovement = InferSchemaType<typeof stockMovementSchema>;
export const StockMovementModel: Model<StockMovement> =
(models.StockMovement as Model<StockMovement> | undefined) ??
model<StockMovement>("StockMovement", stockMovementSchema);
+120
View File
@@ -0,0 +1,120 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseQuantity } from "../quantity";
import { WASTE_REASONS } from "../stock";
const v = tr.validation;
const s = tr.stock.validation;
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
const optionalId = z
.string()
.regex(/^([a-f0-9]{24})?$/i)
.transform((x) => (x === "" ? null : x));
const requiredId = z.string().regex(/^[a-f0-9]{24}$/i, s.selectCategory);
/** Miktar metni → binde bir tamsayı. `allowZero` sayım ve başlangıç için. */
const quantity = (allowZero = false) =>
z
.string()
.trim()
.transform((x, ctx) => {
const q = parseQuantity(x);
if (q === null || q < 0 || (!allowZero && q === 0)) {
ctx.addIssue({ code: "custom", message: s.quantity });
return z.NEVER;
}
return q;
});
const optionalQuantity = z.preprocess(
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
quantity(true).nullable(),
);
/** Boş → null; dolu → 1..365 gün. */
const countEveryDays = z.preprocess(
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
z
.string()
.trim()
.transform((x, ctx) => {
const n = Number(x);
if (!/^\d+$/.test(x) || n < 1 || n > 365) {
ctx.addIssue({ code: "custom", message: s.countDays });
return z.NEVER;
}
return n;
})
.nullable(),
);
export const stockCategoryFormSchema = z.object({
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
parentId: optionalId,
expenseCategoryId: optionalId,
countEveryDays,
});
export type StockCategoryFormInput = z.input<typeof stockCategoryFormSchema>;
export type StockCategoryForm = z.output<typeof stockCategoryFormSchema>;
export const productTypeFormSchema = z.object({
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
categoryId: requiredId,
unit: z.string().trim().min(1, s.unit).max(20, s.unit),
targetQuantity: quantity(),
thresholdPct: z
.string()
.trim()
.transform((x, ctx) => {
const n = Number(x);
if (!/^\d+$/.test(x) || n > 100) {
ctx.addIssue({ code: "custom", message: s.thresholdPct });
return z.NEVER;
}
return n;
}),
location: z.string().trim().max(80).default(""),
step: quantity(),
countEveryDays,
note: z.string().trim().max(300).default(""),
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
initialQuantity: optionalQuantity.default(null),
});
export type ProductTypeFormInput = z.input<typeof productTypeFormSchema>;
export type ProductTypeForm = z.output<typeof productTypeFormSchema>;
/** Alım, tüketim veya fire. Sayım düzeltmesi ayrı formdan (gerçek miktar). */
export const stockMovementFormSchema = z
.object({
kind: z.enum(["purchase", "consume", "waste"]),
date: ymd,
quantity: quantity(),
wasteReason: z.preprocess(
(x) => (x === "" || x === undefined ? null : x),
z.enum(WASTE_REASONS).nullable(),
),
expiryDate: z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()),
note: z.string().trim().max(300).default(""),
})
.superRefine((x, ctx) => {
if (x.kind === "waste" && !x.wasteReason)
ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason });
if (x.expiryDate && x.expiryDate < x.date)
ctx.addIssue({ code: "custom", path: ["expiryDate"], message: s.expiryBeforeDate });
})
.transform((x) => ({
...x,
wasteReason: x.kind === "waste" ? x.wasteReason : null,
expiryDate: x.kind === "purchase" ? x.expiryDate : null,
}));
export type StockMovementFormInput = z.input<typeof stockMovementFormSchema>;
export type StockMovementForm = z.output<typeof stockMovementFormSchema>;
/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi olur. */
export const stockCountFormSchema = z.object({
date: ymd,
actualQuantity: quantity(true),
note: z.string().trim().max(300).default(""),
});
export type StockCountFormInput = z.input<typeof stockCountFormSchema>;
export type StockCountForm = z.output<typeof stockCountFormSchema>;
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
currentQuantity,
expiringLots,
expiryStatus,
fifoLots,
quantityInput,
refillQuantity,
stepLabel,
stockLevel,
type StockMove,
} from "./stock";
const m = (
kind: StockMove["kind"],
date: string,
quantity: number,
expiryDate: string | null = null,
): StockMove => ({ kind, date, quantity, expiryDate });
describe("mevcut miktar", () => {
it("alım − tüketim − fire ± sayım", () => {
expect(
currentQuantity([
m("purchase", "2026-10-01", 5_000),
m("consume", "2026-10-05", 1_500),
m("waste", "2026-10-06", 500),
m("count_adjust", "2026-10-10", -250),
m("count_adjust", "2026-10-11", 100),
]),
).toBe(2_850);
expect(currentQuantity([])).toBe(0);
});
});
describe("FIFO partileri ve SKT", () => {
it("çıkışlar en eski partiden düşer", () => {
const lots = fifoLots([
m("purchase", "2026-10-01", 2_000, "2026-11-10"),
m("purchase", "2026-10-20", 3_000, "2026-12-01"),
m("consume", "2026-10-25", 2_500),
m("count_adjust", "2026-10-26", 400),
]);
expect(lots).toEqual([
{ index: 1, date: "2026-10-20", expiryDate: "2026-12-01", quantity: 3_000, remaining: 2_500 },
{ index: 3, date: "2026-10-26", expiryDate: null, quantity: 400, remaining: 400 },
]);
});
it("geçici eksi stok sonraki girişten kapatılır", () => {
const lots = fifoLots([
m("consume", "2026-10-01", 1_000),
m("purchase", "2026-10-02", 3_000, "2026-10-30"),
]);
expect(lots).toEqual([expect.objectContaining({ quantity: 3_000, remaining: 2_000 })]);
expect(fifoLots([m("purchase", "2026-10-01", 1_000), m("waste", "2026-10-02", 1_000)])).toEqual(
[],
);
});
it("SKT durumu: geçmiş, 7 gün içinde, sorunsuz", () => {
expect(expiryStatus("2026-11-05", "2026-11-06")).toBe("expired");
expect(expiryStatus("2026-11-06", "2026-11-06")).toBe("soon");
expect(expiryStatus("2026-11-13", "2026-11-06")).toBe("soon");
expect(expiryStatus("2026-11-14", "2026-11-06")).toBe("ok");
const lots = fifoLots([
m("purchase", "2026-10-01", 1_000, "2026-11-20"),
m("purchase", "2026-10-02", 1_000, "2026-11-08"),
m("purchase", "2026-10-03", 1_000, "2026-11-01"),
m("purchase", "2026-10-04", 1_000),
]);
expect(expiringLots(lots, "2026-11-06").map((l) => [l.expiryDate, l.status])).toEqual([
["2026-11-01", "expired"],
["2026-11-08", "soon"],
]);
});
});
describe("eşik ve öneri", () => {
it("%20 eşiğinin altı azalan sayılır", () => {
expect(stockLevel(1_000, 5_000, 20)).toEqual({ thresholdQuantity: 1_000, pct: 20, low: false });
expect(stockLevel(999, 5_000, 20).low).toBe(true);
expect(stockLevel(-200, 5_000, 20)).toMatchObject({ pct: 0, low: true });
expect(stockLevel(3_000, 10_000, 35)).toMatchObject({ thresholdQuantity: 3_500, low: true });
expect(stockLevel(0, 0, 20)).toEqual({ thresholdQuantity: 0, pct: null, low: false });
expect(refillQuantity(800, 5_000)).toBe(4_200);
expect(refillQuantity(-300, 5_000)).toBe(5_000);
expect(refillQuantity(6_000, 5_000)).toBe(0);
});
it("form metni gruplamasız ve geri okunabilir", () => {
expect(quantityInput(1_000_000)).toBe("1000");
expect(quantityInput(1_500)).toBe("1,5");
expect(quantityInput(250)).toBe("0,25");
});
it("azaltma adımı etiketi", () => {
expect(stepLabel(1_000, "adet")).toBe("−1 adet");
expect(stepLabel(500, "kg")).toBe("−0,5 kg");
});
});
+165
View File
@@ -0,0 +1,165 @@
import { daysBetween } from "./budget";
import { formatQuantity, QUANTITY_SCALE } from "./quantity";
/**
* Ev stoğu motoru (Bölüm 6.10). Saf fonksiyonlar, framework'ten bağımsız.
*
* Miktarlar binde bir tamsayı (QUANTITY_SCALE). Mevcut miktar hareketlerden türetilir:
* alım (+) − tüketim − fire ± sayım düzeltmesi.
* SKT: alım hareketinde (isteğe bağlı). Çıkışlar (tüketim, fire, eksi sayım farkı) FIFO ile en
* eski girişten düşülür; evde kalan miktarın SKT'si uyarı için kullanılır.
*/
export const STOCK_MOVEMENT_KINDS = ["purchase", "consume", "waste", "count_adjust"] as const;
export type StockMovementKind = (typeof STOCK_MOVEMENT_KINDS)[number];
export const WASTE_REASONS = ["spoiled", "expired", "damaged", "other"] as const;
export type WasteReason = (typeof WASTE_REASONS)[number];
/** Hazır birimler; bunların dışındaki her değer özel birimdir ("yıkama", "tablet"). */
export const STOCK_UNITS = ["adet", "kg", "g", "lt", "ml", "paket"] as const;
export const DEFAULT_THRESHOLD_PCT = 20;
/** SKT'ye bu kadar gün (dahil) kala uyarı. */
export const EXPIRY_WARN_DAYS = 7;
export type StockMove = {
kind: StockMovementKind;
/** "YYYY-MM-DD". */
date: string;
/** Pozitif; yalnızca sayım düzeltmesi işaretli (+ fazla çıktı, − eksik çıktı). */
quantity: number;
expiryDate?: string | null;
};
/** Hareketin stoka etkisi (işaretli). */
export function signedDelta(m: Pick<StockMove, "kind" | "quantity">): number {
switch (m.kind) {
case "purchase":
return m.quantity;
case "consume":
case "waste":
return -m.quantity;
case "count_adjust":
return m.quantity;
}
}
export function currentQuantity(moves: readonly Pick<StockMove, "kind" | "quantity">[]): number {
return moves.reduce((sum, m) => sum + signedDelta(m), 0);
}
export type Lot = {
/** Girişin hareket listesindeki sırası. */
index: number;
date: string;
expiryDate: string | null;
quantity: number;
remaining: number;
};
/**
* FIFO partileri. `moves` tarih (ve oluşturulma) sırasında olmalı. Girişler (alım, artı sayım
* farkı) parti açar; çıkışlar en eski partiden düşer. Stok geçici olarak eksiye düşerse (geriye
* tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner.
*/
export function fifoLots(moves: readonly StockMove[]): Lot[] {
const lots: Lot[] = [];
let deficit = 0;
moves.forEach((m, index) => {
const delta = signedDelta(m);
if (delta > 0) {
const covered = Math.min(deficit, delta);
deficit -= covered;
lots.push({
index,
date: m.date,
expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null,
quantity: delta,
remaining: delta - covered,
});
return;
}
let out = -delta;
for (const lot of lots) {
if (out === 0) break;
const take = Math.min(lot.remaining, out);
lot.remaining -= take;
out -= take;
}
deficit += out;
});
return lots.filter((l) => l.remaining > 0);
}
export type ExpiryStatus = "expired" | "soon" | "ok";
export function expiryStatus(
expiryDate: string,
today: string,
warnDays = EXPIRY_WARN_DAYS,
): ExpiryStatus {
if (expiryDate < today) return "expired";
// daysBetween dahil sayar: bugün = 1.
return daysBetween(today, expiryDate) - 1 <= warnDays ? "soon" : "ok";
}
export type ExpiringLot = Lot & { expiryDate: string; status: Exclude<ExpiryStatus, "ok"> };
/** Evde kalan ve SKT'si geçmiş/yaklaşan partiler (en erken SKT önce). */
export function expiringLots(
lots: readonly Lot[],
today: string,
warnDays = EXPIRY_WARN_DAYS,
): ExpiringLot[] {
const out: ExpiringLot[] = [];
for (const lot of lots) {
if (!lot.expiryDate) continue;
const status = expiryStatus(lot.expiryDate, today, warnDays);
if (status !== "ok") out.push({ ...lot, expiryDate: lot.expiryDate, status });
}
return out.sort((a, b) =>
a.expiryDate < b.expiryDate ? -1 : a.expiryDate > b.expiryDate ? 1 : 0,
);
}
export type StockLevel = {
/** Eşik miktarı: hedef × eşik yüzdesi. */
thresholdQuantity: number;
/** Mevcut / hedef (yüzde, tamsayı; hedef yoksa null). */
pct: number | null;
/** Eşiğin altında mı ("altına düşerse": kesin küçük). */
low: boolean;
};
export function stockLevel(current: number, target: number, thresholdPct: number): StockLevel {
if (target <= 0) return { thresholdQuantity: 0, pct: null, low: false };
const thresholdQuantity = Math.round((target * thresholdPct) / 100);
return {
thresholdQuantity,
pct: Math.round((Math.max(0, current) * 100) / target),
low: current < thresholdQuantity,
};
}
/** Basit öneri: hedefe tamamla (Faz 11b'de tüketim hızı ve fireye göre ayarlanır). */
export function refillQuantity(current: number, target: number): number {
return Math.max(0, target - Math.max(0, current));
}
/** Miktar + birim: 1500, "kg" → "1,5 kg". */
export function formatStock(quantity: number, unit: string): string {
return formatQuantity(quantity, unit);
}
/** "1 azalt" düğmesi etiketi: adım 1 birim ise "−1 adet", değilse "−0,5 kg". */
export function stepLabel(step: number, unit: string): string {
return `−${formatQuantity(step || QUANTITY_SCALE, unit)}`;
}
/** Form alanı için gruplamasız metin: 1500 → "1,5"; 1_000_000 → "1000" (parseQuantity ile uyumlu). */
export function quantityInput(quantity: number): string {
const int = Math.trunc(quantity / QUANTITY_SCALE);
const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, "");
return frac ? `${int},${frac}` : String(int);
}