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
+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);
}