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