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