feat(stock): sayım oturumları, fire ayarlı öneri motoru ve tüketim analizi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 10:58:06 +03:00
parent 5e0f1b5901
commit 814bdbbc2a
12 changed files with 1219 additions and 69 deletions
+40
View File
@@ -118,3 +118,43 @@ export const stockCountFormSchema = z.object({
});
export type StockCountFormInput = z.input<typeof stockCountFormSchema>;
export type StockCountForm = z.output<typeof stockCountFormSchema>;
/**
* Toplu sayım satırı: sayılan miktar boşsa ürün sayılmamış sayılır (atlanır). Eksiğin bir kısmı
* fire olarak işaretlenebilir (neden zorunlu).
*/
const stockCountRowSchema = z
.object({
productTypeId: z.string().regex(/^[a-f0-9]{24}$/i),
actualQuantity: optionalQuantity,
wasteQuantity: optionalQuantity.default(null),
wasteReason: z.preprocess(
(x) => (x === "" || x === undefined ? null : x),
z.enum(WASTE_REASONS).nullable(),
),
})
.superRefine((x, ctx) => {
if (x.wasteQuantity && !x.wasteReason)
ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason });
if (x.wasteQuantity && x.actualQuantity === null)
ctx.addIssue({ code: "custom", path: ["actualQuantity"], message: s.quantity });
})
.transform((x) => ({
...x,
wasteQuantity: x.wasteQuantity || null,
wasteReason: x.wasteQuantity ? x.wasteReason : null,
}));
export const stockCountSheetSchema = z
.object({
date: ymd,
categoryId: optionalId,
note: z.string().trim().max(300).default(""),
rows: z.array(stockCountRowSchema).max(500),
})
.superRefine((x, ctx) => {
if (!x.rows.some((r) => r.actualQuantity !== null))
ctx.addIssue({ code: "custom", path: ["rows"], message: s.nothingCounted });
});
export type StockCountSheetInput = z.input<typeof stockCountSheetSchema>;
export type StockCountSheet = z.output<typeof stockCountSheetSchema>;
+128
View File
@@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest";
import {
countSchedule,
currentQuantity,
expiringLots,
expiryStatus,
fifoLots,
monthlyUsage,
quantityInput,
recentWaste,
roundForDisplay,
refillQuantity,
stepLabel,
stockLevel,
suggestPurchase,
usageRate,
type StockMove,
} from "./stock";
@@ -94,8 +100,130 @@ describe("eşik ve öneri", () => {
expect(quantityInput(250)).toBe("0,25");
});
it("gösterim yuvarlaması", () => {
expect(roundForDisplay(18_368)).toBe(18_400);
expect(roundForDisplay(1_672)).toBe(1_670);
expect(roundForDisplay(441.4)).toBe(440);
});
it("azaltma adımı etiketi", () => {
expect(stepLabel(1_000, "adet")).toBe("−1 adet");
expect(stepLabel(500, "kg")).toBe("−0,5 kg");
});
});
describe("fire oranı ve önerilen alım", () => {
const wasteful = [
m("purchase", "2026-11-01", 3_000),
m("waste", "2026-11-02", 900),
m("purchase", "2026-11-03", 3_000),
m("waste", "2026-11-04", 900),
m("consume", "2026-11-05", 4_200),
];
it("fire FIFO ile alımlara yazılır; son alımların oranı", () => {
expect(recentWaste(wasteful)).toEqual({
purchases: 2,
purchased: 6_000,
wasted: 1_800,
pct: 30,
});
// Başlangıç partisinden düşen fire alım oranına girmez.
expect(
recentWaste([
{ ...m("count_adjust", "2026-10-01", 1_000), initial: true } as StockMove,
m("waste", "2026-10-02", 500),
m("purchase", "2026-10-03", 2_000),
]),
).toMatchObject({ purchases: 1, wasted: 0, pct: 0 });
expect(recentWaste([m("consume", "2026-10-01", 1)])).toBeNull();
});
it("%30 fire öneriyi düşürür, adıma yuvarlanır", () => {
const s = suggestPurchase({
current: 0,
target: 3_000,
step: 500,
moves: wasteful,
today: "2026-11-06",
});
// 3 kg × %70 = 2,1 kg → 0,5'in katı 2 kg. Geçmiş 14 günden kısa: kullanım yok.
expect(s).toMatchObject({ base: 3_000, quantity: 2_000, roundTo: 500, usage: null });
expect(s.waste?.pct).toBe(30);
});
it("tüketim hızına (haftalık kullanımın katına) yuvarlanır, hedefi aşmaz", () => {
const moves = [
{ ...m("count_adjust", "2026-10-10", 10_000), initial: true },
m("consume", "2026-11-01", 8_400),
];
expect(usageRate(moves, "2026-11-06")).toEqual({ perDay: 300, days: 28 });
const s = suggestPurchase({
current: 1_600,
target: 6_000,
step: 500,
moves,
today: "2026-11-06",
});
// Haftalık 2,1 → 2 kg; taban 4,4 → 2 hafta = 4 kg; (1,6 + 4) / 0,3 = 18 gün.
expect(s).toMatchObject({
base: 4_400,
quantity: 4_000,
roundTo: 2_000,
roundedByUsage: true,
waste: null,
coverDays: 18,
});
// Taban küçükse en az bir adım; hedef doluysa öneri yok.
expect(
suggestPurchase({ current: 950, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
.quantity,
).toBe(100);
expect(
suggestPurchase({ current: 1_000, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
.quantity,
).toBe(0);
});
});
describe("sayım takvimi ve aylık kullanım", () => {
it("son sayımdan n gün sonra sayım gelir", () => {
expect(countSchedule(15, "2026-10-20", "2026-01-01", "2026-11-06")).toEqual({
everyDays: 15,
lastDate: "2026-10-20",
nextDate: "2026-11-04",
due: true,
overdueDays: 2,
});
expect(countSchedule(15, null, "2026-10-25", "2026-11-06")).toMatchObject({
nextDate: "2026-11-09",
due: false,
overdueDays: 0,
});
expect(countSchedule(7, "2026-10-30", "2026-10-01", "2026-11-06")).toMatchObject({
due: true,
overdueDays: 0,
});
expect(countSchedule(null, null, "2026-10-01", "2026-11-06")).toBeNull();
});
it("kullanım = tüketim + sayım eksiği; fire ayrı", () => {
expect(
monthlyUsage(
[
{ ...m("count_adjust", "2026-10-01", 5_000), initial: true },
m("consume", "2026-10-05", 1_000),
m("count_adjust", "2026-10-10", -200),
m("waste", "2026-10-12", 300),
m("count_adjust", "2026-10-15", 100),
m("consume", "2026-11-02", 500),
],
["2026-09", "2026-10", "2026-11"],
),
).toEqual([
{ month: "2026-09", used: 0, wasted: 0 },
{ month: "2026-10", used: 1_200, wasted: 300 },
{ month: "2026-11", used: 500, wasted: 0 },
]);
});
});
+219 -9
View File
@@ -1,4 +1,5 @@
import { daysBetween } from "./budget";
import { addDays } from "./recurrence";
import { formatQuantity, QUANTITY_SCALE } from "./quantity";
/**
@@ -58,13 +59,11 @@ export type Lot = {
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[] = [];
type TrackedLot = Lot & { kind: StockMovementKind; wasted: number };
/** FIFO çalıştırır; her girişin kalanını ve fireyle düşülen kısmını izler. */
function runFifo(moves: readonly StockMove[]): TrackedLot[] {
const lots: TrackedLot[] = [];
let deficit = 0;
moves.forEach((m, index) => {
const delta = signedDelta(m);
@@ -73,10 +72,12 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] {
deficit -= covered;
lots.push({
index,
kind: m.kind,
date: m.date,
expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null,
quantity: delta,
remaining: delta - covered,
wasted: 0,
});
return;
}
@@ -85,11 +86,29 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] {
if (out === 0) break;
const take = Math.min(lot.remaining, out);
lot.remaining -= take;
if (m.kind === "waste") lot.wasted += take;
out -= take;
}
deficit += out;
});
return lots.filter((l) => l.remaining > 0);
return lots;
}
/**
* 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[] {
return runFifo(moves)
.filter((l) => l.remaining > 0)
.map(({ index, date, expiryDate, quantity, remaining }) => ({
index,
date,
expiryDate,
quantity,
remaining,
}));
}
export type ExpiryStatus = "expired" | "soon" | "ok";
@@ -142,7 +161,7 @@ export function stockLevel(current: number, target: number, thresholdPct: number
};
}
/** Basit öneri: hedefe tamamla (Faz 11b'de tüketim hızı ve fireye göre ayarlanır). */
/** Hedefe tamamlayan miktar (öneri motorunun tabanı). */
export function refillQuantity(current: number, target: number): number {
return Math.max(0, target - Math.max(0, current));
}
@@ -157,9 +176,200 @@ export function stepLabel(step: number, unit: string): string {
return `−${formatQuantity(step || QUANTITY_SCALE, unit)}`;
}
/** Ortalama/hız gösterimi: 10 birimden büyükse bir, değilse iki ondalık (18,368 → 18,4). */
export function roundForDisplay(quantity: number): number {
const unit = Math.abs(quantity) >= 10 * QUANTITY_SCALE ? 100 : 10;
return Math.round(quantity / unit) * 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);
}
// --- Öneri, tüketim hızı, sayım ve analiz (Faz 11b) ---
/** Fire oranı son bu kadar alımdan hesaplanır. */
export const WASTE_LOOKBACK_PURCHASES = 3;
/** Bu oranın (%) altındaki fire öneriyi değiştirmez. */
export const WASTE_MIN_PCT = 5;
/** Tüketim hızı son bu kadar günden hesaplanır. */
export const USAGE_WINDOW_DAYS = 90;
/** Bundan kısa geçmişte tüketim hızı hesaplanmaz. */
export const USAGE_MIN_DAYS = 14;
export type WasteStat = {
/** Hesaba katılan alım sayısı. */
purchases: number;
purchased: number;
wasted: number;
/** Fire oranı (%, tamsayı). */
pct: number;
};
/**
* Son alımların fire oranı: fireler FIFO ile hangi alımdan düşüldüyse ona yazılır
* (ör. iki alımın 6 kg'ından 1,8 kg'ı bozuldu → %30). Alım yoksa null.
*/
export function recentWaste(
moves: readonly StockMove[],
lookback = WASTE_LOOKBACK_PURCHASES,
): WasteStat | null {
const purchases = runFifo(moves)
.filter((l) => l.kind === "purchase")
.slice(-lookback);
if (purchases.length === 0) return null;
const purchased = purchases.reduce((s, l) => s + l.quantity, 0);
const wasted = purchases.reduce((s, l) => s + l.wasted, 0);
return {
purchases: purchases.length,
purchased,
wasted,
pct: purchased > 0 ? Math.round((wasted * 100) / purchased) : 0,
};
}
/** Kullanım: tüketim + sayımda çıkan eksik (başlangıç hariç). Fire ayrı tutulur. */
function usageOf(m: StockMove & { initial?: boolean }): number {
if (m.kind === "consume") return m.quantity;
if (m.kind === "count_adjust" && !m.initial && m.quantity < 0) return -m.quantity;
return 0;
}
export type UsageRate = {
/** Günlük ortalama kullanım (binde bir, kesirli olabilir). */
perDay: number;
/** Hesaba katılan gün sayısı. */
days: number;
};
/**
* Son `window` günün günlük ortalama kullanımı. Pencere, ilk hareketten önce başlıyorsa ilk
* hareketten sayılır. Geçmiş `USAGE_MIN_DAYS`'ten kısaysa null.
*/
export function usageRate(
moves: readonly (StockMove & { initial?: boolean })[],
today: string,
window = USAGE_WINDOW_DAYS,
): UsageRate | null {
const first = moves[0]?.date;
if (!first) return null;
const windowStart = addDays(today, -(window - 1));
const start = first > windowStart ? first : windowStart;
if (start > today) return null;
const days = daysBetween(start, today);
if (days < USAGE_MIN_DAYS) return null;
const used = moves
.filter((m) => m.date >= start && m.date <= today)
.reduce((s, m) => s + usageOf(m), 0);
return { perDay: used / days, days };
}
export type PurchaseSuggestion = {
/** Önerilen alım (binde bir). */
quantity: number;
/** Hedefe tamamlayan ham miktar. */
base: number;
/** Öneriyi düşüren fire (oran WASTE_MIN_PCT'nin altındaysa null). */
waste: WasteStat | null;
/** Yuvarlama birimi: haftalık kullanım (adımın katı) veya adım. */
roundTo: number;
roundedByUsage: boolean;
usage: UsageRate | null;
/** Önerilen alım + mevcut kaç gün yeter (kullanım bilinmiyorsa null). */
coverDays: number | null;
};
const roundUp = (x: number, unit: number) => Math.ceil(x / unit) * unit;
/**
* Önerilen alım miktarı (Bölüm 6.10):
* 1. Taban = hedef − mevcut.
* 2. Son alımlarda fire varsa (≥ %5) taban fire oranı kadar düşürülür.
* 3. Tüketim hızına göre yuvarlanır: haftalık kullanım adımdan büyükse haftalık kullanımın
* (adıma yuvarlanmış) katına, değilse adıma en yakın kata. Sonuç tabanı (adıma yukarı
* yuvarlanmış) aşmaz; taban > 0 iken en az bir adımdır.
*/
export function suggestPurchase(input: {
current: number;
target: number;
step: number;
moves: readonly (StockMove & { initial?: boolean })[];
today: string;
}): PurchaseSuggestion {
const step = Math.max(1, input.step);
const base = refillQuantity(input.current, input.target);
const stat = recentWaste(input.moves);
const waste = stat && stat.pct >= WASTE_MIN_PCT && stat.wasted > 0 ? stat : null;
const usage = usageRate(input.moves, input.today);
const weekly = usage ? Math.round((usage.perDay * 7) / step) * step : 0;
const roundTo = weekly > step ? weekly : step;
let quantity = 0;
if (base > 0) {
const reduced = waste ? (base * (100 - waste.pct)) / 100 : base;
quantity = Math.round(reduced / roundTo) * roundTo;
if (quantity === 0) quantity = roundUp(reduced, step);
quantity = Math.max(step, Math.min(quantity, roundUp(base, step)));
}
const after = Math.max(0, input.current) + quantity;
return {
quantity,
base,
waste,
roundTo,
roundedByUsage: roundTo !== step,
usage,
coverDays: usage && usage.perDay > 0 ? Math.floor(after / usage.perDay) : null,
};
}
export type CountSchedule = {
everyDays: number;
/** Son sayım (yoksa null). */
lastDate: string | null;
nextDate: string;
due: boolean;
/** Sayım günü geçtiyse kaç gün geçti (bugün sayım günüyse 0). */
overdueDays: number;
};
/**
* Periyodik sayım takvimi: son sayımdan (hiç sayılmadıysa `since`'ten, ör. ilk hareket)
* `everyDays` gün sonra sayım gelir. Sıklık yoksa null.
*/
export function countSchedule(
everyDays: number | null,
lastDate: string | null,
since: string,
today: string,
): CountSchedule | null {
if (!everyDays) return null;
const nextDate = addDays(lastDate ?? since, everyDays);
const due = nextDate <= today;
return {
everyDays,
lastDate,
nextDate,
due,
overdueDays: due ? daysBetween(nextDate, today) - 1 : 0,
};
}
export type MonthUsage = { month: string; used: number; wasted: number };
/** Ay ay ("YYYY-MM") kullanım ve fire. */
export function monthlyUsage(
moves: readonly (StockMove & { initial?: boolean })[],
months: readonly string[],
): MonthUsage[] {
const by = new Map(months.map((m) => [m, { month: m, used: 0, wasted: 0 }]));
for (const mv of moves) {
const row = by.get(mv.date.slice(0, 7));
if (!row) continue;
row.used += usageOf(mv);
if (mv.kind === "waste") row.wasted += mv.quantity;
}
return months.map((m) => by.get(m)!);
}