feat: Faz 11b - periyodik sayım, fire ayarlı öneri ve tüketim analizi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+23
-12
@@ -6,6 +6,27 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
## [0.11.0] - 2026-11-06
|
||||
|
||||
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
||||
|
||||
### Eklendi — Faz 11b: periyodik sayım, fire ayarlı öneri, tüketim analizi
|
||||
|
||||
- Sayım oturumları (`stockCounts`): her sayımda ürün başına beklenen, sayılan ve fireye ayrılan miktar; fark sıfır olsa da kayıt tutulur ve oluşan hareketler oturuma bağlanır (`countId`). Ürün detayındaki tek ürün sayımı da oturum oluşturur.
|
||||
- Periyodik sayım ekranı (`/stok/sayim?kategori=…`): kategori (alt ağacıyla) veya tüm stok; liste hâlinde beklenen → sayılan, anlık fark; eksiğin bir kısmı nedenle fire olarak işaretlenebilir (fire, eksikten fazla olamaz), kalan fark sayım düzeltmesi olur. "Yalnızca sayımı gelenler", "Beklenenle doldur", tek transaction'da kayıt.
|
||||
- Sayım takvimi: sıklık ürün tipinden, yoksa stok kategorisinden (üstten devralınır); son sayımdan (hiç sayılmadıysa ilk hareketten) n gün sonra sayım gelir. `/stok`'ta "Sayımı gelenler" kartı (kategori, ürün sayısı, gecikme) ve dashboard hatırlatması.
|
||||
- Önerilen alım miktarı motoru: hedef − mevcut; son 3 alımda fire varsa (fireler FIFO ile hangi alımdan düştüyse ona yazılır, ≥ %5) fire oranı kadar düşürülür; haftalık tüketim adımdan büyükse haftalık tüketimin katına, değilse adıma yuvarlanır; hedefi aşmaz. Gerekçe gösterilir ("Son 2 alımda %30 fire, miktar azaltıldı.", "Haftalık tüketime (0,3 kg) göre yuvarlandı.", "Mevcutla birlikte yaklaşık 46 gün yeter.").
|
||||
- Tüketim hızı (son 90 gün; tüketim + sayımda çıkan eksik, fire hariç) ve periyodik sayım durumu ürün detayında.
|
||||
- Tüketim analizi (`/stok/analiz`): ürün tipi başına son 6 ayın tüketim/fire çubukları, aylık ortalama tüketim ve fire, fire oranı (≥ %15 vurgulu), bu ayın tüketimi. Maliyet ürün fiyatlarıyla (Faz 12) eklenecek.
|
||||
- Migration `012-stock-counts`: index'ler; Faz 11a'da oturumsuz kaydedilen sayım düzeltmeleri, beklenen miktar yeniden hesaplanarak sayım oturumlarına bağlanır.
|
||||
- Seed: Çakır Ailesi'nde Mutfak 15, Genel Temizlik 30 günde bir sayılır; sayım oturumları ve ara sıra sayım farkı; Mutfak'ın sayımı gecikmiş.
|
||||
- Testler: FIFO fire dağılımı, son alımların fire oranı, öneri (fire indirimi, tüketim hızına yuvarlama, hedef sınırı), sayım takvimi, aylık kullanım (birim); seed sayım takvimi, toplu sayım (fire + düzeltme, oturum, sıfır fark, yetki), tüketim analizi (entegrasyon).
|
||||
|
||||
### Değişti — Faz 11b
|
||||
|
||||
- Dashboard stok kartı "Stok uyarıları" oldu: azalan ürünler, SKT yaklaşan partiler ve sayımı gelen kategoriler.
|
||||
- Seed'deki sayım farkları artık periyodik sayım oturumlarında oluşur.
|
||||
|
||||
### Eklendi — Faz 11a: ev stoğu
|
||||
|
||||
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç; varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanede ve migration ile mevcut hanelere yüklenir. Her kategori bir gider kategorisine eşlenebilir (alt kategoriler üstten devralır; Faz 13'te alışveriş gideri için) ve sayım sıklığı tutar. Taşıma, arşiv (içinde aktif ürün yoksa) ve arşivden çıkarma; `/stok/kategoriler`.
|
||||
@@ -17,17 +38,6 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık alım/tüketim/fire/sayım simülasyonu.
|
||||
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, izolasyon, hareket kuralları, sayım, kategori kuralları (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 11a: ev stoğu
|
||||
|
||||
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç (en fazla 5 seviye). Varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanelerde ve migration ile mevcut hanelerde kurulur. Her kategori bir gider kategorisine eşlenebilir (üstten devralınır; alışveriş tamamlama için), sayım sıklığı alanı (üstten devralınır). `/stok/kategoriler`: ekle, düzenle, taşı, arşivle (içinde aktif ürün varken arşivlenemez).
|
||||
- Ürün tipleri (`productTypes`): kategori, temel birim (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik (%20 varsayılan), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki mevcut miktar. Arşivleme.
|
||||
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı son kullanma tarihi), tüketim, fire/zarar (neden ile), sayım düzeltmesi (gerçek miktar girilir, fark otomatik). Mevcut miktar hareketlerden türetilir; stoktan fazla tüketim/fire reddedilir; hareket silme denetim kaydına yazılır.
|
||||
- SKT takibi FIFO: çıkışlar en eski alımdan düşülür; evde kalan partilerin SKT'si 7 gün içinde dolan veya geçmiş olanlar uyarılır.
|
||||
- `/stok`: özet (ürün, azalan, SKT yaklaşan/geçmiş), "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT listesi, kategoriye göre ürünler (mevcut/hedef çubuğu ve eşik çizgisi), arama, "yalnızca azalanlar", tek dokunuşla "1 azalt". `/stok/:id`: durum, partiler, hareketler, alım/tüketim/fire/sayım.
|
||||
- Dashboard "Azalan stoklar" kartı.
|
||||
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık tüketim, alım, fire ve sayım hareketleri.
|
||||
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, gider eşlemesi, izolasyon, hareket kuralları, sayım, arşiv ve kategori kuralları (entegrasyon).
|
||||
|
||||
## [0.10.0] - 2026-11-06
|
||||
|
||||
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
|
||||
@@ -340,7 +350,8 @@ Faz 1: Framework, klasör yapısı ve altyapı.
|
||||
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
||||
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
||||
|
||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...HEAD
|
||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.11.0...HEAD
|
||||
[0.11.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...v0.11.0
|
||||
[0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0
|
||||
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
|
||||
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
|
||||
|
||||
@@ -322,6 +322,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
||||
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
||||
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
|
||||
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
|
||||
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
|
||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||
|
||||
---
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "butcem",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Types } from "mongoose";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { signedDelta, type StockMovementKind } from "@/shared/stock";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
type Move = {
|
||||
_id: Types.ObjectId;
|
||||
householdId: Types.ObjectId;
|
||||
productTypeId: Types.ObjectId;
|
||||
kind: StockMovementKind;
|
||||
date: string;
|
||||
quantity: number;
|
||||
initial: boolean;
|
||||
countId: Types.ObjectId | null;
|
||||
createdBy: Types.ObjectId;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Faz 11b: sayım oturumları. Faz 11a'da tek ürün sayımıyla oluşan (oturumsuz) sayım
|
||||
* düzeltmeleri, o ana kadarki kayıtlardan beklenen miktar hesaplanarak hane + tarih + kişi
|
||||
* başına bir sayım oturumuna bağlanır. Başlangıç miktarları sayım sayılmaz.
|
||||
*/
|
||||
export const migration: Migration = {
|
||||
name: "012-stock-counts",
|
||||
description: "stockCounts index'leri; oturumsuz sayım düzeltmelerini sayım oturumuna bağla",
|
||||
async up() {
|
||||
await StockCountModel.createIndexes();
|
||||
const orphans = await StockMovementModel.find({
|
||||
kind: "count_adjust",
|
||||
initial: { $ne: true },
|
||||
countId: null,
|
||||
deletedAt: null,
|
||||
}).lean<Move[]>();
|
||||
if (orphans.length === 0) return;
|
||||
const products = [...new Set(orphans.map((o) => String(o.productTypeId)))];
|
||||
const all = await StockMovementModel.find({
|
||||
productTypeId: { $in: products.map((id) => new Types.ObjectId(id)) },
|
||||
deletedAt: null,
|
||||
}).lean<Move[]>();
|
||||
const byProduct = new Map<string, Move[]>();
|
||||
for (const m of all) {
|
||||
const list = byProduct.get(String(m.productTypeId)) ?? [];
|
||||
list.push(m);
|
||||
byProduct.set(String(m.productTypeId), list);
|
||||
}
|
||||
// Her düzeltme için beklenen = önceki hareketlerin toplamı (tarih, oluşturulma sırası).
|
||||
const expectedOf = new Map<string, number>();
|
||||
for (const list of byProduct.values()) {
|
||||
list.sort((a, b) =>
|
||||
a.date === b.date
|
||||
? a.createdAt.getTime() - b.createdAt.getTime()
|
||||
: a.date < b.date
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
let running = 0;
|
||||
for (const m of list) {
|
||||
expectedOf.set(String(m._id), running);
|
||||
running += signedDelta(m);
|
||||
}
|
||||
}
|
||||
const sessions = new Map<string, Move[]>();
|
||||
for (const o of orphans) {
|
||||
const key = `${o.householdId}:${o.date}:${o.createdBy}`;
|
||||
sessions.set(key, [...(sessions.get(key) ?? []), o]);
|
||||
}
|
||||
for (const moves of sessions.values()) {
|
||||
const first = moves[0]!;
|
||||
const count = await StockCountModel.create({
|
||||
householdId: first.householdId,
|
||||
date: first.date,
|
||||
categoryId: null,
|
||||
items: moves.map((m) => {
|
||||
const expected = expectedOf.get(String(m._id)) ?? 0;
|
||||
return {
|
||||
productTypeId: m.productTypeId,
|
||||
expected,
|
||||
actual: expected + m.quantity,
|
||||
wasted: 0,
|
||||
};
|
||||
}),
|
||||
createdBy: first.createdBy,
|
||||
});
|
||||
await StockMovementModel.updateMany(
|
||||
{ _id: { $in: moves.map((m) => m._id) } },
|
||||
{ $set: { countId: count._id } },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { migration as m008 } from "./008-in-kind";
|
||||
import { migration as m009 } from "./009-assets";
|
||||
import { migration as m010 } from "./010-budgets";
|
||||
import { migration as m011 } from "./011-stock";
|
||||
import { migration as m012 } from "./012-stock-counts";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
@@ -26,6 +27,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
m009,
|
||||
m010,
|
||||
m011,
|
||||
m012,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
@@ -83,6 +83,7 @@ const DOMAIN_COLLECTIONS = [
|
||||
"stockCategories",
|
||||
"productTypes",
|
||||
"stockMovements",
|
||||
"stockCounts",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
|
||||
+72
-17
@@ -2,6 +2,7 @@ import { Types } from "mongoose";
|
||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StockCategoryModel } from "@/models/stock-category";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
@@ -9,8 +10,10 @@ import { foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Örnek ev stoğu (Faz 11). Varsayılan stok kategorileri her haneye; Çakır Ailesi'ne ürün tipleri
|
||||
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, sayım, SKT). Deterministik;
|
||||
* idempotent: ürün tipi `seedRef` ile bir kez oluşturulur. Miktarlar binde bir.
|
||||
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, periyodik sayım, SKT).
|
||||
* Sayım: Mutfak 15 günde bir (son sayım 21 gün önce → gecikmiş), Genel Temizlik 30 günde bir
|
||||
* (son sayım 14 gün önce). Deterministik; idempotent: ürün tipi `seedRef` ile bir kez
|
||||
* oluşturulur, sayım oturumları `seedRef` ile paylaşılır. Miktarlar binde bir.
|
||||
*/
|
||||
|
||||
type SeedProduct = {
|
||||
@@ -30,8 +33,8 @@ type SeedProduct = {
|
||||
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
|
||||
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
||||
stopRefillDaysAgo?: number;
|
||||
/** Her n haftada bir küçük sayım farkı (−%). */
|
||||
countDrift?: { everyWeeks: number; pct: number };
|
||||
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||||
countDrift?: { everyCounts: number; pct: number };
|
||||
};
|
||||
|
||||
const K = 1000;
|
||||
@@ -188,7 +191,7 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
pack: 50 * K,
|
||||
perDay: 700,
|
||||
location: "Banyo dolabı",
|
||||
countDrift: { everyWeeks: 8, pct: 4 },
|
||||
countDrift: { everyCounts: 2, pct: 4 },
|
||||
},
|
||||
{
|
||||
ref: "yumusatici",
|
||||
@@ -221,7 +224,7 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
pack: 16 * K,
|
||||
perDay: 600,
|
||||
location: "Banyo dolabı",
|
||||
countDrift: { everyWeeks: 10, pct: 6 },
|
||||
countDrift: { everyCounts: 3, pct: 6 },
|
||||
},
|
||||
// Kişisel Temizlik
|
||||
{
|
||||
@@ -259,6 +262,12 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
|
||||
const START_DAYS_AGO = 182;
|
||||
|
||||
/** Sayım sıklığı (gün) ve sayım günü kuralı (gün önce), ana stok kategorisine göre. */
|
||||
const COUNT_PLANS: Record<string, { everyDays: number; on: (daysAgo: number) => boolean }> = {
|
||||
mutfak: { everyDays: 15, on: (d) => d >= 21 && d % 14 === 7 },
|
||||
"genel-temizlik": { everyDays: 30, on: (d) => d % 28 === 14 },
|
||||
};
|
||||
|
||||
type Row = {
|
||||
kind: "purchase" | "consume" | "waste" | "count_adjust";
|
||||
daysAgo: number;
|
||||
@@ -267,17 +276,24 @@ type Row = {
|
||||
wasteReason?: string | null;
|
||||
note?: string;
|
||||
initial?: boolean;
|
||||
/** Bu satır hangi sayıma ait (gün önce). */
|
||||
countDaysAgo?: number;
|
||||
};
|
||||
|
||||
/** Haftalık simülasyon: tüketim, eşikte alım, fire ve sayım farkı. Stok asla eksiye düşmez. */
|
||||
export function simulate(p: SeedProduct, today: string): Row[] {
|
||||
export type SeedCount = { daysAgo: number; expected: number; actual: number };
|
||||
|
||||
/**
|
||||
* Haftalık simülasyon: tüketim, periyodik sayım (farkı nadiren), eşikte alım ve fire. Stok asla
|
||||
* eksiye düşmez.
|
||||
*/
|
||||
export function simulate(p: SeedProduct, today: string): { rows: Row[]; counts: SeedCount[] } {
|
||||
const rows: Row[] = [];
|
||||
const counts: SeedCount[] = [];
|
||||
const plan = COUNT_PLANS[p.category.split(".")[0]!];
|
||||
let stock = p.target;
|
||||
let purchases = 0;
|
||||
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true });
|
||||
let week = 0;
|
||||
for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) {
|
||||
week++;
|
||||
// Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg).
|
||||
const step = p.step ?? K;
|
||||
const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step));
|
||||
@@ -285,12 +301,21 @@ export function simulate(p: SeedProduct, today: string): Row[] {
|
||||
rows.push({ kind: "consume", daysAgo: d, quantity: consume });
|
||||
stock -= consume;
|
||||
}
|
||||
if (p.countDrift && week % p.countDrift.everyWeeks === 0 && stock > 0) {
|
||||
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
|
||||
if (drift > 0) {
|
||||
rows.push({ kind: "count_adjust", daysAgo: d, quantity: -drift, note: "Sayım" });
|
||||
if (plan?.on(d)) {
|
||||
const expected = stock;
|
||||
const nth = counts.length + 1;
|
||||
if (p.countDrift && nth % p.countDrift.everyCounts === 0 && stock > 0) {
|
||||
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
|
||||
rows.push({
|
||||
kind: "count_adjust",
|
||||
daysAgo: d,
|
||||
quantity: -drift,
|
||||
note: "Sayım",
|
||||
countDaysAgo: d,
|
||||
});
|
||||
stock -= drift;
|
||||
}
|
||||
counts.push({ daysAgo: d, expected, actual: stock });
|
||||
}
|
||||
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
|
||||
if (canRefill && stock < (p.target * 35) / 100) {
|
||||
@@ -311,7 +336,7 @@ export function simulate(p: SeedProduct, today: string): Row[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
return { rows, counts };
|
||||
}
|
||||
|
||||
export async function seedStock(
|
||||
@@ -321,6 +346,12 @@ export async function seedStock(
|
||||
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
// Periyodik sayım sıklığı (kullanıcı ayarladıysa dokunulmaz).
|
||||
for (const [key, plan] of Object.entries(COUNT_PLANS))
|
||||
await StockCategoryModel.updateOne(
|
||||
{ householdId, defaultKey: key, countEveryDays: null },
|
||||
{ $set: { countEveryDays: plan.everyDays } },
|
||||
);
|
||||
const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } })
|
||||
.select({ defaultKey: 1 })
|
||||
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
|
||||
@@ -344,9 +375,33 @@ export async function seedStock(
|
||||
updatedBy: actor,
|
||||
});
|
||||
const base = Date.now() - START_DAYS_AGO * 86_400_000;
|
||||
const { rows, counts } = simulate(p, today);
|
||||
// Sayım oturumu kategori başına ortak: ürün satırı oturuma eklenir.
|
||||
const root = p.category.split(".")[0]!;
|
||||
const countIds = new Map<number, Types.ObjectId>();
|
||||
for (const c of counts) {
|
||||
const session = await StockCountModel.findOneAndUpdate(
|
||||
{ householdId, seedRef: `${root}:${c.daysAgo}` },
|
||||
{
|
||||
$setOnInsert: {
|
||||
householdId,
|
||||
date: addDays(today, -c.daysAgo),
|
||||
categoryId: catByKey.get(root) ?? null,
|
||||
note: "",
|
||||
seedRef: `${root}:${c.daysAgo}`,
|
||||
createdBy: actor,
|
||||
},
|
||||
$push: {
|
||||
items: { productTypeId: doc._id, expected: c.expected, actual: c.actual, wasted: 0 },
|
||||
},
|
||||
},
|
||||
{ upsert: true, returnDocument: "after" },
|
||||
).lean<{ _id: Types.ObjectId }>();
|
||||
countIds.set(c.daysAgo, session!._id);
|
||||
}
|
||||
// Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
|
||||
await StockMovementModel.collection.insertMany(
|
||||
simulate(p, today).map((r, i) => {
|
||||
rows.map((r, i) => {
|
||||
const at = new Date(base + i * 1000);
|
||||
return {
|
||||
householdId,
|
||||
@@ -358,7 +413,7 @@ export async function seedStock(
|
||||
expiryDate: r.expiryDate ?? null,
|
||||
note: r.note ?? "",
|
||||
initial: r.initial ?? false,
|
||||
countId: null,
|
||||
countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
|
||||
deletedAt: null,
|
||||
seedRef: `${p.ref}:${i}`,
|
||||
createdBy: actor,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { StockAnalysisView } from "@/features/stock/components/stock-analysis-view";
|
||||
import { getStockAnalysis } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stock.analysis.title };
|
||||
|
||||
/** /stok/analiz — ürün tipi başına aylık ortalama tüketim ve fire. */
|
||||
export default async function StockAnalysisPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await getStockAnalysis(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return <StockAnalysisView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CountSheet } from "@/features/stock/components/count-sheet";
|
||||
import { getCountSheet } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stock.count.title };
|
||||
|
||||
/** /stok/sayim?kategori=<id> — periyodik sayım: beklenen → sayılan (kategori alt ağacıyla). */
|
||||
export default async function StockCountPage({ searchParams }: PageProps<"/stok/sayim">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const sp = await searchParams;
|
||||
const kategori =
|
||||
typeof sp.kategori === "string" && /^[a-f0-9]{24}$/i.test(sp.kategori) ? sp.kategori : null;
|
||||
const data = await getCountSheet(user.id, household.id, kategori);
|
||||
if (!data) notFound();
|
||||
return <CountSheet key={`${kategori ?? "all"}-${data.today}`} data={data} />;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 11.1;
|
||||
export const CURRENT_PHASE = 11.2;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
productTypeFormSchema,
|
||||
stockCategoryFormSchema,
|
||||
stockCountFormSchema,
|
||||
stockCountSheetSchema,
|
||||
stockMovementFormSchema,
|
||||
} from "@/shared/schemas/stock";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
quickConsume,
|
||||
setProductTypeArchived,
|
||||
setStockCategoryArchived,
|
||||
submitCount,
|
||||
updateProductType,
|
||||
updateStockCategory,
|
||||
type StockOption,
|
||||
@@ -106,6 +108,11 @@ export async function countStockAction(productTypeId: unknown, input: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Toplu sayım (/stok/sayim). */
|
||||
export async function submitCountAction(input: unknown) {
|
||||
return run(stockCountSheetSchema, input, submitCount);
|
||||
}
|
||||
|
||||
export async function deleteStockMovementAction(movementId: unknown) {
|
||||
return withId(movementId, (id) =>
|
||||
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ClipboardCheck, Eraser, ListChecks } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseQuantity } from "@/shared/quantity";
|
||||
import { formatStock, quantityInput, WASTE_REASONS, type WasteReason } from "@/shared/stock";
|
||||
import { submitCountAction } from "../actions";
|
||||
import type { CountSheet as Data, CountSheetRow } from "../service";
|
||||
import { ymdLabel } from "./stock-view";
|
||||
|
||||
const t = tr.stock;
|
||||
const ALL = "__all__";
|
||||
|
||||
type Entry = { actual: string; waste: string; reason: WasteReason | "" };
|
||||
const EMPTY: Entry = { actual: "", waste: "", reason: "" };
|
||||
|
||||
function signed(q: number, unit: string) {
|
||||
if (q === 0) return t.count.same;
|
||||
return `${q > 0 ? "+" : "−"}${formatStock(Math.abs(q), unit)}`;
|
||||
}
|
||||
|
||||
/** /stok/sayim — liste hâlinde beklenen → sayılan; fark sayım düzeltmesi, bir kısmı fire. */
|
||||
export function CountSheet({ data }: { data: Data }) {
|
||||
const router = useRouter();
|
||||
const [entries, setEntries] = useState<Record<string, Entry>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [onlyDue, setOnlyDue] = useState(data.rows.some((r) => r.count?.due));
|
||||
const [note, setNote] = useState("");
|
||||
const [pending, start] = useTransition();
|
||||
|
||||
const rows = useMemo(
|
||||
() => (onlyDue ? data.rows.filter((r) => r.count?.due) : data.rows),
|
||||
[data.rows, onlyDue],
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const by = new Map<string, { path: string; rows: CountSheetRow[] }>();
|
||||
for (const r of rows) {
|
||||
const g = by.get(r.categoryId) ?? { path: r.categoryPath, rows: [] };
|
||||
g.rows.push(r);
|
||||
by.set(r.categoryId, g);
|
||||
}
|
||||
return [...by.values()];
|
||||
}, [rows]);
|
||||
const dueTotal = data.rows.filter((r) => r.count?.due).length;
|
||||
const filled = data.rows.filter((r) => entries[r.id]?.actual.trim()).length;
|
||||
|
||||
const set = (id: string, patch: Partial<Entry>) =>
|
||||
setEntries((e) => ({ ...e, [id]: { ...(e[id] ?? EMPTY), ...patch } }));
|
||||
|
||||
const fillExpected = () =>
|
||||
setEntries((e) => {
|
||||
const next = { ...e };
|
||||
for (const r of rows)
|
||||
if (!next[r.id]?.actual.trim())
|
||||
next[r.id] = { ...(next[r.id] ?? EMPTY), actual: quantityInput(Math.max(0, r.expected)) };
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = () =>
|
||||
start(async () => {
|
||||
const sent = data.rows.filter((r) => entries[r.id]?.actual.trim());
|
||||
const r = await submitCountAction({
|
||||
date: data.today,
|
||||
categoryId: data.category?.id ?? "",
|
||||
note,
|
||||
rows: sent.map((row) => {
|
||||
const e = entries[row.id]!;
|
||||
// Fire alanı yalnızca sayımda eksik çıkınca geçerlidir.
|
||||
const actual = parseQuantity(e.actual);
|
||||
const short = actual !== null && actual < row.expected;
|
||||
return {
|
||||
productTypeId: row.id,
|
||||
actualQuantity: e.actual,
|
||||
wasteQuantity: short ? e.waste : "",
|
||||
wasteReason: short ? e.reason : "",
|
||||
};
|
||||
}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const d = r.data as { counted: number; adjusted: number };
|
||||
toast.success(t.count.done(d.counted, d.adjusted));
|
||||
setEntries({});
|
||||
setErrors({});
|
||||
setNote("");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, msg] of Object.entries(r.fieldErrors ?? {})) {
|
||||
const m = /^rows\.(\d+)\.(\w+)$/.exec(key);
|
||||
const row = m ? sent[Number(m[1])] : undefined;
|
||||
if (row && msg) next[`${row.id}.${m![2]}`] = msg;
|
||||
}
|
||||
setErrors(next);
|
||||
toast.error(r.fieldErrors?.rows ?? r.error);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.count.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={data.category ? `${t.count.title}: ${data.category.path}` : t.count.title}
|
||||
description={t.count.description}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={data.category?.id ?? ALL}
|
||||
onValueChange={(v) =>
|
||||
router.push(v === ALL ? "/stok/sayim" : `/stok/sayim?kategori=${v}`)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72" aria-label={t.count.category}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t.count.allCategories}</SelectItem>
|
||||
{data.categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: c.depth * 12 }}>{c.path.split(" › ").at(-1)}</span>
|
||||
{c.due ? (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-400">
|
||||
{" "}
|
||||
· {t.count.dueCount(c.due)}
|
||||
</span>
|
||||
) : null}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dueTotal > 0 ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={onlyDue} onCheckedChange={(x) => setOnlyDue(x === true)} />
|
||||
{t.count.onlyDue} ({dueTotal})
|
||||
</label>
|
||||
) : null}
|
||||
{data.canWrite && rows.length ? (
|
||||
<div className="flex gap-2 sm:ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={fillExpected}
|
||||
title={t.count.fillExpectedHint}
|
||||
>
|
||||
<ListChecks />
|
||||
{t.count.fillExpected}
|
||||
</Button>
|
||||
{filled ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEntries({})}>
|
||||
<Eraser />
|
||||
{t.count.clear}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.count.empty}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{groups.map((g) => (
|
||||
<section key={g.path} className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">{g.path}</h2>
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{g.rows.map((r) => (
|
||||
<CountRow
|
||||
key={r.id}
|
||||
row={r}
|
||||
entry={entries[r.id] ?? EMPTY}
|
||||
errors={errors}
|
||||
disabled={!data.canWrite || pending}
|
||||
onChange={(patch) => set(r.id, patch)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{data.canWrite && data.rows.length ? (
|
||||
<div className="sticky bottom-[calc(4rem+env(safe-area-inset-bottom))] z-10 -mx-4 flex flex-wrap items-center gap-3 border-t bg-background/95 px-4 py-3 backdrop-blur md:bottom-0 md:mx-0 md:rounded-xl md:border">
|
||||
<Input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t.count.note}
|
||||
aria-label={t.count.note}
|
||||
maxLength={300}
|
||||
className="min-w-40 flex-1"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t.count.counted(filled)}
|
||||
</span>
|
||||
<Button onClick={submit} disabled={pending || filled === 0}>
|
||||
<ClipboardCheck />
|
||||
{t.count.submit}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountRow({
|
||||
row,
|
||||
entry,
|
||||
errors,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
row: CountSheetRow;
|
||||
entry: Entry;
|
||||
errors: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onChange: (patch: Partial<Entry>) => void;
|
||||
}) {
|
||||
const actual = entry.actual.trim() ? parseQuantity(entry.actual) : null;
|
||||
const diff = actual === null ? null : actual - row.expected;
|
||||
const shortage = diff !== null && diff < 0 ? -diff : 0;
|
||||
const actualError = errors[`${row.id}.actualQuantity`];
|
||||
const wasteError = errors[`${row.id}.wasteQuantity`] ?? errors[`${row.id}.wasteReason`];
|
||||
const inputId = `count-${row.id}`;
|
||||
return (
|
||||
<li className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-40">
|
||||
<label htmlFor={inputId} className="flex flex-wrap items-center gap-1.5 font-medium">
|
||||
<span className="truncate">{row.name}</span>
|
||||
{row.count?.due ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{t.count.dueBadge(row.count.overdueDays)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</label>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{[
|
||||
row.location,
|
||||
row.count
|
||||
? row.count.lastDate
|
||||
? t.count.lastCount(ymdLabel(row.count.lastDate))
|
||||
: t.count.neverCounted
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-20 text-right text-sm tabular-nums">
|
||||
<p className="text-xs text-muted-foreground">{t.count.expected}</p>
|
||||
{formatStock(Math.max(0, row.expected), row.unit)}
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.count.actual} ({row.unit})
|
||||
</span>
|
||||
<Input
|
||||
id={inputId}
|
||||
inputMode="decimal"
|
||||
value={entry.actual}
|
||||
onChange={(e) => onChange({ actual: e.target.value })}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(actualError) || (entry.actual.trim() !== "" && actual === null)}
|
||||
className="h-8 tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 text-right text-sm tabular-nums">
|
||||
<p className="text-xs text-muted-foreground">{t.count.diff}</p>
|
||||
<span
|
||||
className={cn(
|
||||
diff === null
|
||||
? "text-muted-foreground"
|
||||
: diff > 0
|
||||
? "text-income"
|
||||
: diff < 0
|
||||
? "text-expense"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{diff === null ? "—" : signed(diff, row.unit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{actualError ? <p className="text-xs text-destructive">{actualError}</p> : null}
|
||||
{shortage > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-muted/50 p-2 text-sm">
|
||||
<span className="text-xs text-muted-foreground">{t.count.markWaste}:</span>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={entry.waste}
|
||||
onChange={(e) => onChange({ waste: e.target.value })}
|
||||
disabled={disabled}
|
||||
placeholder={t.count.wasteMax(formatStock(shortage, row.unit))}
|
||||
aria-label={`${row.name}: ${t.count.wasteQuantity}`}
|
||||
aria-invalid={Boolean(errors[`${row.id}.wasteQuantity`])}
|
||||
className="h-8 w-36 tabular-nums"
|
||||
/>
|
||||
<Select
|
||||
value={entry.reason}
|
||||
onValueChange={(v) => onChange({ reason: v as WasteReason })}
|
||||
disabled={disabled || !entry.waste.trim()}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-48"
|
||||
aria-label={`${row.name}: ${t.fields.wasteReason}`}
|
||||
aria-invalid={Boolean(errors[`${row.id}.wasteReason`])}
|
||||
>
|
||||
<SelectValue placeholder={t.fields.wasteReason} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WASTE_REASONS.map((w) => (
|
||||
<SelectItem key={w} value={w}>
|
||||
{t.wasteReasons[w]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{wasteError ? <p className="w-full text-xs text-destructive">{wasteError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Clock } from "lucide-react";
|
||||
import { ClipboardCheck, Clock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -9,7 +9,7 @@ import { getStock } from "../service";
|
||||
const t = tr.stock;
|
||||
const LIMIT = 5;
|
||||
|
||||
/** Dashboard: eşiğin altındaki ürünler ve SKT'si yaklaşan partiler (yoksa görünmez). */
|
||||
/** Dashboard: eşiğin altındaki ürünler, SKT'si yaklaşan partiler, sayımı gelen kategoriler. */
|
||||
export async function LowStockCard({
|
||||
userId,
|
||||
householdId,
|
||||
@@ -18,7 +18,8 @@ export async function LowStockCard({
|
||||
householdId: string;
|
||||
}) {
|
||||
const data = await getStock(userId, householdId);
|
||||
if (!data || (!data.suggestions.length && !data.expiring.length)) return null;
|
||||
if (!data || (!data.suggestions.length && !data.expiring.length && !data.countDue.length))
|
||||
return null;
|
||||
const low = data.suggestions.slice(0, LIMIT);
|
||||
const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
|
||||
return (
|
||||
@@ -26,7 +27,11 @@ export async function LowStockCard({
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.dashboard.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.dashboard.description(data.suggestions.length, data.expiring.length)}
|
||||
{t.dashboard.description(
|
||||
data.suggestions.length,
|
||||
data.expiring.length,
|
||||
data.countDue.length,
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
@@ -69,6 +74,23 @@ export async function LowStockCard({
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.countDue.slice(0, 3).map((g) => (
|
||||
<li
|
||||
key={`count-${g.categoryId}`}
|
||||
className="flex items-center justify-between gap-3 py-1.5"
|
||||
>
|
||||
<Link
|
||||
href={`/stok/sayim?kategori=${g.categoryId}`}
|
||||
className="min-w-0 truncate hover:underline"
|
||||
>
|
||||
{g.path}
|
||||
</Link>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
<ClipboardCheck className="size-3" />
|
||||
{t.count.dueBadge(g.overdueDays)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data.suggestions.length > low.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import { formatStock, roundForDisplay } from "@/shared/stock";
|
||||
import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
|
||||
import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
|
||||
import {
|
||||
@@ -46,7 +46,14 @@ import {
|
||||
StockMovementDialog,
|
||||
type MovementKindInput,
|
||||
} from "./stock-dialogs";
|
||||
import { ExpiryBadge, LevelBadge, QuickConsumeButton, StockBar, ymdLabel } from "./stock-view";
|
||||
import {
|
||||
ExpiryBadge,
|
||||
LevelBadge,
|
||||
QuickConsumeButton,
|
||||
StockBar,
|
||||
suggestionReasons,
|
||||
ymdLabel,
|
||||
} from "./stock-view";
|
||||
|
||||
const t = tr.stock;
|
||||
|
||||
@@ -156,6 +163,53 @@ export function ProductDetailView({
|
||||
<Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat>
|
||||
</div>
|
||||
<StockBar item={data} />
|
||||
{suggestionReasons(data).length ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
data.suggestion.waste
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{suggestionReasons(data).join(" ")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||
<Stat label={t.detail.usage}>
|
||||
{data.suggestion.usage ? (
|
||||
t.detail.perWeek(q(roundForDisplay(data.suggestion.usage.perDay * 7)))
|
||||
) : (
|
||||
<span className="font-normal text-muted-foreground">{t.detail.noUsage}</span>
|
||||
)}
|
||||
</Stat>
|
||||
<Stat label={t.detail.countSchedule}>
|
||||
{data.count ? (
|
||||
<span className="flex flex-wrap items-center gap-x-2">
|
||||
<span>{t.detail.countEvery(data.count.everyDays)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
data.count.due
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{data.count.due
|
||||
? tr.stock.count.dueBadge(data.count.overdueDays)
|
||||
: t.detail.nextCount(ymdLabel(data.count.nextDate))}
|
||||
{data.count.lastDate
|
||||
? ` · ${tr.stock.count.lastCount(ymdLabel(data.count.lastDate))}`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{t.detail.noCountSchedule}
|
||||
</span>
|
||||
)}
|
||||
</Stat>
|
||||
</div>
|
||||
{data.location ? (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">{t.detail.location}:</span> {data.location}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import type { StockAnalysis, StockAnalysisRow } from "../service";
|
||||
|
||||
const t = tr.stock.analysis;
|
||||
/** Bu oranın (%) üstündeki fire vurgulanır. */
|
||||
const HIGH_WASTE_PCT = 15;
|
||||
|
||||
const monthShort = (month: string) =>
|
||||
new Intl.DateTimeFormat("tr-TR", { month: "short" }).format(new Date(`${month}-15T12:00:00Z`));
|
||||
const monthLong = (month: string) =>
|
||||
new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" }).format(
|
||||
new Date(`${month}-15T12:00:00Z`),
|
||||
);
|
||||
|
||||
/** Ürünün kendi ölçeğinde son aylar: tüketim (alt) + fire (üst), taban çizgisine oturur. */
|
||||
function Trend({ row }: { row: StockAnalysisRow }) {
|
||||
const max = Math.max(1, ...row.months.map((m) => m.used + m.wasted));
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t.chartLabel(row.name)}
|
||||
className="flex h-8 w-full items-end gap-1 border-b border-border"
|
||||
>
|
||||
{row.months.map((m) => {
|
||||
const total = m.used + m.wasted;
|
||||
const label = t.monthValue(
|
||||
monthLong(m.month),
|
||||
formatStock(m.used, row.unit),
|
||||
formatStock(m.wasted, row.unit),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={m.month}
|
||||
title={label}
|
||||
className="flex h-full min-w-0 flex-1 flex-col justify-end gap-0.5 hover:opacity-80"
|
||||
>
|
||||
{m.wasted > 0 ? (
|
||||
<div
|
||||
className="w-full rounded-t-[3px] bg-expense"
|
||||
style={{ height: `${(m.wasted / max) * 100}%`, minHeight: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
{m.used > 0 ? (
|
||||
<div
|
||||
className={cn("w-full bg-budget", m.wasted > 0 ? "" : "rounded-t-[3px]")}
|
||||
style={{ height: `${(m.used / max) * 100}%`, minHeight: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
{total === 0 ? <span className="sr-only">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Num({ value, unit, tone }: { value: number | null; unit: string; tone?: string }) {
|
||||
return (
|
||||
<span className={cn("tabular-nums", value === null && "text-muted-foreground", tone)}>
|
||||
{value === null ? t.noData : formatStock(value, unit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** /stok/analiz: ürün tipi başına aylık ortalama tüketim, fire ve son ayların eğilimi. */
|
||||
export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||
const groups = new Map<string, StockAnalysisRow[]>();
|
||||
for (const r of data.rows) groups.set(r.categoryPath, [...(groups.get(r.categoryPath) ?? []), r]);
|
||||
const first = data.months[0]!;
|
||||
const last = data.months.at(-1)!;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader title={t.title} description={t.description} />
|
||||
|
||||
{data.rows.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden className="size-2.5 rounded-sm bg-budget" />
|
||||
{tr.stock.kinds.consume}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden className="size-2.5 rounded-sm bg-expense" />
|
||||
{tr.stock.kinds.waste}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<div className="hidden grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(4,minmax(0,0.7fr))] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
|
||||
<span>{t.product}</span>
|
||||
<span>{t.trend(data.months.length)}</span>
|
||||
<span className="text-right">{t.avgUsed}</span>
|
||||
<span className="text-right">{t.avgWasted}</span>
|
||||
<span className="text-right">{t.wastePct}</span>
|
||||
<span className="text-right">{t.thisMonth}</span>
|
||||
</div>
|
||||
{[...groups.entries()].map(([path, rows]) => (
|
||||
<section key={path}>
|
||||
<h2 className="border-b bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{path}
|
||||
</h2>
|
||||
<ul className="divide-y">
|
||||
{rows.map((r) => {
|
||||
const high = r.wastePct !== null && r.wastePct >= HIGH_WASTE_PCT;
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className="grid grid-cols-2 items-center gap-x-3 gap-y-2 px-3 py-2.5 text-sm md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(4,minmax(0,0.7fr))]"
|
||||
>
|
||||
<Link
|
||||
href={`/stok/${r.id}`}
|
||||
className="min-w-0 truncate font-medium hover:underline"
|
||||
>
|
||||
{r.name}
|
||||
</Link>
|
||||
<Trend row={r} />
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.avgUsed}
|
||||
</span>
|
||||
<Num value={r.avgMonthlyUsed} unit={r.unit} />
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.avgWasted}
|
||||
</span>
|
||||
<Num value={r.avgMonthlyWasted} unit={r.unit} />
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.wastePct}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
high ? "font-medium text-expense" : "",
|
||||
r.wastePct === null && "text-muted-foreground",
|
||||
)}
|
||||
title={high ? t.highWaste : undefined}
|
||||
>
|
||||
{r.wastePct === null ? t.noData : `%${r.wastePct}`}
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.thisMonth}
|
||||
</span>
|
||||
<Num value={r.thisMonth.used} unit={r.unit} />
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, ChevronRight, Clock, FolderTree, Plus, Search } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChartColumn,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
FolderTree,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
@@ -111,6 +120,17 @@ export function QuickConsumeButton({ item }: { item: ProductTypeView }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Önerilen miktarın gerekçesi: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */
|
||||
export function suggestionReasons(item: ProductTypeView): string[] {
|
||||
const s = item.suggestion;
|
||||
if (s.quantity === 0) return [];
|
||||
const out: string[] = [];
|
||||
if (s.waste) out.push(t.suggestions.wasteReduced(s.waste.purchases, s.waste.pct));
|
||||
if (s.roundedByUsage) out.push(t.suggestions.roundedByUsage(formatStock(s.roundTo, item.unit)));
|
||||
if (s.coverDays !== null) out.push(t.suggestions.covers(s.coverDays));
|
||||
return out;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
@@ -144,6 +164,18 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/analiz">
|
||||
<ChartColumn />
|
||||
{t.analysisLink}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/sayim">
|
||||
<ClipboardCheck />
|
||||
{t.countLink}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/kategoriler">
|
||||
<FolderTree />
|
||||
@@ -182,26 +214,76 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
||||
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
||||
) : (
|
||||
<ul className="divide-y text-sm">
|
||||
{data.suggestions.map((i) => (
|
||||
<li key={i.id} className="flex items-center justify-between gap-3 py-2">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
|
||||
<span className="block truncate font-medium">{i.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{t.list.of(
|
||||
formatStock(Math.max(0, i.current), i.unit),
|
||||
formatStock(i.targetQuantity, i.unit),
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="shrink-0 text-right font-medium tabular-nums">
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.suggestions.map((i) => {
|
||||
const reasons = suggestionReasons(i);
|
||||
return (
|
||||
<li key={i.id} className="flex flex-col gap-0.5 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
|
||||
<span className="block truncate font-medium">{i.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{t.list.of(
|
||||
formatStock(Math.max(0, i.current), i.unit),
|
||||
formatStock(i.targetQuantity, i.unit),
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="shrink-0 text-right font-medium tabular-nums">
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</div>
|
||||
{reasons.length ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
i.suggestion.waste
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{reasons.join(" ")}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{data.countDue.length ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.countDue.title}</CardTitle>
|
||||
<CardDescription>{t.countDue.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y text-sm">
|
||||
{data.countDue.map((g) => (
|
||||
<li key={g.categoryId} className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate font-medium">{g.path}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.countDue.products(g.products)} ·{" "}
|
||||
<span className="text-amber-700 dark:text-amber-400">
|
||||
{t.countDue.overdue(g.overdueDays)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{data.canWrite ? (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/stok/sayim?kategori=${g.categoryId}`}>
|
||||
<ClipboardCheck />
|
||||
{t.countDue.start}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{data.expiring.length ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
productTypeFormSchema,
|
||||
stockCategoryFormSchema,
|
||||
stockCountFormSchema,
|
||||
stockCountSheetSchema,
|
||||
stockMovementFormSchema,
|
||||
type ProductTypeFormInput,
|
||||
} from "@/shared/schemas/stock";
|
||||
@@ -18,13 +20,16 @@ import {
|
||||
createProductType,
|
||||
createStockCategory,
|
||||
deleteStockMovement,
|
||||
getCountSheet,
|
||||
getProductType,
|
||||
getStock,
|
||||
getStockAnalysis,
|
||||
getStockOptions,
|
||||
listStockCategories,
|
||||
quickConsume,
|
||||
setProductTypeArchived,
|
||||
setStockCategoryArchived,
|
||||
submitCount,
|
||||
updateStockCategory,
|
||||
} from "./service";
|
||||
|
||||
@@ -305,3 +310,137 @@ describe("stok kategorileri", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("periyodik sayım, öneri ve analiz (Faz 11b)", () => {
|
||||
it("seed: Mutfak sayımı gecikti, Genel Temizlik değil; fire öneriyi düşürür", async () => {
|
||||
const s = (await getStock(ayse, cakir))!;
|
||||
const paths = s.countDue.map((g) => g.path);
|
||||
expect(paths).toEqual(expect.arrayContaining(["Mutfak › Manav", "Mutfak › Bakliyat"]));
|
||||
expect(paths.some((p) => p.startsWith("Genel Temizlik"))).toBe(false);
|
||||
// Son sayım 21 gün önce, sıklık 15 → 6 gün gecikti.
|
||||
expect(s.countDue.find((g) => g.path === "Mutfak › Manav")!.overdueDays).toBe(6);
|
||||
const domates = s.items.find((i) => i.name === "Domates")!;
|
||||
expect(domates.count).toMatchObject({ everyDays: 15, lastDate: addDays(today, -21) });
|
||||
expect(domates.suggestion.waste?.pct).toBeGreaterThan(0);
|
||||
const deterjan = s.items.find((i) => i.name === "Toz Deterjan")!;
|
||||
expect(deterjan.count).toMatchObject({ everyDays: 30, due: false });
|
||||
expect(s.items.find((i) => i.name === "Şampuan")!.count).toBeNull();
|
||||
// Öneri hedefi aşmaz, tüketim hızı biliniyor.
|
||||
for (const i of s.suggestions) {
|
||||
expect(i.suggestedQuantity).toBeGreaterThan(0);
|
||||
expect(i.suggestedQuantity).toBeLessThanOrEqual(
|
||||
Math.ceil(i.suggestion.base / i.step) * i.step,
|
||||
);
|
||||
// Bugün eklenen test ürünlerinin geçmişi kısa: kullanım hızı yok.
|
||||
if (i.movementCount > 5) expect(i.suggestion.usage).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("sayım listesi kategori alt ağacıyla; toplu sayım fire + düzeltme, oturum kaydı", async () => {
|
||||
const opts = (await getStockOptions(ayse, cakir))!;
|
||||
const manav = opts.categories.find((c) => c.path === "Mutfak › Manav")!.id;
|
||||
const mutfak = opts.categories.find((c) => c.path === "Mutfak")!.id;
|
||||
const sheet = (await getCountSheet(ayse, cakir, mutfak))!;
|
||||
expect(sheet.rows.every((r) => r.categoryPath.startsWith("Mutfak"))).toBe(true);
|
||||
expect(sheet.categories.find((c) => c.id === manav)!.due).toBeGreaterThan(0);
|
||||
|
||||
const patates = sheet.rows.find((r) => r.name === "Patates")!;
|
||||
const sogan = sheet.rows.find((r) => r.name === "Kuru Soğan")!;
|
||||
const bad = await submitCount(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCountSheetSchema.parse({
|
||||
date: today,
|
||||
categoryId: mutfak,
|
||||
rows: [
|
||||
{
|
||||
productTypeId: patates.id,
|
||||
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
|
||||
wasteQuantity: "0,6",
|
||||
wasteReason: "spoiled",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(bad).toMatchObject({
|
||||
ok: false,
|
||||
fieldErrors: { "rows.0.wasteQuantity": expect.any(String) },
|
||||
});
|
||||
|
||||
const r = await submitCount(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCountSheetSchema.parse({
|
||||
date: today,
|
||||
categoryId: mutfak,
|
||||
note: "Ay sonu",
|
||||
rows: [
|
||||
{
|
||||
productTypeId: patates.id,
|
||||
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
|
||||
wasteQuantity: "0,3",
|
||||
wasteReason: "spoiled",
|
||||
},
|
||||
{
|
||||
productTypeId: sogan.id,
|
||||
actualQuantity: String(sogan.expected / 1000).replace(".", ","),
|
||||
},
|
||||
{ productTypeId: manav, actualQuantity: "" },
|
||||
].map((x) => ({ wasteQuantity: "", wasteReason: "", ...x })),
|
||||
}),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { counted: 2, adjusted: 1, diffs: [-500, 0] } });
|
||||
const countId = r.ok ? r.data.countId : "";
|
||||
const session = await StockCountModel.findById(countId).lean();
|
||||
expect(session!.items).toHaveLength(2);
|
||||
expect(session!.items[0]).toMatchObject({ expected: patates.expected, wasted: 300 });
|
||||
const moves = await StockMovementModel.find({ countId }).sort({ createdAt: 1 }).lean();
|
||||
expect(moves.map((m) => [m.kind, m.quantity, m.wasteReason])).toEqual([
|
||||
["waste", 300, "spoiled"],
|
||||
["count_adjust", -200, null],
|
||||
]);
|
||||
|
||||
// Fark sıfır olan soğan da sayıldı: takvim yenilendi.
|
||||
const p = (await getProductType(ayse, cakir, sogan.id))!;
|
||||
expect(p.count).toMatchObject({ lastDate: today, due: false, nextDate: addDays(today, 15) });
|
||||
expect((await getProductType(ayse, cakir, patates.id))!.current).toBe(patates.expected - 500);
|
||||
|
||||
// Hiçbir satır dolu değilse şema reddeder; izleyici sayamaz.
|
||||
expect(
|
||||
stockCountSheetSchema.safeParse({
|
||||
date: today,
|
||||
categoryId: "",
|
||||
rows: [{ productTypeId: sogan.id, actualQuantity: "", wasteQuantity: "", wasteReason: "" }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await submitCount(
|
||||
zeynep,
|
||||
cakir,
|
||||
stockCountSheetSchema.parse({
|
||||
date: today,
|
||||
categoryId: "",
|
||||
rows: [
|
||||
{ productTypeId: sogan.id, actualQuantity: "1", wasteQuantity: "", wasteReason: "" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
expect(await getCountSheet(fatma, cakir, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("tüketim analizi: aylık ortalama ve fire oranı", async () => {
|
||||
const a = (await getStockAnalysis(ayse, cakir))!;
|
||||
expect(a.months).toHaveLength(6);
|
||||
expect(a.months.at(-1)! < a.thisMonth).toBe(true);
|
||||
const domates = a.rows.find((r) => r.name === "Domates")!;
|
||||
expect(domates.avgMonthlyUsed).toBeGreaterThan(5_000);
|
||||
expect(domates.wastePct).toBeGreaterThan(0);
|
||||
const sampuan = a.rows.find((r) => r.name === "Şampuan")!;
|
||||
expect(sampuan.avgMonthlyWasted).toBe(0);
|
||||
expect(sampuan.wastePct).toBe(0);
|
||||
expect(await getStockAnalysis(fatma, cakir)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+397
-41
@@ -20,26 +20,35 @@ import { toObjectId } from "@/lib/object-id";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StockCategoryModel } from "@/models/stock-category";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { shiftMonth } from "@/shared/budget";
|
||||
import type {
|
||||
ProductTypeForm,
|
||||
StockCategoryForm,
|
||||
StockCountForm,
|
||||
StockCountSheet,
|
||||
StockMovementForm,
|
||||
} from "@/shared/schemas/stock";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
import {
|
||||
countSchedule,
|
||||
currentQuantity,
|
||||
expiringLots,
|
||||
expiryStatus,
|
||||
fifoLots,
|
||||
formatStock,
|
||||
refillQuantity,
|
||||
monthlyUsage,
|
||||
roundForDisplay,
|
||||
signedDelta,
|
||||
stockLevel,
|
||||
suggestPurchase,
|
||||
type CountSchedule,
|
||||
type ExpiringLot,
|
||||
type ExpiryStatus,
|
||||
type MonthUsage,
|
||||
type PurchaseSuggestion,
|
||||
type StockLevel,
|
||||
type StockMovementKind,
|
||||
type WasteReason,
|
||||
@@ -77,6 +86,7 @@ type ProductLean = {
|
||||
countEveryDays: number | null;
|
||||
note: string;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type MoveLean = {
|
||||
@@ -124,16 +134,30 @@ export type ProductTypeView = {
|
||||
current: number;
|
||||
level: StockLevel;
|
||||
suggestedQuantity: number;
|
||||
/** Öneri ayrıntısı: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */
|
||||
suggestion: PurchaseSuggestion;
|
||||
/** Periyodik sayım (sıklık ürün tipinden, yoksa kategoriden). */
|
||||
count: CountSchedule | null;
|
||||
expiring: ExpiringLot[];
|
||||
lastMovementDate: string | null;
|
||||
movementCount: number;
|
||||
};
|
||||
|
||||
/** Sayımı gelen ürünlerin kategoriye göre özeti. */
|
||||
export type CountDueGroup = {
|
||||
categoryId: string;
|
||||
path: string;
|
||||
products: number;
|
||||
/** En çok geciken ürünün gecikmesi (gün). */
|
||||
overdueDays: number;
|
||||
};
|
||||
|
||||
export type StockView = {
|
||||
categories: StockCategoryView[];
|
||||
items: ProductTypeView[];
|
||||
suggestions: ProductTypeView[];
|
||||
expiring: { item: ProductTypeView; lot: ExpiringLot }[];
|
||||
countDue: CountDueGroup[];
|
||||
canWrite: boolean;
|
||||
today: string;
|
||||
};
|
||||
@@ -261,19 +285,43 @@ function categoryViews(
|
||||
});
|
||||
}
|
||||
|
||||
function toView(
|
||||
p: ProductLean,
|
||||
moves: MoveLean[],
|
||||
categoryPath: string,
|
||||
today: string,
|
||||
): ProductTypeView {
|
||||
/** Ürün başına son sayım tarihi (sayım oturumlarından). */
|
||||
async function loadLastCounts(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||
const rows = await StockCountModel.aggregate<{ _id: Types.ObjectId; date: string }>([
|
||||
{ $match: { householdId, "items.productTypeId": { $in: productIds } } },
|
||||
{ $unwind: "$items" },
|
||||
{ $match: { "items.productTypeId": { $in: productIds } } },
|
||||
{ $group: { _id: "$items.productTypeId", date: { $max: "$date" } } },
|
||||
]);
|
||||
return new Map(rows.map((r) => [String(r._id), r.date]));
|
||||
}
|
||||
|
||||
type ViewContext = {
|
||||
categoryPath: string;
|
||||
/** Kategoriden devralınan sayım sıklığı. */
|
||||
categoryCountDays: number | null;
|
||||
lastCount: string | null;
|
||||
today: string;
|
||||
};
|
||||
|
||||
const ymdOf = (d: Date) => todayIstanbul(d);
|
||||
|
||||
function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTypeView {
|
||||
const { today } = ctx;
|
||||
const current = currentQuantity(moves);
|
||||
const level = stockLevel(current, p.targetQuantity, p.thresholdPct);
|
||||
const suggestion = suggestPurchase({
|
||||
current,
|
||||
target: p.targetQuantity,
|
||||
step: p.step,
|
||||
moves,
|
||||
today,
|
||||
});
|
||||
return {
|
||||
id: String(p._id),
|
||||
name: p.name,
|
||||
categoryId: String(p.categoryId),
|
||||
categoryPath,
|
||||
categoryPath: ctx.categoryPath,
|
||||
unit: p.unit,
|
||||
targetQuantity: p.targetQuantity,
|
||||
thresholdPct: p.thresholdPct,
|
||||
@@ -284,7 +332,14 @@ function toView(
|
||||
archived: Boolean(p.archivedAt),
|
||||
current,
|
||||
level,
|
||||
suggestedQuantity: refillQuantity(current, p.targetQuantity),
|
||||
suggestedQuantity: suggestion.quantity,
|
||||
suggestion,
|
||||
count: countSchedule(
|
||||
p.countEveryDays ?? ctx.categoryCountDays,
|
||||
ctx.lastCount,
|
||||
moves[0]?.date ?? ymdOf(p.createdAt),
|
||||
today,
|
||||
),
|
||||
expiring: expiringLots(fifoLots(moves), today),
|
||||
lastMovementDate: moves.at(-1)?.date ?? null,
|
||||
movementCount: moves.length,
|
||||
@@ -308,15 +363,22 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
|
||||
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 catOf = new Map(categories.map((c) => [c.id, c]));
|
||||
const ids = products.map((p) => p._id);
|
||||
const [moves, lastCounts] = await Promise.all([
|
||||
loadMoves(m.householdId, ids),
|
||||
loadLastCounts(m.householdId, ids),
|
||||
]);
|
||||
const items = products
|
||||
.map((p) =>
|
||||
toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today),
|
||||
)
|
||||
.map((p) => {
|
||||
const cat = catOf.get(String(p.categoryId));
|
||||
return toView(p, moves.get(String(p._id)) ?? [], {
|
||||
categoryPath: cat?.path ?? "",
|
||||
categoryCountDays: cat?.effectiveCountDays ?? null,
|
||||
lastCount: lastCounts.get(String(p._id)) ?? null,
|
||||
today,
|
||||
});
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) ||
|
||||
@@ -333,11 +395,31 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
|
||||
items,
|
||||
suggestions,
|
||||
expiring,
|
||||
countDue: groupCountDue(items),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
today,
|
||||
};
|
||||
}
|
||||
|
||||
function groupCountDue(items: ProductTypeView[]): CountDueGroup[] {
|
||||
const by = new Map<string, CountDueGroup>();
|
||||
for (const i of items) {
|
||||
if (!i.count?.due) continue;
|
||||
const g = by.get(i.categoryId) ?? {
|
||||
categoryId: i.categoryId,
|
||||
path: i.categoryPath,
|
||||
products: 0,
|
||||
overdueDays: 0,
|
||||
};
|
||||
g.products++;
|
||||
g.overdueDays = Math.max(g.overdueDays, i.count.overdueDays);
|
||||
by.set(i.categoryId, g);
|
||||
}
|
||||
return [...by.values()].sort(
|
||||
(a, b) => b.overdueDays - a.overdueDays || compareTr(a.path, b.path),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) {
|
||||
const id = toObjectId(productTypeId);
|
||||
if (!id) return null;
|
||||
@@ -357,11 +439,14 @@ export async function getProductType(
|
||||
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)) ?? [];
|
||||
const cat = categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
|
||||
(c) => c.id === String(p.categoryId),
|
||||
);
|
||||
const [movesBy, lastCounts] = await Promise.all([
|
||||
loadMoves(m.householdId, [p._id]),
|
||||
loadLastCounts(m.householdId, [p._id]),
|
||||
]);
|
||||
const moves = movesBy.get(String(p._id)) ?? [];
|
||||
let running = 0;
|
||||
const rows = moves.map((mv): StockMovementRow => {
|
||||
const delta = signedDelta(mv);
|
||||
@@ -379,7 +464,12 @@ export async function getProductType(
|
||||
};
|
||||
});
|
||||
return {
|
||||
...toView(p, moves, path, today),
|
||||
...toView(p, moves, {
|
||||
categoryPath: cat?.path ?? "",
|
||||
categoryCountDays: cat?.effectiveCountDays ?? null,
|
||||
lastCount: lastCounts.get(String(p._id)) ?? null,
|
||||
today,
|
||||
}),
|
||||
movements: rows.reverse(),
|
||||
lots: fifoLots(moves).map((l) => ({
|
||||
date: l.date,
|
||||
@@ -877,8 +967,8 @@ export async function quickConsume(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Tek ürün sayımı (ürün detayından): toplu sayımın tek satırlık hâli. Fark yoksa düzeltme
|
||||
* oluşmaz ama sayım kaydı tutulur. Farkı (işaretli) döner.
|
||||
*/
|
||||
export async function countStock(
|
||||
actorId: string,
|
||||
@@ -886,22 +976,21 @@ export async function countStock(
|
||||
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 });
|
||||
const r = await submitCount(actorId, householdId, {
|
||||
date: input.date,
|
||||
categoryId: null,
|
||||
note: input.note,
|
||||
rows: [
|
||||
{
|
||||
productTypeId,
|
||||
actualQuantity: input.actualQuantity,
|
||||
wasteQuantity: null,
|
||||
wasteReason: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!r.ok) return r;
|
||||
return ok({ diff: r.data.diffs[0] ?? 0 });
|
||||
}
|
||||
|
||||
export async function deleteStockMovement(
|
||||
@@ -936,3 +1025,270 @@ export async function deleteStockMovement(
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Periyodik sayım (Faz 11b) ---
|
||||
|
||||
export type CountSheetRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
categoryId: string;
|
||||
categoryPath: string;
|
||||
unit: string;
|
||||
step: number;
|
||||
location: string;
|
||||
/** Kayıtlara göre bugün beklenen miktar. */
|
||||
expected: number;
|
||||
count: CountSchedule | null;
|
||||
};
|
||||
|
||||
export type CountSheet = {
|
||||
today: string;
|
||||
category: { id: string; path: string } | null;
|
||||
/** Filtre seçenekleri: ürünü olan kategoriler ve sayımı gelen ürün sayısı. */
|
||||
categories: { id: string; path: string; depth: number; due: number }[];
|
||||
rows: CountSheetRow[];
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
/** /stok/sayim: beklenen miktarlar (kategori seçiliyse alt ağacıyla). */
|
||||
export async function getCountSheet(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
categoryId: string | null,
|
||||
): Promise<CountSheet | null> {
|
||||
const data = await getStock(userId, householdId);
|
||||
if (!data) return null;
|
||||
const all = data.categories;
|
||||
const category = categoryId ? (all.find((c) => c.id === categoryId) ?? null) : null;
|
||||
const inScope = category
|
||||
? new Set([
|
||||
category.id,
|
||||
...descendantIds(
|
||||
all.map((c) => ({
|
||||
id: c.id,
|
||||
kind: "expense" as const,
|
||||
name: c.name,
|
||||
parentId: c.parentId,
|
||||
order: 0,
|
||||
archived: false,
|
||||
})),
|
||||
category.id,
|
||||
),
|
||||
])
|
||||
: null;
|
||||
const due = new Map(data.countDue.map((g) => [g.categoryId, g.products]));
|
||||
const withItems = new Set(data.items.map((i) => i.categoryId));
|
||||
// Ürünü olan kategoriler ve ataları seçenek olarak görünür.
|
||||
const visible = new Set<string>();
|
||||
for (const c of all)
|
||||
if (withItems.has(c.id)) {
|
||||
visible.add(c.id);
|
||||
let parent = c.parentId;
|
||||
while (parent) {
|
||||
visible.add(parent);
|
||||
parent = all.find((x) => x.id === parent)?.parentId ?? null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
today: data.today,
|
||||
category: category ? { id: category.id, path: category.path } : null,
|
||||
categories: all
|
||||
.filter((c) => visible.has(c.id))
|
||||
.map((c) => ({ id: c.id, path: c.path, depth: c.depth, due: due.get(c.id) ?? 0 })),
|
||||
rows: data.items
|
||||
.filter((i) => !inScope || inScope.has(i.categoryId))
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
categoryId: i.categoryId,
|
||||
categoryPath: i.categoryPath,
|
||||
unit: i.unit,
|
||||
step: i.step,
|
||||
location: i.location,
|
||||
expected: i.current,
|
||||
count: i.count,
|
||||
})),
|
||||
canWrite: data.canWrite,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toplu sayım (tek transaction): her sayılan ürün için o tarihe kadarki kayıtlara göre beklenen
|
||||
* hesaplanır; eksiğin fire olarak işaretlenen kısmı `waste`, kalan fark `count_adjust` olur.
|
||||
* Fark sıfır olsa da sayım oturumuna yazılır. Fire, eksikten fazla olamaz.
|
||||
*/
|
||||
export async function submitCount(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: StockCountSheet,
|
||||
): Promise<ActionResult<{ countId: string; counted: number; adjusted: number; diffs: number[] }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const rows = input.rows.filter((r) => r.actualQuantity !== null);
|
||||
if (rows.length === 0) return fail(t.validation.nothingCounted);
|
||||
if (new Set(rows.map((r) => r.productTypeId)).size !== rows.length)
|
||||
return fail(tr.common.notFound);
|
||||
const ids = rows.map((r) => new Types.ObjectId(r.productTypeId));
|
||||
const products = await ProductTypeModel.find({
|
||||
_id: { $in: ids },
|
||||
householdId: m.householdId,
|
||||
}).lean<ProductLean[]>();
|
||||
const byId = new Map(products.map((p) => [String(p._id), p]));
|
||||
const moves = await loadMoves(m.householdId, ids);
|
||||
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
const plan = rows.map((r) => {
|
||||
const p = byId.get(r.productTypeId);
|
||||
const index = input.rows.indexOf(r);
|
||||
if (!p) {
|
||||
fieldErrors[`rows.${index}.actualQuantity`] = tr.common.notFound;
|
||||
return null;
|
||||
}
|
||||
if (p.archivedAt) {
|
||||
fieldErrors[`rows.${index}.actualQuantity`] = t.validation.archived;
|
||||
return null;
|
||||
}
|
||||
const expected = currentQuantity(
|
||||
(moves.get(r.productTypeId) ?? []).filter((x) => x.date <= input.date),
|
||||
);
|
||||
const diff = r.actualQuantity! - expected;
|
||||
const wasted = r.wasteQuantity ?? 0;
|
||||
if (wasted > Math.max(0, -diff)) {
|
||||
fieldErrors[`rows.${index}.wasteQuantity`] = t.validation.wasteOverShortage(
|
||||
formatStock(Math.max(0, -diff), p.unit),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { p, row: r, expected, diff, wasted };
|
||||
});
|
||||
if (Object.keys(fieldErrors).length) return fail(tr.adminUsers.errors.checkFields, fieldErrors);
|
||||
const items = plan.filter((x) => x !== null);
|
||||
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const mongoose = await connectMongoose();
|
||||
let countId = "";
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
const [count] = await StockCountModel.create(
|
||||
[
|
||||
{
|
||||
householdId: m.householdId,
|
||||
date: input.date,
|
||||
categoryId: input.categoryId ? new Types.ObjectId(input.categoryId) : null,
|
||||
note: input.note,
|
||||
items: items.map((x) => ({
|
||||
productTypeId: x.p._id,
|
||||
expected: x.expected,
|
||||
actual: x.row.actualQuantity!,
|
||||
wasted: x.wasted,
|
||||
})),
|
||||
createdBy: actor,
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
countId = String(count!._id);
|
||||
const docs = items.flatMap((x) => {
|
||||
const base = {
|
||||
householdId: m.householdId,
|
||||
productTypeId: x.p._id,
|
||||
date: input.date,
|
||||
countId: count!._id,
|
||||
createdBy: actor,
|
||||
};
|
||||
const out: Record<string, unknown>[] = [];
|
||||
if (x.wasted > 0)
|
||||
out.push({
|
||||
...base,
|
||||
kind: "waste",
|
||||
quantity: x.wasted,
|
||||
wasteReason: x.row.wasteReason,
|
||||
note: t.countNote,
|
||||
});
|
||||
const rest = x.diff + x.wasted;
|
||||
if (rest !== 0)
|
||||
out.push({
|
||||
...base,
|
||||
kind: "count_adjust",
|
||||
quantity: rest,
|
||||
note: input.note || t.countNote,
|
||||
});
|
||||
return out;
|
||||
});
|
||||
// Sıralı ekleme: aynı tarihte fire, düzeltmeden önce gelir.
|
||||
for (const d of docs) await StockMovementModel.create([d], { session });
|
||||
});
|
||||
return ok({
|
||||
countId,
|
||||
counted: items.length,
|
||||
adjusted: items.filter((x) => x.diff !== 0).length,
|
||||
diffs: items.map((x) => x.diff),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tüketim analizi (Faz 11b) ---
|
||||
|
||||
/** Analizde gösterilen tam ay sayısı (içinde bulunulan ay ayrıca). */
|
||||
export const ANALYSIS_MONTHS = 6;
|
||||
|
||||
export type StockAnalysisRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
categoryId: string;
|
||||
categoryPath: string;
|
||||
unit: string;
|
||||
months: MonthUsage[];
|
||||
/** Ürünün var olduğu tam aylarda aylık ortalama kullanım (yoksa null). */
|
||||
avgMonthlyUsed: number | null;
|
||||
avgMonthlyWasted: number | null;
|
||||
/** Fire / (kullanım + fire), %. */
|
||||
wastePct: number | null;
|
||||
/** Bu ay (bugüne kadar). */
|
||||
thisMonth: MonthUsage;
|
||||
};
|
||||
|
||||
export type StockAnalysis = {
|
||||
months: string[];
|
||||
thisMonth: string;
|
||||
rows: StockAnalysisRow[];
|
||||
};
|
||||
|
||||
/** /stok/analiz: ürün tipi başına aylık ortalama tüketim ve fire. */
|
||||
export async function getStockAnalysis(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<StockAnalysis | null> {
|
||||
const data = await getStock(userId, householdId);
|
||||
if (!data) return null;
|
||||
const thisMonth = data.today.slice(0, 7);
|
||||
const months = Array.from({ length: ANALYSIS_MONTHS }, (_, i) =>
|
||||
shiftMonth(thisMonth, i - ANALYSIS_MONTHS),
|
||||
);
|
||||
const m = (await resolveMembership(userId, householdId, "viewer"))!;
|
||||
const moves = await loadMoves(
|
||||
m.householdId,
|
||||
data.items.map((i) => new Types.ObjectId(i.id)),
|
||||
);
|
||||
const rows = data.items.map((item): StockAnalysisRow => {
|
||||
const list = moves.get(item.id) ?? [];
|
||||
const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!];
|
||||
// Ortalama: ilk hareketin ayından sonraki tam aylar (ilk ay eksik olabilir).
|
||||
const first = list[0]?.date.slice(0, 7);
|
||||
const full = first ? usage.filter((u) => u.month > first) : [];
|
||||
const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0);
|
||||
const used = sum("used");
|
||||
const wasted = sum("wasted");
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
categoryId: item.categoryId,
|
||||
categoryPath: item.categoryPath,
|
||||
unit: item.unit,
|
||||
months: usage,
|
||||
avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null,
|
||||
avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null,
|
||||
wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null,
|
||||
thisMonth: current,
|
||||
};
|
||||
});
|
||||
return { months, thisMonth, rows };
|
||||
}
|
||||
|
||||
+77
-2
@@ -935,6 +935,67 @@ export const tr = {
|
||||
"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",
|
||||
countLink: "Sayım",
|
||||
analysisLink: "Tüketim analizi",
|
||||
countNote: "Sayım",
|
||||
countDue: {
|
||||
title: "Sayımı gelenler",
|
||||
description:
|
||||
"Sayım sıklığı dolan kategoriler. Sıklık ürün tipinde veya stok kategorisinde ayarlanır.",
|
||||
products: (n: number) => `${n} ürün`,
|
||||
overdue: (days: number) => (days > 0 ? `${days} gün gecikti` : "Bugün"),
|
||||
start: "Sayıma başla",
|
||||
},
|
||||
count: {
|
||||
title: "Sayım",
|
||||
description:
|
||||
"Evdeki gerçek miktarları girin. Kayıtlarla arasındaki fark sayım düzeltmesi olur; eksiğin bir kısmını fire olarak işaretleyebilirsiniz. Boş bıraktığınız ürünler sayılmaz.",
|
||||
back: "Ev stoğuna dön",
|
||||
allCategories: "Tüm stok",
|
||||
category: "Kategori",
|
||||
dueCount: (n: number) => `${n} sayımı gelen`,
|
||||
onlyDue: "Yalnızca sayımı gelenler",
|
||||
expected: "Beklenen",
|
||||
actual: "Sayılan",
|
||||
diff: "Fark",
|
||||
same: "Aynı",
|
||||
markWaste: "Eksiğin bir kısmı fire",
|
||||
wasteQuantity: "Fire miktarı",
|
||||
wasteMax: (q: string) => `En çok ${q}`,
|
||||
dueBadge: (days: number) => (days > 0 ? `Sayım ${days} gün gecikti` : "Sayım bugün"),
|
||||
lastCount: (date: string) => `Son sayım ${date}`,
|
||||
neverCounted: "Hiç sayılmadı",
|
||||
fillExpected: "Beklenenle doldur",
|
||||
fillExpectedHint: "Sayılmayan satırlara beklenen miktarı yazar; değişenleri düzeltin.",
|
||||
clear: "Temizle",
|
||||
note: "Not",
|
||||
counted: (n: number) => `${n} ürün sayılacak`,
|
||||
submit: "Sayımı kaydet",
|
||||
empty: "Bu kategoride sayılacak ürün yok.",
|
||||
done: (counted: number, adjusted: number) =>
|
||||
adjusted
|
||||
? `${counted} ürün sayıldı; ${adjusted} üründe fark düzeltildi.`
|
||||
: `${counted} ürün sayıldı; kayıtlarla aynı.`,
|
||||
},
|
||||
analysis: {
|
||||
title: "Tüketim analizi",
|
||||
description:
|
||||
"Ürün tipi başına aylık tüketim ve fire. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet, ürün fiyatları (Faz 12) gelince eklenecek.",
|
||||
back: "Ev stoğuna dön",
|
||||
product: "Ürün tipi",
|
||||
avgUsed: "Aylık ort. tüketim",
|
||||
avgWasted: "Aylık ort. fire",
|
||||
wastePct: "Fire oranı",
|
||||
thisMonth: "Bu ay",
|
||||
trend: (n: number) => `Son ${n} ay`,
|
||||
noData: "—",
|
||||
empty: "Analiz için ürün tipi yok.",
|
||||
chartLabel: (name: string) => `${name}: aylık tüketim`,
|
||||
monthValue: (month: string, used: string, wasted: string) =>
|
||||
`${month}: tüketim ${used}, fire ${wasted}`,
|
||||
highWaste: "Fire oranı yüksek",
|
||||
table: "Tablo",
|
||||
},
|
||||
readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
|
||||
summary: {
|
||||
products: "Ürün tipi",
|
||||
@@ -947,6 +1008,10 @@ export const tr = {
|
||||
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}`,
|
||||
wasteReduced: (purchases: number, pct: number) =>
|
||||
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
||||
roundedByUsage: (week: string) => `Haftalık tüketime (${week}) göre yuvarlandı.`,
|
||||
covers: (days: number) => `Mevcutla birlikte yaklaşık ${days} gün yeter.`,
|
||||
},
|
||||
expiring: {
|
||||
title: "Son kullanma tarihi",
|
||||
@@ -1031,6 +1096,13 @@ export const tr = {
|
||||
target: "Hedef",
|
||||
threshold: "Eşik",
|
||||
suggested: "Önerilen alım",
|
||||
usage: "Tüketim hızı",
|
||||
perWeek: (q: string) => `Haftada ~${q}`,
|
||||
noUsage: "Henüz yeterli geçmiş yok",
|
||||
countSchedule: "Periyodik sayım",
|
||||
countEvery: (n: number) => `${n} günde bir`,
|
||||
nextCount: (date: string) => `Sonraki sayım ${date}`,
|
||||
noCountSchedule: "Sayım sıklığı ayarlanmadı",
|
||||
location: "Saklama yeri",
|
||||
lots: "Evdeki partiler",
|
||||
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
|
||||
@@ -1103,17 +1175,20 @@ export const tr = {
|
||||
wasteReason: "Fire nedenini seçin",
|
||||
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
|
||||
notEnough: (q: string) => `Stokta yalnızca ${q} var.`,
|
||||
nothingCounted: "En az bir ürünün sayılan miktarını girin.",
|
||||
wasteOverShortage: (q: string) => `Fire, sayımda çıkan eksikten (${q}) fazla olamaz.`,
|
||||
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) =>
|
||||
title: "Stok uyarıları",
|
||||
description: (low: number, expiring: number, countDue = 0) =>
|
||||
[
|
||||
low ? `${low} ürün eşiğin altında` : null,
|
||||
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
|
||||
countDue ? `${countDue} kategoride sayım zamanı` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
|
||||
/**
|
||||
* Sayım oturumu (Bölüm 6.10, periyodik sayım). Her sayılan ürün için beklenen ve sayılan miktar,
|
||||
* fireye ayrılan kısım. Fark sıfır olsa da kayıt tutulur: "son sayım" ve sayım takvimi buradan
|
||||
* türetilir. Oluşan hareketler `countId` ile bağlanır.
|
||||
*/
|
||||
const stockCountItemSchema = new Schema(
|
||||
{
|
||||
productTypeId: { type: Schema.Types.ObjectId, required: true },
|
||||
expected: { type: Number, required: true },
|
||||
actual: { type: Number, required: true },
|
||||
/** Eksiğin fire olarak işaretlenen kısmı (≥ 0). */
|
||||
wasted: { type: Number, default: 0 },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const stockCountSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
/** "YYYY-MM-DD" (İstanbul). */
|
||||
date: { type: String, required: true },
|
||||
/** Sayılan stok kategorisi (alt ağacıyla); null: tüm stok veya tek ürün. */
|
||||
categoryId: { type: Schema.Types.ObjectId, default: null },
|
||||
items: { type: [stockCountItemSchema], default: [] },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "stockCounts" },
|
||||
);
|
||||
|
||||
stockCountSchema.index({ householdId: 1, "items.productTypeId": 1, date: -1 });
|
||||
stockCountSchema.index({ householdId: 1, date: -1 });
|
||||
stockCountSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type StockCount = InferSchemaType<typeof stockCountSchema>;
|
||||
|
||||
export const StockCountModel: Model<StockCount> =
|
||||
(models.StockCount as Model<StockCount> | undefined) ??
|
||||
model<StockCount>("StockCount", stockCountSchema);
|
||||
@@ -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