11 Commits

Author SHA1 Message Date
suphicakir 0b7de17b5c feat: Faz 12b - fiyat kayıtları ve birim fiyat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:27:51 +03:00
suphicakir d46869c644 chore(release): 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:27:51 +03:00
suphicakir 9e32f2988d feat(ui): ürün sayfası, fiyat geçmişi grafiği ve birim fiyat karşılaştırması
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:27:51 +03:00
suphicakir e67a1d5411 feat(prices): fiyat kayıtları, birim fiyat motoru ve analizde aylık maliyet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:27:51 +03:00
suphicakir 78503b1787 feat: Faz 12a - mağazalar ve ürünler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:48:04 +03:00
suphicakir d52f41ab0c feat(ui): mağazalar sayfası, ürün kataloğu ve ürün tipi detayında ürünler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:48:04 +03:00
suphicakir f0b570284e feat(shopping): mağazalar, zahmet skoru ve ürünler (marka/paket, dönüşüm, görseller)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:48:04 +03:00
suphicakir 8e7e374778 feat: Faz 11b - periyodik sayım, fire ayarlı öneri ve tüketim analizi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:18 +03:00
suphicakir 6cc698f70b chore(release): 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
suphicakir 07ba531d31 feat(ui): sayım ekranı, tüketim analizi ve sayım hatırlatmaları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
suphicakir 814bdbbc2a feat(stock): sayım oturumları, fire ayarlı öneri motoru ve tüketim analizi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
70 changed files with 7073 additions and 117 deletions
+51 -12
View File
@@ -6,6 +6,54 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış] ## [Yayınlanmamış]
## [0.12.0] - 2026-11-06
Faz 12: mağazalar, ürünler, fiyatlar ve birim fiyat (12a ve 12b alt fazları).
### Eklendi — Faz 12b: fiyat kayıtları ve birim fiyat
- Fiyat kayıtları (`productPrices`): ürün × mağaza × tarih × paket fiyatı; kampanya/indirim işareti ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:<ad>` (şimdilik elle giriş). Gelecek tarihli fiyat ve arşivli ürün/mağazaya fiyat girilemez; kayıt silme denetim kaydına yazılır.
- Birim fiyat: her fiyat ürün tipinin birimine çevrilir (TL/kg, TL/lt, TL/adet, TL/yıkama; g ve ml gösterimde kg ve lt). Yuvarlama yalnızca gösterimde. Geçerli fiyat mağaza başına son kayıt; süresi biten kampanyada önceki normal fiyata dönülür ("Kampanya bitti, normal fiyat"), 90 günden eski fiyat işaretlenir.
- Ürün tipi detayında "Birim fiyat karşılaştırması": tüm marka/paket ve mağazalar birim fiyata göre sıralı, en uygun vurgulu, diğerleri "%X pahalı"; buradan fiyat girişi (birim fiyat anlık önizlemesiyle).
- Ürün sayfası (`/stok/urunler/:id`): görseller, mağazalardaki geçerli fiyatlar, mağaza başına fiyat geçmişi grafiği (basamaklı çizgi, bugüne uzanır) ve fiyat kayıtları listesi. Mağaza renkleri sabit kategorik paletten (mağazanın ekleniş sırası; açık/koyu tema için doğrulanmış `--series-1…8`).
- Tüketim analizinde aylık ortalama maliyet: aylık ortalama tüketim × ürün tipinin son bilinen birim fiyatı; fiyatı bilinen ürünlerin toplamı.
- Migration `014-product-prices`. Seed: 22 ürün için son 6 ayın 265 fiyat kaydı (mağaza farkları, aylık artış, süren ve biten kampanya, eski fiyat örneği).
- Testler: birim fiyat ve gösterim birimi, geçerli fiyat (biten kampanya, eski fiyat), sıralama ve eşitlik, renk yuvası (birim); karşılaştırma sırası, kampanya dönüşü, fiyat girişi kuralları, silme ve denetim, analiz maliyeti (entegrasyon).
### Değişti — Faz 12b
- Sayfa başlığı açıklaması bağlantı içerebilir; ek dosya seçicisine özel düğme metni ve ipucu.
### Eklendi — Faz 12a: mağazalar ve ürünler (marka/paket)
- Mağazalar (`stores`, `/magazalar`): ad, tür (fiziksel market / online platform / pazar), şube, eve uzaklık (km), taşıma güçlüğü (1–5), online ise teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı geçerliliği ve geçerli sağlayıcılar, not. Zahmet skoru (0–100; uzaklık 20 km'ye kadar ve taşıma güçlüğü eşit ağırlıkla, online mağazada uzaklık sayılmaz) ve düzeyi; "ada göre / en yakın / en uzak / en zahmetli" sıralama. Aynı ad + şube tekil; arşivleme denetim kaydına yazılır. Kenar menüde "Mağazalar".
- Ürünler (`products`): ürün tipine bağlı marka, tanım, isteğe bağlı barkod (8–14 rakam, hanede tekil), paket miktarı ve birimi. Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyorsa (aynı birim, kg↔g, lt↔ml) dönüşüm otomatik; çevrilemiyorsa dönüşüm zorunlu ("5 kg = 33 yıkama", "1 paket = 50 tablet", "75 ml = 1 adet"). Ürün tipinin birimi sonradan değişirse "Dönüşüm gerekli" uyarısı.
- Ürün görselleri: birden çok görsel, mevcut ek dosya altyapısıyla (WebP'ye dönüştürme, küçük resim, yetkili okuma; hane üyeleri görür). Liste küçük resmi ve görüntüleyici.
- Ürün tipi detayında "Ürünler (marka / paket)" bölümü; `/stok/urunler` ürün kataloğu (ürün tipine göre gruplu, marka/tanım/barkodla arama, arşiv).
- Migration `013-stores-products` (index'ler). Seed: Çakır Ailesi için 8 mağaza ve 22 ürün.
- Testler: zahmet skoru, sıralama, uzaklık gösterimi, paket dönüşümü ve açıklaması (birim); mağaza şeması ve kuralları, ürün dönüşüm zorunluluğu, barkod tekilliği, görsel bağlama/sıralama/çıkarma ve okuma yetkisi, arşiv (entegrasyon).
## [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 ### 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`. - 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 +65,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. - 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). - 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 ## [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ı). Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
@@ -340,7 +377,9 @@ Faz 1: Framework, klasör yapısı ve altyapı.
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `.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`. - `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.12.0...HEAD
[0.12.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.11.0...v0.12.0
[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.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.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 [0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
+2
View File
@@ -322,6 +322,8 @@ 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 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 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 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.
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
--- ---
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.10.0", "version": "0.12.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "butcem", "name": "butcem",
"version": "0.10.0", "version": "0.12.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
"@tanstack/react-table": "^9.3.1", "@tanstack/react-table": "^9.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.10.0", "version": "0.12.0",
"private": true, "private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": { "engines": {
+92
View File
@@ -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 } },
);
}
},
};
+12
View File
@@ -0,0 +1,12 @@
import { ProductModel } from "@/models/product";
import { StoreModel } from "@/models/store";
import type { Migration } from "./types";
export const migration: Migration = {
name: "013-stores-products",
description: "mağaza ve ürün (marka/paket) koleksiyonlarının index'leri",
async up() {
await StoreModel.createIndexes();
await ProductModel.createIndexes();
},
};
+10
View File
@@ -0,0 +1,10 @@
import { ProductPriceModel } from "@/models/product-price";
import type { Migration } from "./types";
export const migration: Migration = {
name: "014-product-prices",
description: "ürün fiyat kayıtları (productPrices) index'leri",
async up() {
await ProductPriceModel.createIndexes();
},
};
+6
View File
@@ -11,6 +11,9 @@ import { migration as m008 } from "./008-in-kind";
import { migration as m009 } from "./009-assets"; import { migration as m009 } from "./009-assets";
import { migration as m010 } from "./010-budgets"; import { migration as m010 } from "./010-budgets";
import { migration as m011 } from "./011-stock"; import { migration as m011 } from "./011-stock";
import { migration as m012 } from "./012-stock-counts";
import { migration as m013 } from "./013-stores-products";
import { migration as m014 } from "./014-product-prices";
import type { Migration } from "./types"; import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
@@ -26,6 +29,9 @@ export const MIGRATIONS: Migration[] = [
m009, m009,
m010, m010,
m011, m011,
m012,
m013,
m014,
]; ];
const COLLECTION = "migrations"; const COLLECTION = "migrations";
+6
View File
@@ -35,6 +35,9 @@ describe("seed", () => {
expect(summary.created.assets).toBe(4); expect(summary.created.assets).toBe(4);
expect(summary.created.budgets).toBe(2); expect(summary.created.budgets).toBe(2);
expect(summary.created.stock).toBe(19); expect(summary.created.stock).toBe(19);
expect(summary.created.stores).toBe(8);
expect(summary.created.products).toBe(22);
expect(summary.created.prices).toBeGreaterThan(200);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
}); });
@@ -55,6 +58,9 @@ describe("seed", () => {
assets: 0, assets: 0,
budgets: 0, budgets: 0,
stock: 0, stock: 0,
stores: 0,
products: 0,
prices: 0,
}); });
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
+18
View File
@@ -13,6 +13,7 @@ import { runMigrations } from "../migrations";
import { seedContacts } from "./contacts"; import { seedContacts } from "./contacts";
import { seedAssets } from "./assets"; import { seedAssets } from "./assets";
import { seedBudgets } from "./budgets"; import { seedBudgets } from "./budgets";
import { seedProductPrices, seedShopping } from "./shopping";
import { seedStock } from "./stock"; import { seedStock } from "./stock";
import { seedInKind } from "./in-kind"; import { seedInKind } from "./in-kind";
import { seedInstallments } from "./installments"; import { seedInstallments } from "./installments";
@@ -57,6 +58,9 @@ export type SeedSummary = {
assets: number; assets: number;
budgets: number; budgets: number;
stock: number; stock: number;
stores: number;
products: number;
prices: number;
}; };
accounts: SeedAccountRow[]; accounts: SeedAccountRow[];
}; };
@@ -83,6 +87,10 @@ const DOMAIN_COLLECTIONS = [
"stockCategories", "stockCategories",
"productTypes", "productTypes",
"stockMovements", "stockMovements",
"stockCounts",
"stores",
"products",
"productPrices",
]; ];
type AuthApi = ReturnType<typeof getAuth>["api"]; type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -139,6 +147,9 @@ export async function runSeed({
assets: 0, assets: 0,
budgets: 0, budgets: 0,
stock: 0, stock: 0,
stores: 0,
products: 0,
prices: 0,
}, },
accounts: [], accounts: [],
}; };
@@ -323,6 +334,13 @@ export async function runSeed({
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`); if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
summary.created.stock = await seedStock(householdIds, userIdMap); summary.created.stock = await seedStock(householdIds, userIdMap);
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`); if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
const shopping = await seedShopping(householdIds, userIdMap);
summary.created.stores = shopping.stores;
summary.created.products = shopping.products;
if (shopping.stores || shopping.products)
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
// 7) Geliştirme hesapları tablosu // 7) Geliştirme hesapları tablosu
const all = [ const all = [
+494
View File
@@ -0,0 +1,494 @@
import { Types } from "mongoose";
import { ProductModel } from "@/models/product";
import { ProductPriceModel } from "@/models/product-price";
import { ProductTypeModel } from "@/models/product-type";
import { StoreModel } from "@/models/store";
import type { MealCardProvider } from "@/shared/accounts";
import { autoConvertible } from "@/shared/products";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { foldTr } from "@/shared/search";
import type { StoreKind } from "@/shared/stores";
/**
* Örnek mağazalar ve ürünler (Faz 12). Çakır Ailesi (Kadıköy) için marketler, pazar ve online
* platform; stok ürün tiplerine bağlı marka/paketler. Elle dönüşüm gereken örnekler: deterjan
* (kg → yıkama), tablet paketi (paket → tablet), diş macunu/şampuan (ml → adet). Görsel yok.
* Idempotent (`seedRef`). Miktarlar binde bir, tutarlar kuruş.
*/
type SeedStore = {
ref: string;
name: string;
kind: StoreKind;
branch?: string;
distanceM?: number;
carry: number;
deliveryFee?: number;
freeDeliveryMin?: number;
mealCards?: MealCardProvider[];
note?: string;
};
export const STORES: SeedStore[] = [
{
ref: "migros",
name: "Migros",
kind: "market",
branch: "Bahariye",
distanceM: 1_200,
carry: 2,
mealCards: ["pluxee", "multinet", "edenred", "setcard"],
},
{ ref: "a101", name: "A101", kind: "market", branch: "Moda", distanceM: 400, carry: 1 },
{ ref: "bim", name: "BİM", kind: "market", branch: "Yeldeğirmeni", distanceM: 650, carry: 2 },
{
ref: "sok",
name: "Şok",
kind: "market",
branch: "Rasimpaşa",
distanceM: 300,
carry: 1,
mealCards: ["multinet", "pluxee"],
},
{
ref: "carrefoursa",
name: "CarrefourSA",
kind: "market",
branch: "Acıbadem",
distanceM: 3_800,
carry: 4,
mealCards: ["pluxee", "multinet", "edenred", "setcard", "metropol"],
note: "Hafta sonu arabayla; büyük alışveriş.",
},
{
ref: "sali-pazari",
name: "Salı Pazarı",
kind: "bazaar",
branch: "Kadıköy",
distanceM: 1_900,
carry: 4,
note: "Salı ve cuma; sebze-meyve.",
},
{
ref: "getir-buyuk",
name: "Getir Büyük",
kind: "online",
carry: 1,
deliveryFee: 39_90,
freeDeliveryMin: 750_00,
mealCards: ["multinet"],
},
{
ref: "metro",
name: "Metro Toptancı",
kind: "market",
branch: "Kozyatağı",
distanceM: 9_500,
carry: 5,
},
];
type SeedProduct = {
ref: string;
/** Stok seed'indeki ürün tipi `seedRef`'i. */
type: string;
brand: string;
name?: string;
barcode?: string;
qty: number;
unit: string;
/** Elle dönüşüm (bir paket = bu kadar ürün tipi birimi). */
typeQty?: number;
};
const K = 1000;
export const PRODUCTS: SeedProduct[] = [
{
ref: "ariel-5kg",
type: "toz-deterjan",
brand: "Ariel",
name: "Matik Dağ Esintisi",
qty: 5 * K,
unit: "kg",
typeQty: 33 * K,
barcode: "8690000100015",
},
{
ref: "omo-6kg",
type: "toz-deterjan",
brand: "Omo",
name: "Matik Active Fresh",
qty: 6 * K,
unit: "kg",
typeQty: 40 * K,
},
{
ref: "bingo-4kg",
type: "toz-deterjan",
brand: "Bingo",
name: "Matik",
qty: 4 * K,
unit: "kg",
typeQty: 26 * K,
},
{
ref: "fairy-50",
type: "tablet-deterjan",
brand: "Fairy",
name: "Platinum Plus",
qty: 1 * K,
unit: "paket",
typeQty: 50 * K,
barcode: "8690000100022",
},
{
ref: "finish-40",
type: "tablet-deterjan",
brand: "Finish",
name: "Quantum",
qty: 40 * K,
unit: "tablet",
},
{
ref: "yayla-mercimek",
type: "kirmizi-mercimek",
brand: "Yayla",
name: "Kırmızı Mercimek",
qty: 1 * K,
unit: "kg",
},
{
ref: "reis-mercimek",
type: "kirmizi-mercimek",
brand: "Reis",
name: "Kırmızı Mercimek",
qty: 1 * K,
unit: "kg",
},
{
ref: "duru-mercimek",
type: "kirmizi-mercimek",
brand: "Duru",
name: "Kırmızı Mercimek",
qty: 2_500,
unit: "kg",
},
{ ref: "yayla-baldo", type: "pirinc", brand: "Yayla", name: "Baldo", qty: 2_500, unit: "kg" },
{ ref: "reis-baldo", type: "pirinc", brand: "Reis", name: "Baldo", qty: 1 * K, unit: "kg" },
{
ref: "pinar-sut",
type: "sut",
brand: "Pınar",
name: "Tam Yağlı Süt",
qty: 1 * K,
unit: "lt",
barcode: "8690000100039",
},
{ ref: "sutas-sut", type: "sut", brand: "Sütaş", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt" },
{
ref: "icim-sut-500",
type: "sut",
brand: "İçim",
name: "Yarım Yağlı Süt",
qty: 500 * K,
unit: "ml",
},
{
ref: "keskinoglu-30",
type: "yumurta",
brand: "Keskinoğlu",
name: "L Boy 30'lu",
qty: 30 * K,
unit: "adet",
},
{
ref: "koy-yumurta-15",
type: "yumurta",
brand: "Köy Yumurtası",
name: "15'li",
qty: 15 * K,
unit: "adet",
},
{
ref: "pinar-peynir",
type: "beyaz-peynir",
brand: "Pınar",
name: "Beyaz Peynir",
qty: 500 * K,
unit: "g",
},
{
ref: "yumos-1440",
type: "yumusatici",
brand: "Yumoş",
name: "Extra Lilyum",
qty: 1_440 * K,
unit: "ml",
},
{
ref: "selpak-32",
type: "tuvalet-kagidi",
brand: "Selpak",
name: "32'li",
qty: 32 * K,
unit: "rulo",
},
{
ref: "solo-24",
type: "tuvalet-kagidi",
brand: "Solo",
name: "24'lü",
qty: 24 * K,
unit: "rulo",
},
{
ref: "colgate-75",
type: "dis-macunu",
brand: "Colgate",
name: "Total 75 ml",
qty: 75 * K,
unit: "ml",
typeQty: 1 * K,
},
{
ref: "sensodyne-75",
type: "dis-macunu",
brand: "Sensodyne",
name: "Repair 75 ml",
qty: 75 * K,
unit: "ml",
typeQty: 1 * K,
},
{
ref: "elidor-500",
type: "sampuan",
brand: "Elidor",
name: "500 ml",
qty: 500 * K,
unit: "ml",
typeQty: 1 * K,
},
];
export async function seedShopping(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<{ stores: number; products: number }> {
const actor = new Types.ObjectId(users.get("ayse")!);
const householdId = householdIds.get("Çakır Ailesi")!;
let stores = 0;
for (const s of STORES) {
if (await StoreModel.exists({ householdId, seedRef: s.ref })) continue;
await StoreModel.create({
householdId,
name: s.name,
nameKey: foldTr(s.name),
kind: s.kind,
branch: s.branch ?? "",
distanceM: s.kind === "online" ? null : (s.distanceM ?? null),
carryDifficulty: s.carry,
deliveryFeeMinor: s.kind === "online" ? (s.deliveryFee ?? null) : null,
freeDeliveryMinMinor: s.kind === "online" ? (s.freeDeliveryMin ?? null) : null,
mealCardAccepted: Boolean(s.mealCards?.length),
mealCardProviders: s.mealCards ?? [],
note: s.note ?? "",
seedRef: s.ref,
createdBy: actor,
updatedBy: actor,
});
stores++;
}
const types = await ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1, unit: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string; unit: string }[]>();
const typeByRef = new Map(types.map((x) => [x.seedRef, x]));
let products = 0;
for (const p of PRODUCTS) {
const type = typeByRef.get(p.type);
if (!type || (await ProductModel.exists({ householdId, seedRef: p.ref }))) continue;
if (!autoConvertible(p.unit, type.unit) && !p.typeQty)
throw new Error(`Seed ürünü ${p.ref}: ${p.unit} → ${type.unit} dönüşümü eksik`);
await ProductModel.create({
householdId,
productTypeId: type._id,
brand: p.brand,
name: p.name ?? "",
searchKey: foldTr(`${p.brand} ${p.name ?? ""}`),
barcode: p.barcode ?? null,
packageQuantity: p.qty,
packageUnit: p.unit,
typeQuantity: autoConvertible(p.unit, type.unit) ? null : p.typeQty,
seedRef: p.ref,
createdBy: actor,
updatedBy: actor,
});
products++;
}
return { stores, products };
}
// --- Fiyatlar (Faz 12b) ---
/** Bugünkü paket fiyatı (kuruş, Kasım 2026 tahmini). */
const CURRENT_PRICE: Record<string, number> = {
"ariel-5kg": 519_90,
"omo-6kg": 559_90,
"bingo-4kg": 329_90,
"fairy-50": 479_90,
"finish-40": 419_90,
"yayla-mercimek": 84_90,
"reis-mercimek": 79_90,
"duru-mercimek": 189_90,
"yayla-baldo": 259_90,
"reis-baldo": 109_90,
"pinar-sut": 41_90,
"sutas-sut": 39_90,
"icim-sut-500": 23_90,
"keskinoglu-30": 174_90,
"koy-yumurta-15": 119_90,
"pinar-peynir": 209_90,
"yumos-1440": 139_90,
"selpak-32": 329_90,
"solo-24": 229_90,
"colgate-75": 84_90,
"sensodyne-75": 159_90,
"elidor-500": 119_90,
};
/** Mağaza fiyat çarpanı (indirim marketi ucuz, online pahalı). */
const STORE_FACTOR: Record<string, number> = {
migros: 1.06,
a101: 0.94,
bim: 0.91,
sok: 0.96,
carrefoursa: 1.0,
"getir-buyuk": 1.12,
metro: 0.88,
};
const PRICE_STORES = Object.keys(STORE_FACTOR);
/** Aylık fiyat artışı. */
const MONTHLY_INFLATION = 0.025;
/** x,90 biçimine yuvarla (ör. 87,34 → 86,90 / 87,90). */
const ninety = (minor: number) => Math.max(90, Math.round((minor - 90) / 100) * 100 + 90);
type SeedPrice = {
ref: string;
product: string;
store: string;
daysAgo: number;
priceMinor: number;
isPromo?: boolean;
promoUntilDaysAgo?: number;
};
/**
* Her ürün 3 mağazada (ürün sırasına göre dönüşümlü), son 6 ayda ~45 günde bir fiyat.
* Kampanyalar: Ariel @ Migros hâlâ süren (%20), Pınar süt @ A101 süresi bitmiş (önceki normal
* fiyata dönülür). Reis mercimek @ Metro yalnızca eski kayıtlarla ("eski fiyat").
*/
export function seedPrices(): SeedPrice[] {
const out: SeedPrice[] = [];
PRODUCTS.forEach((p, i) => {
const current = CURRENT_PRICE[p.ref]!;
const stores = [0, 2, 4].map((k) => PRICE_STORES[(i + k) % PRICE_STORES.length]!);
stores.forEach((store, si) => {
for (let step = 0; step < 4; step++) {
const daysAgo = 170 - step * 45 - si * 7 - (i % 5);
if (daysAgo < 0) continue;
if (p.ref === "reis-mercimek" && store === "metro" && daysAgo < 100) continue;
const months = daysAgo / 30;
const price = (current * STORE_FACTOR[store]!) / (1 + MONTHLY_INFLATION) ** months;
out.push({
ref: `${p.ref}:${store}:${step}`,
product: p.ref,
store,
daysAgo,
priceMinor: ninety(price),
});
}
});
});
out.push(
{
ref: "ariel-5kg:migros:promo",
product: "ariel-5kg",
store: "migros",
daysAgo: 4,
priceMinor: ninety(CURRENT_PRICE["ariel-5kg"]! * 1.06 * 0.8),
isPromo: true,
promoUntilDaysAgo: -6,
},
{
ref: "pinar-sut:a101:normal",
product: "pinar-sut",
store: "a101",
daysAgo: 30,
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.94),
},
{
ref: "pinar-sut:a101:promo",
product: "pinar-sut",
store: "a101",
daysAgo: 12,
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.75),
isPromo: true,
promoUntilDaysAgo: 3,
},
);
return out;
}
export async function seedProductPrices(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<number> {
const actor = new Types.ObjectId(users.get("ayse")!);
const householdId = householdIds.get("Çakır Ailesi")!;
const [products, stores] = await Promise.all([
ProductModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
StoreModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
]);
const productByRef = new Map(products.map((p) => [p.seedRef, p._id]));
const storeByRef = new Map(stores.map((x) => [x.seedRef, x._id]));
const existing = new Set(
(
await ProductPriceModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ seedRef: string }[]>()
).map((x) => x.seedRef),
);
const today = todayIstanbul();
const base = Date.now() - 200 * 86_400_000;
const docs = seedPrices()
.filter((x) => !existing.has(x.ref) && productByRef.has(x.product) && storeByRef.has(x.store))
.sort((a, b) => b.daysAgo - a.daysAgo)
.map((x, i) => {
const at = new Date(base + i * 1000);
return {
householdId,
productId: productByRef.get(x.product)!,
storeId: storeByRef.get(x.store)!,
date: addDays(today, -x.daysAgo),
priceMinor: x.priceMinor,
isPromo: x.isPromo ?? false,
promoUntil: x.promoUntilDaysAgo === undefined ? null : addDays(today, -x.promoUntilDaysAgo),
source: "manual",
note: "",
deletedAt: null,
seedRef: x.ref,
createdBy: actor,
createdAt: at,
updatedAt: at,
};
});
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
return docs.length;
}
+72 -17
View File
@@ -2,6 +2,7 @@ import { Types } from "mongoose";
import { installDefaultStockCategories } from "@/features/stock/service"; import { installDefaultStockCategories } from "@/features/stock/service";
import { ProductTypeModel } from "@/models/product-type"; import { ProductTypeModel } from "@/models/product-type";
import { StockCategoryModel } from "@/models/stock-category"; import { StockCategoryModel } from "@/models/stock-category";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement"; import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates"; import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence"; 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 * Ö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; * ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, periyodik sayım, SKT).
* idempotent: ürün tipi `seedRef` ile bir kez oluşturulur. Miktarlar binde bir. * 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 = { type SeedProduct = {
@@ -30,8 +33,8 @@ type SeedProduct = {
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" }; 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. */ /** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
stopRefillDaysAgo?: number; stopRefillDaysAgo?: number;
/** Her n haftada bir küçük sayım farkı (−%). */ /** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
countDrift?: { everyWeeks: number; pct: number }; countDrift?: { everyCounts: number; pct: number };
}; };
const K = 1000; const K = 1000;
@@ -188,7 +191,7 @@ export const PRODUCTS: SeedProduct[] = [
pack: 50 * K, pack: 50 * K,
perDay: 700, perDay: 700,
location: "Banyo dolabı", location: "Banyo dolabı",
countDrift: { everyWeeks: 8, pct: 4 }, countDrift: { everyCounts: 2, pct: 4 },
}, },
{ {
ref: "yumusatici", ref: "yumusatici",
@@ -221,7 +224,7 @@ export const PRODUCTS: SeedProduct[] = [
pack: 16 * K, pack: 16 * K,
perDay: 600, perDay: 600,
location: "Banyo dolabı", location: "Banyo dolabı",
countDrift: { everyWeeks: 10, pct: 6 }, countDrift: { everyCounts: 3, pct: 6 },
}, },
// Kişisel Temizlik // Kişisel Temizlik
{ {
@@ -259,6 +262,12 @@ export const PRODUCTS: SeedProduct[] = [
const START_DAYS_AGO = 182; 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 = { type Row = {
kind: "purchase" | "consume" | "waste" | "count_adjust"; kind: "purchase" | "consume" | "waste" | "count_adjust";
daysAgo: number; daysAgo: number;
@@ -267,17 +276,24 @@ type Row = {
wasteReason?: string | null; wasteReason?: string | null;
note?: string; note?: string;
initial?: boolean; 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 type SeedCount = { daysAgo: number; expected: number; actual: number };
export function simulate(p: SeedProduct, today: string): Row[] {
/**
* 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 rows: Row[] = [];
const counts: SeedCount[] = [];
const plan = COUNT_PLANS[p.category.split(".")[0]!];
let stock = p.target; let stock = p.target;
let purchases = 0; let purchases = 0;
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true }); 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) { 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). // Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg).
const step = p.step ?? K; const step = p.step ?? K;
const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step)); 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 }); rows.push({ kind: "consume", daysAgo: d, quantity: consume });
stock -= consume; stock -= consume;
} }
if (p.countDrift && week % p.countDrift.everyWeeks === 0 && stock > 0) { if (plan?.on(d)) {
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100)); const expected = stock;
if (drift > 0) { const nth = counts.length + 1;
rows.push({ kind: "count_adjust", daysAgo: d, quantity: -drift, note: "Sayım" }); 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; stock -= drift;
} }
counts.push({ daysAgo: d, expected, actual: stock });
} }
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo; const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
if (canRefill && stock < (p.target * 35) / 100) { 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( export async function seedStock(
@@ -321,6 +346,12 @@ export async function seedStock(
const actor = new Types.ObjectId(users.get("ayse")!); const actor = new Types.ObjectId(users.get("ayse")!);
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor); for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
const householdId = householdIds.get("Çakır Ailesi")!; 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" } }) const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } })
.select({ defaultKey: 1 }) .select({ defaultKey: 1 })
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>(); .lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
@@ -344,9 +375,33 @@ export async function seedStock(
updatedBy: actor, updatedBy: actor,
}); });
const base = Date.now() - START_DAYS_AGO * 86_400_000; 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. // Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
await StockMovementModel.collection.insertMany( await StockMovementModel.collection.insertMany(
simulate(p, today).map((r, i) => { rows.map((r, i) => {
const at = new Date(base + i * 1000); const at = new Date(base + i * 1000);
return { return {
householdId, householdId,
@@ -358,7 +413,7 @@ export async function seedStock(
expiryDate: r.expiryDate ?? null, expiryDate: r.expiryDate ?? null,
note: r.note ?? "", note: r.note ?? "",
initial: r.initial ?? false, initial: r.initial ?? false,
countId: null, countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
deletedAt: null, deletedAt: null,
seedRef: `${p.ref}:${i}`, seedRef: `${p.ref}:${i}`,
createdBy: actor, createdBy: actor,
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { StoresView } from "@/features/stores/components/stores-view";
import { listStores } from "@/features/stores/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stores.title };
/** /magazalar — marketler, online platformlar ve pazarlar; uzaklık, taşıma ve zahmet skoru. */
export default async function StoresPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await listStores(user.id, household.id);
if (!data) notFound();
return (
<>
<PageHeader title={tr.stores.title} description={tr.stores.description} />
{!data.canWrite ? (
<p className="text-sm text-muted-foreground">{tr.stores.readOnly}</p>
) : null}
<StoresView stores={data.stores} canWrite={data.canWrite} />
</>
);
}
+26 -3
View File
@@ -1,5 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { TypePriceComparison } from "@/features/prices/components/type-price-comparison";
import { getTypePriceComparison } from "@/features/prices/service";
import { ProductsSection } from "@/features/products/components/products-section";
import { listProducts, listProductTypeOptions } from "@/features/products/service";
import { ProductDetailView } from "@/features/stock/components/product-detail-view"; import { ProductDetailView } from "@/features/stock/components/product-detail-view";
import { getProductType, getStockOptions } from "@/features/stock/service"; import { getProductType, getStockOptions } from "@/features/stock/service";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
@@ -11,10 +15,29 @@ export const metadata: Metadata = { title: tr.stock.detail.movements };
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) { export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
const { user, household } = await requireActiveHousehold("viewer"); const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params; const { id } = await params;
const [data, options] = await Promise.all([ const [data, options, products, types, prices] = await Promise.all([
getProductType(user.id, household.id, id), getProductType(user.id, household.id, id),
getStockOptions(user.id, household.id), getStockOptions(user.id, household.id),
listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
listProductTypeOptions(user.id, household.id),
getTypePriceComparison(user.id, household.id, id),
]); ]);
if (!data || !options) notFound(); if (!data || !options || !products || !types || !prices) notFound();
return <ProductDetailView data={data} categories={options.categories} />; return (
<ProductDetailView
data={data}
categories={options.categories}
products={
<>
<TypePriceComparison data={prices} />
<ProductsSection
productTypeId={data.id}
products={products.products}
types={types}
canWrite={products.canWrite}
/>
</>
}
/>
);
} }
+16
View File
@@ -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} />;
}
+19
View File
@@ -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} />;
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ProductPriceView } from "@/features/prices/components/product-price-view";
import { getProductPrices } from "@/features/prices/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.products.title };
/** /stok/urunler/:id — ürün (marka/paket): mağaza fiyatları, birim fiyat, fiyat geçmişi. */
export default async function ProductPage({ params }: PageProps<"/stok/urunler/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
const data = await getProductPrices(user.id, household.id, id);
if (!data) notFound();
return <ProductPriceView data={data} />;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ProductsCatalog } from "@/features/products/components/products-catalog";
import { listProducts, listProductTypeOptions } from "@/features/products/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.products.title };
/** /stok/urunler — ürün kataloğu (marka/paket), ürün tipine göre gruplu. */
export default async function ProductsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [data, types] = await Promise.all([
listProducts(user.id, household.id, { includeArchived: true }),
listProductTypeOptions(user.id, household.id),
]);
if (!data || !types) notFound();
return <ProductsCatalog products={data.products} types={types} canWrite={data.canWrite} />;
}
+29
View File
@@ -31,6 +31,14 @@
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-series-1: var(--series-1);
--color-series-2: var(--series-2);
--color-series-3: var(--series-3);
--color-series-4: var(--series-4);
--color-series-5: var(--series-5);
--color-series-6: var(--series-6);
--color-series-7: var(--series-7);
--color-series-8: var(--series-8);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
@@ -95,6 +103,17 @@
--chart-3: var(--expense); --chart-3: var(--expense);
--chart-4: var(--warning); --chart-4: var(--warning);
--chart-5: oklch(0.65 0.08 230); --chart-5: oklch(0.65 0.08 230);
/* Kategorik seriler (mağaza vb. kimlik; sabit sıra, döngü yok). dataviz doğrulayıcısından
geçti (açık: kart #fff; 3 yuva 3:1 altında → lejant + tablo zorunlu). */
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--series-4: #eda100;
--series-5: #e87ba4;
--series-6: #008300;
--series-7: #6250d6;
--series-8: #e34948;
--radius: 0.625rem; --radius: 0.625rem;
/* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */ /* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */
@@ -156,6 +175,16 @@
--chart-4: var(--warning); --chart-4: var(--warning);
--chart-5: oklch(0.7 0.07 230); --chart-5: oklch(0.7 0.07 230);
/* Koyu yüzey (#101a2f) için aynı tonların koyu adımları; hepsi ≥ 3:1. */
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
--swatch-blue: oklch(0.74 0.13 258); --swatch-blue: oklch(0.74 0.13 258);
--swatch-sky: oklch(0.74 0.13 230); --swatch-sky: oklch(0.74 0.13 230);
--swatch-teal: oklch(0.74 0.13 185); --swatch-teal: oklch(0.74 0.13 185);
+3 -1
View File
@@ -8,6 +8,7 @@ import {
Package, Package,
Settings, Settings,
ShoppingCart, ShoppingCart,
Store,
Tags, Tags,
Target, Target,
UserCog, UserCog,
@@ -27,7 +28,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
export const CURRENT_PHASE = 11.1; export const CURRENT_PHASE = 12.2;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -41,6 +42,7 @@ export const MAIN_NAV: NavItem[] = [
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 }, { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
+1 -1
View File
@@ -5,7 +5,7 @@ export function PageHeader({
actions, actions,
}: { }: {
title: string; title: string;
description?: string; description?: React.ReactNode;
actions?: React.ReactNode; actions?: React.ReactNode;
}) { }) {
return ( return (
@@ -38,11 +38,17 @@ export function AttachmentPicker({
onChange, onChange,
allowedKinds, allowedKinds,
onBusyChange, onBusyChange,
hint,
addLabel,
}: { }: {
value: AttachmentInfo[]; value: AttachmentInfo[];
onChange: (items: AttachmentInfo[]) => void; onChange: (items: AttachmentInfo[]) => void;
allowedKinds: readonly AttachmentKind[]; allowedKinds: readonly AttachmentKind[];
onBusyChange?: (busy: boolean) => void; onBusyChange?: (busy: boolean) => void;
/** Boşken gösterilen ipucu (varsayılan: fiş/fatura). */
hint?: string;
/** Düğme metni (varsayılan: "Ek ekle"). */
addLabel?: string;
}) { }) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(0); const [uploading, setUploading] = useState(0);
@@ -91,14 +97,16 @@ export function AttachmentPicker({
disabled={room <= 0 || uploading > 0} disabled={room <= 0 || uploading > 0}
> >
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />} {uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
{uploading > 0 ? t.uploading : t.add} {uploading > 0 ? t.uploading : (addLabel ?? t.add)}
</Button> </Button>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{value.length {value.length
? t.count(value.length) ? t.count(value.length)
: allowedKinds.includes("pdf") : hint
? t.addHint.imagePdf ? hint
: t.addHint.image} : allowedKinds.includes("pdf")
? t.addHint.imagePdf
: t.addHint.image}
</span> </span>
<input <input
ref={inputRef} ref={inputRef}
+4
View File
@@ -11,6 +11,7 @@ import { getStorage } from "@/lib/storage";
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process"; import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
import { AccountModel } from "@/models/account"; import { AccountModel } from "@/models/account";
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment"; import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
import { ProductModel } from "@/models/product";
import { TransactionModel } from "@/models/transaction"; import { TransactionModel } from "@/models/transaction";
import { import {
detectFileType, detectFileType,
@@ -267,6 +268,9 @@ export async function readAttachmentForUser(
ownerUserId: { $ne: new Types.ObjectId(userId) }, ownerUserId: { $ne: new Types.ObjectId(userId) },
}); });
if (hidden) return null; if (hidden) return null;
} else if (a.ownerType === "product") {
// Ürün görselleri hane ortaktır (arşivli ürün dahil).
if (!(await ProductModel.exists({ _id: a.ownerId, householdId: a.householdId }))) return null;
} else { } else {
return null; return null;
} }
+44
View File
@@ -0,0 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { productPriceFormSchema } from "@/shared/schemas/product-price";
import { addProductPrice, deleteProductPrice } from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T, R>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = schema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function addProductPriceAction(input: unknown) {
return run(productPriceFormSchema, input, (u, h, d) => addProductPrice(u, h, d));
}
export async function deleteProductPriceAction(priceId: unknown) {
const id = idSchema.safeParse(priceId);
if (!id.success) return fail(tr.common.notFound);
return run(z.unknown(), null, (u, h) => deleteProductPrice(u, h, id.data));
}
@@ -0,0 +1,124 @@
import { Clock, Tag, Trophy } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatMoney } from "@/shared/format";
import { formatUnitPrice, type RankedOffer } from "@/shared/prices";
import type { StoreOffer } from "../service";
const t = tr.productPrices;
export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
/** Mağaza renk işareti (kimlik; metin her zaman yanında). */
export function StoreDot({ slot, className }: { slot: number; className?: string }) {
return (
<span
aria-hidden
className={cn("mr-1.5 inline-block size-2.5 shrink-0 rounded-full", className)}
style={{ backgroundColor: `var(--series-${slot})` }}
/>
);
}
/** Teklif rozetleri: en uygun, kampanya, kampanya bitti, eski fiyat. */
function OfferBadges({ offer }: { offer: RankedOffer<StoreOffer> }) {
const p = offer.price;
return (
<span className="flex flex-wrap items-center gap-1">
{offer.cheapest ? (
<Badge className="gap-1 bg-income text-income-foreground">
<Trophy className="size-3" aria-hidden />
{t.cheapest}
</Badge>
) : null}
{p.isPromo && !offer.promoEnded ? (
<Badge variant="outline" className="gap-1 border-budget/50 text-budget">
<Tag className="size-3" aria-hidden />
{t.promo}
{p.promoUntil ? ` · ${t.promoUntil(ymdLabel(p.promoUntil))}` : ""}
</Badge>
) : null}
{offer.fellBack ? <Badge variant="secondary">{t.fellBack}</Badge> : null}
{offer.promoEnded ? <Badge variant="secondary">{t.promoEnded}</Badge> : null}
{offer.stale ? (
<Badge
variant="outline"
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
title={t.staleHint}
>
<Clock className="size-3" aria-hidden />
{t.stale(offer.ageDays)}
</Badge>
) : null}
</span>
);
}
/**
* Birim fiyata göre sıralı teklifler. `showProduct`: ürün tipi karşılaştırmasında marka/paket
* ve ürün sayfası bağlantısı.
*/
export function OffersList({
offers,
typeUnit,
showProduct = false,
}: {
offers: RankedOffer<StoreOffer>[];
typeUnit: string;
showProduct?: boolean;
}) {
return (
<ol className="divide-y rounded-xl border bg-card">
{offers.map((o) => (
<li
key={o.key}
className={cn(
"grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1 p-3",
o.cheapest && "bg-income/5",
)}
>
<span className="pt-0.5 text-sm text-muted-foreground tabular-nums">{o.rank ?? "–"}</span>
<div className="min-w-0">
{showProduct ? (
<p className="truncate font-medium">
<Link href={`/stok/urunler/${o.productId}`} className="hover:underline">
{o.productLabel}
</Link>{" "}
<span className="text-xs font-normal text-muted-foreground">{o.packageLabel}</span>
</p>
) : null}
<p
className={cn(
"flex flex-wrap items-center",
showProduct ? "text-sm text-muted-foreground" : "font-medium",
)}
>
<StoreDot slot={o.store.slot} />
{o.store.label}
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
· {ymdLabel(o.price.date)}
</span>
</p>
<div className="mt-1">
<OfferBadges offer={o} />
</div>
</div>
<div className="text-right tabular-nums">
<p className="font-semibold">
{o.unitPriceMinor === null ? (
<span className="text-sm font-normal text-muted-foreground">{t.noConversion}</span>
) : (
formatUnitPrice(o.unitPriceMinor, typeUnit)
)}
</p>
<p className="text-xs text-muted-foreground">
{formatMoney(o.price.priceMinor)}
{o.diffPct ? ` · ${t.diff(o.diffPct)}` : ""}
</p>
</div>
</li>
))}
</ol>
);
}
@@ -0,0 +1,200 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import { todayIstanbul } from "@/shared/dates";
import { parseMoney } from "@/shared/money";
import { formatUnitPrice, unitPrice } from "@/shared/prices";
import { addProductPriceAction } from "../actions";
import type { PriceProductOption } from "../options";
import type { StoreRef } from "../service";
import { StoreDot } from "./offers-list";
const t = tr.productPrices;
type Errors = Record<string, string | undefined>;
/** Elle fiyat girişi: ürün (sabit olabilir), mağaza, tarih, paket fiyatı, kampanya. */
export function PriceFormDialog({
products,
stores,
productId,
onClose,
}: {
products: PriceProductOption[];
stores: StoreRef[];
/** Ürün sayfasından açılınca sabit. */
productId?: string;
onClose: () => void;
}) {
const [v, setV] = useState({
productId: productId ?? (products.length === 1 ? products[0]!.id : ""),
storeId: "",
date: todayIstanbul(),
price: "",
isPromo: false,
promoUntil: "",
note: "",
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
const product = products.find((p) => p.id === v.productId);
const minor = parseMoney(v.price);
const preview =
product?.inTypeUnit && minor && minor > 0
? formatUnitPrice(unitPrice(minor, product.inTypeUnit), product.typeUnit)
: null;
const f = t.fields;
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await addProductPriceAction(v);
setBusy(false);
if (r.ok) {
toast.success(t.toast.saved);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={t.addTitle}
description={product ? `${product.label} · ${product.packageLabel}` : undefined}
footer={
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="price-form" disabled={busy}>
{tr.common.save}
</Button>
</>
}
>
<form id="price-form" onSubmit={submit} noValidate>
<FieldGroup>
{productId ? null : (
<Field data-invalid={Boolean(errors.productId)}>
<FieldLabel htmlFor="pp-product">{f.product}</FieldLabel>
<Select value={v.productId} onValueChange={(x) => set("productId", x)}>
<SelectTrigger
id="pp-product"
className="w-full"
aria-invalid={Boolean(errors.productId)}
>
<SelectValue placeholder={t.validation.product} />
</SelectTrigger>
<SelectContent>
{products.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.label}{" "}
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
</Field>
)}
<Field data-invalid={Boolean(errors.storeId)}>
<FieldLabel htmlFor="pp-store">{f.store}</FieldLabel>
<Select value={v.storeId} onValueChange={(x) => set("storeId", x)}>
<SelectTrigger
id="pp-store"
className="w-full"
aria-invalid={Boolean(errors.storeId)}
>
<SelectValue placeholder={t.validation.store} />
</SelectTrigger>
<SelectContent>
{stores.map((s) => (
<SelectItem key={s.id} value={s.id}>
<StoreDot slot={s.slot} />
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.storeId ? <FieldError>{errors.storeId}</FieldError> : null}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="pp-date">{f.date}</FieldLabel>
<Input
id="pp-date"
type="date"
value={v.date}
max={todayIstanbul()}
onChange={(e) => set("date", e.target.value)}
aria-invalid={Boolean(errors.date)}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.price)}>
<FieldLabel htmlFor="pp-price">{f.price}</FieldLabel>
<Input
id="pp-price"
inputMode="decimal"
value={v.price}
onChange={(e) => set("price", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.price)}
autoFocus
/>
{errors.price ? <FieldError>{errors.price}</FieldError> : null}
</Field>
</div>
{preview ? (
<FieldDescription className="-mt-2 text-right tabular-nums">
{f.unitPreview(preview)}
</FieldDescription>
) : null}
<Field orientation="horizontal">
<Checkbox
id="pp-promo"
checked={v.isPromo}
onCheckedChange={(x) => set("isPromo", x === true)}
/>
<FieldLabel htmlFor="pp-promo">{f.isPromo}</FieldLabel>
</Field>
{v.isPromo ? (
<Field data-invalid={Boolean(errors.promoUntil)}>
<FieldLabel htmlFor="pp-until">{f.promoUntil}</FieldLabel>
<Input
id="pp-until"
type="date"
value={v.promoUntil}
min={v.date}
onChange={(e) => set("promoUntil", e.target.value)}
aria-invalid={Boolean(errors.promoUntil)}
/>
<FieldDescription>{f.promoUntilHint}</FieldDescription>
{errors.promoUntil ? <FieldError>{errors.promoUntil}</FieldError> : null}
</Field>
) : null}
<Field>
<FieldLabel htmlFor="pp-note">{f.note}</FieldLabel>
<Input id="pp-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,271 @@
"use client";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { toast } from "sonner";
import { PageHeader } from "@/components/page-header";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { AttachmentStrip } from "@/features/attachments/components/attachment-strip";
import { tr } from "@/i18n/tr";
import { formatMoney } from "@/shared/format";
import { formatUnitPrice } from "@/shared/prices";
import { deleteProductPriceAction } from "../actions";
import { priceProductOption } from "../options";
import type { PriceRow, ProductPriceDetail } from "../service";
import { OffersList, StoreDot, ymdLabel } from "./offers-list";
import { PriceFormDialog } from "./price-dialog";
const t = tr.productPrices;
const shortDate = (ymd: string) =>
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
new Date(`${ymd}T12:00:00+03:00`),
);
const compactTl = (minor: number) =>
new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 0 }).format(minor / 100) + " ₺";
/** Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli (basamaklı çizgi). */
function PriceChart({ data }: { data: ProductPriceDetail }) {
const config = Object.fromEntries(
data.series.map((s) => [`s${s.id}`, { label: s.label, color: `var(--series-${s.slot})` }]),
) satisfies ChartConfig;
return (
<ChartContainer
config={config}
className="aspect-auto h-64 w-full"
role="img"
aria-label={t.detail.chartLabel(data.product.label)}
>
<LineChart data={data.points} margin={{ left: 4, right: 12, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
tickFormatter={(v: string) => shortDate(v)}
/>
<YAxis
tickLine={false}
axisLine={false}
width={64}
domain={["auto", "auto"]}
tickFormatter={(v: number) => compactTl(v)}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) =>
payload?.[0] ? ymdLabel(String(payload[0].payload.date)) : ""
}
formatter={(value, name) => (
<div className="flex w-full items-center justify-between gap-3">
<span className="flex items-center">
<StoreDot slot={data.series.find((s) => `s${s.id}` === name)?.slot ?? 1} />
{config[String(name)]?.label}
</span>
<span className="font-medium tabular-nums">{formatMoney(Number(value))}</span>
</div>
)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{data.series.map((s) => (
<Line
key={s.id}
dataKey={`s${s.id}`}
type="stepAfter"
stroke={`var(--color-s${s.id})`}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
);
}
/** /stok/urunler/:id — ürün bilgisi, mağazalardaki fiyatlar, fiyat geçmişi ve kayıtlar. */
export function ProductPriceView({ data }: { data: ProductPriceDetail }) {
const [adding, setAdding] = useState(false);
const [deleting, setDeleting] = useState<PriceRow | null>(null);
const p = data.product;
const canAdd = data.canWrite && !p.archived && data.stores.length > 0;
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/stok/urunler">
<ArrowLeft />
{t.detail.back}
</Link>
</Button>
<PageHeader
title={p.label}
description={
<>
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
{t.detail.typeLink(p.productTypeName)}
</Link>
{" · "}
{p.packageLabel}
{p.barcode ? ` · ${tr.products.barcode(p.barcode)}` : ""}
</>
}
actions={
canAdd ? (
<Button size="sm" onClick={() => setAdding(true)}>
<Plus />
{t.add}
</Button>
) : null
}
/>
<div className="-mt-2 flex flex-wrap items-center gap-2">
{p.archived ? <Badge variant="secondary">{tr.products.archived}</Badge> : null}
{p.conversionMissing ? (
<Badge
variant="outline"
className="border-amber-500/50 text-amber-700 dark:text-amber-400"
>
{tr.products.conversionMissing}
</Badge>
) : null}
<AttachmentStrip items={p.images} />
</div>
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.detail.currentTitle}</CardTitle>
<CardDescription>{t.detail.currentDescription}</CardDescription>
</CardHeader>
<CardContent>
{data.offers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
) : (
<OffersList offers={data.offers} typeUnit={p.typeUnit} />
)}
</CardContent>
</Card>
{data.history.length > 1 ? (
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.detail.chartTitle}</CardTitle>
<CardDescription>{t.detail.chartDescription}</CardDescription>
</CardHeader>
<CardContent>
<PriceChart data={data} />
</CardContent>
</Card>
) : null}
{data.history.length ? (
<section className="flex flex-col gap-2" aria-labelledby="price-history-title">
<h2 id="price-history-title" className="text-base font-medium">
{t.detail.historyTitle}
</h2>
<ul className="divide-y rounded-xl border bg-card">
{data.history.map((r) => (
<li key={r.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="flex items-center text-sm font-medium">
<StoreDot slot={r.store.slot} />
{r.store.label}
</p>
<p className="text-xs text-muted-foreground">
{ymdLabel(r.date)}
{r.isPromo
? ` · ${r.promoEnded ? t.promoEnded : t.promo}${
r.promoUntil ? ` (${t.promoUntil(ymdLabel(r.promoUntil))})` : ""
}`
: ""}
{` · ${t.sources[r.source.split(":")[0]!] ?? r.source}`}
{r.note ? ` · ${r.note}` : ""}
</p>
</div>
<div className="text-right text-sm tabular-nums">
<p className="font-medium">{formatMoney(r.priceMinor)}</p>
{r.unitPriceMinor !== null ? (
<p className="text-xs text-muted-foreground">
{formatUnitPrice(r.unitPriceMinor, p.typeUnit)}
</p>
) : null}
</div>
{data.canWrite ? (
<Button
size="icon-sm"
variant="ghost"
aria-label={t.detail.deletePrice}
onClick={() => setDeleting(r)}
>
<Trash2 />
</Button>
) : null}
</li>
))}
</ul>
</section>
) : null}
{adding ? (
<PriceFormDialog
products={[priceProductOption(p)]}
productId={p.id}
stores={data.stores}
onClose={() => setAdding(false)}
/>
) : null}
<AlertDialog open={deleting !== null} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.detail.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.detail.deleteDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
const r = deleting;
setDeleting(null);
if (!r) return;
const res = await deleteProductPriceAction(r.id);
if (res.ok) toast.success(t.toast.deleted);
else toast.error(res.error);
}}
>
{t.detail.deletePrice}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { unitPriceBase } from "@/shared/prices";
import type { TypePriceComparison as Data } from "../service";
import { OffersList } from "./offers-list";
import { PriceFormDialog } from "./price-dialog";
const t = tr.productPrices;
/** /stok/:id: markalar ve mağazalar birim fiyata göre; en uygun vurgulu. */
export function TypePriceComparison({ data }: { data: Data }) {
const [adding, setAdding] = useState(false);
return (
<Card size="sm">
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{t.comparisonTitle}</CardTitle>
<CardDescription>
{t.comparisonDescription(unitPriceBase(data.typeUnit).unit)}
</CardDescription>
</div>
{data.canWrite && data.products.length && data.stores.length ? (
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus />
{t.add}
</Button>
) : null}
</CardHeader>
<CardContent>
{data.products.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.noProducts}</p>
) : data.offers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
) : (
<OffersList offers={data.offers} typeUnit={data.typeUnit} showProduct />
)}
</CardContent>
{adding ? (
<PriceFormDialog
products={data.products}
stores={data.stores}
onClose={() => setAdding(false)}
/>
) : null}
</Card>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { ProductView } from "@/features/products/service";
/** Fiyat formu ürün seçeneği (birim fiyat önizlemesi için dönüşümle). İstemcide de kullanılır. */
export type PriceProductOption = {
id: string;
label: string;
packageLabel: string;
inTypeUnit: number | null;
typeUnit: string;
};
export const priceProductOption = (p: ProductView): PriceProductOption => ({
id: p.id,
label: p.label,
packageLabel: p.packageLabel,
inTypeUnit: p.inTypeUnit,
typeUnit: p.typeUnit,
});
+150
View File
@@ -0,0 +1,150 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getStockAnalysis } from "@/features/stock/service";
import { AuditLogModel } from "@/models/audit-log";
import { ProductModel } from "@/models/product";
import { ProductTypeModel } from "@/models/product-type";
import { StoreModel } from "@/models/store";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { productPriceFormSchema } from "@/shared/schemas/product-price";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
addProductPrice,
deleteProductPrice,
getProductPrices,
getTypePriceComparison,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let zeynep = "";
let fatma = "";
const today = todayIstanbul();
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
const ref = (
model: typeof ProductModel | typeof StoreModel | typeof ProductTypeModel,
seedRef: string,
) =>
(model as typeof ProductModel)
.findOne({ seedRef })
.lean()
.then((x) => String(x!._id));
describe("birim fiyat karşılaştırması", () => {
it("toz deterjan: birim fiyata göre sıralı, en uygun işaretli, süren kampanya", async () => {
const c = (await getTypePriceComparison(
ayse,
cakir,
await ref(ProductTypeModel, "toz-deterjan"),
))!;
expect(c.typeUnit).toBe("yıkama");
const units = c.offers.map((o) => o.unitPriceMinor!);
expect(units).toEqual([...units].sort((a, b) => a - b));
expect(c.offers[0]!.cheapest).toBe(true);
expect(c.offers.filter((o) => o.cheapest)).toHaveLength(1);
const ariel = c.offers.find(
(o) => o.productLabel.startsWith("Ariel") && o.store.label.startsWith("Migros"),
)!;
expect(ariel.price).toMatchObject({ isPromo: true, promoUntil: addDays(today, 6) });
expect(ariel.promoEnded).toBe(false);
// Birim fiyat = paket / 33 yıkama.
expect(ariel.unitPriceMinor!).toBeCloseTo((ariel.price.priceMinor * 1000) / 33_000, 6);
expect(c.products).toHaveLength(3);
expect(
await getTypePriceComparison(fatma, cakir, await ref(ProductTypeModel, "toz-deterjan")),
).toBeNull();
});
it("süresi biten kampanyada normal fiyata dönülür; eski fiyat işaretlenir", async () => {
const sut = (await getProductPrices(ayse, cakir, await ref(ProductModel, "pinar-sut")))!;
const a101 = sut.offers.find((o) => o.store.label.startsWith("A101"))!;
expect(a101).toMatchObject({ fellBack: true, promoEnded: false });
expect(a101.price.isPromo).toBe(false);
expect(a101.price.date).toBe(addDays(today, -30));
expect(sut.history.some((h) => h.isPromo && h.promoEnded)).toBe(true);
expect(sut.series.length).toBeGreaterThanOrEqual(3);
expect(sut.points.length).toBeGreaterThan(5);
const reis = (await getProductPrices(ayse, cakir, await ref(ProductModel, "reis-mercimek")))!;
expect(reis.offers.find((o) => o.store.label.startsWith("Metro"))!.stale).toBe(true);
});
});
describe("fiyat girişi", () => {
it("ekle, gelecek tarih ve arşivli mağaza reddi, izleyici yazamaz, sil + denetim", async () => {
const productId = await ref(ProductModel, "omo-6kg");
const storeId = await ref(StoreModel, "sok");
const form = (over: Record<string, unknown> = {}) =>
productPriceFormSchema.parse({
productId,
storeId,
date: today,
price: "449,90",
isPromo: false,
promoUntil: "",
note: "",
...over,
});
expect(
productPriceFormSchema.safeParse({ ...form(), price: "0", isPromo: false }).success,
).toBe(false);
expect(
productPriceFormSchema.safeParse({
productId,
storeId,
date: today,
price: "10",
isPromo: true,
promoUntil: addDays(today, -1),
}).success,
).toBe(false);
expect((await addProductPrice(ayse, cakir, form({ date: addDays(today, 1) }))).ok).toBe(false);
expect((await addProductPrice(zeynep, cakir, form())).ok).toBe(false);
const r = await addProductPrice(ayse, cakir, form());
expect(r.ok).toBe(true);
let d = (await getProductPrices(ayse, cakir, productId))!;
const sok = d.offers.find((o) => o.store.id === storeId)!;
expect(sok.price).toMatchObject({ priceMinor: 449_90, date: today, source: "manual" });
// 6 kg = 40 yıkama → 11,2475 TL/yıkama.
expect(sok.unitPriceMinor).toBeCloseTo(1124.75, 6);
const priceId = r.ok ? r.data.id : "";
expect((await deleteProductPrice(fatma, cakir, priceId)).ok).toBe(false);
expect((await deleteProductPrice(ayse, cakir, priceId)).ok).toBe(true);
expect(await AuditLogModel.exists({ action: "price.delete", targetId: priceId })).toBeTruthy();
d = (await getProductPrices(ayse, cakir, productId))!;
expect(d.history.some((h) => h.id === priceId)).toBe(false);
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: new Date() } });
expect(await addProductPrice(ayse, cakir, form())).toMatchObject({
ok: false,
fieldErrors: { storeId: expect.any(String) },
});
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: null } });
});
});
describe("tüketim analizinde maliyet", () => {
it("aylık ortalama tüketim × son birim fiyat", async () => {
const a = (await getStockAnalysis(ayse, cakir))!;
const deterjan = a.rows.find((r) => r.name === "Toz Deterjan")!;
expect(deterjan.unitPrice).not.toBeNull();
// Maliyet yuvarlanmamış ortalamadan; gösterilen ortalama yuvarlıdır (≤ %1 fark).
const expected = (deterjan.avgMonthlyUsed! * deterjan.unitPrice!.minor) / 1000;
expect(Math.abs(deterjan.avgMonthlyCostMinor! - expected) / expected).toBeLessThan(0.01);
// Ürünü (marka/paket) olmayan tipte maliyet yok.
expect(a.rows.find((r) => r.name === "Domates")!.avgMonthlyCostMinor).toBeNull();
});
});
+436
View File
@@ -0,0 +1,436 @@
import "server-only";
import { Types } from "mongoose";
import { hasHouseholdRole } from "@/features/households/roles";
import { resolveMembership, type Membership } from "@/features/households/service";
import { listProducts, type ProductView } from "@/features/products/service";
import { storeLabel } from "@/features/stores/service";
import { priceProductOption, type PriceProductOption } from "./options";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { toObjectId } from "@/lib/object-id";
import { ProductModel } from "@/models/product";
import { ProductPriceModel } from "@/models/product-price";
import { ProductTypeModel } from "@/models/product-type";
import { StoreModel } from "@/models/store";
import { todayIstanbul } from "@/shared/dates";
import {
effectivePrice,
promoEnded,
rankOffers,
seriesSlot,
unitPrice,
type PriceSource,
type RankedOffer,
} from "@/shared/prices";
import { packageInTypeUnit } from "@/shared/products";
import type { ProductPriceForm } from "@/shared/schemas/product-price";
import { compareTr } from "@/shared/search";
/**
* Ürün fiyatları (Bölüm 6.11). Hane bazlı; okuma viewer, yazma member. Birim fiyat ürünün
* dönüşümüyle okuma sırasında hesaplanır (saf motor: shared/prices).
*/
const t = tr.productPrices;
type PriceLean = {
_id: Types.ObjectId;
productId: Types.ObjectId;
storeId: Types.ObjectId;
date: string;
priceMinor: number;
isPromo: boolean;
promoUntil: string | null;
source: PriceSource;
note: string;
createdAt: Date;
};
type StoreLean = {
_id: Types.ObjectId;
name: string;
branch: string;
archivedAt: Date | null;
createdAt: Date;
};
export type StoreRef = { id: string; label: string; slot: number; archived: boolean };
export type PriceRow = {
id: string;
productId: string;
store: StoreRef;
date: string;
priceMinor: number;
isPromo: boolean;
promoUntil: string | null;
promoEnded: boolean;
source: PriceSource;
note: string;
/** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş; dönüşüm yoksa null). */
unitPriceMinor: number | null;
};
export type StoreOffer = {
/** Teklif anahtarı: ürün + mağaza. */
key: string;
productId: string;
productLabel: string;
packageLabel: string;
store: StoreRef;
price: PriceRow;
/** Son kayıt süresi biten kampanyaydı; önceki normal fiyat gösteriliyor. */
fellBack: boolean;
promoEnded: boolean;
ageDays: number;
stale: boolean;
unitPriceMinor: number | null;
};
export type ProductPriceDetail = {
product: ProductView;
offers: RankedOffer<StoreOffer>[];
/** Tüm kayıtlar, yeniden eskiye. */
history: PriceRow[];
/** Grafik: mağaza serileri ve tarih noktaları (paket fiyatı, kuruş). */
series: StoreRef[];
points: Record<string, number | string>[];
stores: StoreRef[];
canWrite: boolean;
today: string;
};
export type TypePriceComparison = {
typeUnit: string;
offers: RankedOffer<StoreOffer>[];
/** Fiyat girişi seçenekleri. */
products: PriceProductOption[];
stores: StoreRef[];
canWrite: boolean;
};
/** Hanenin tüm mağazaları; renk yuvası ekleniş sırasına göre sabit. */
async function loadStores(householdId: Types.ObjectId) {
const rows = await StoreModel.find({ householdId })
.select({ name: 1, branch: 1, archivedAt: 1, createdAt: 1 })
.sort({ createdAt: 1, _id: 1 })
.lean<StoreLean[]>();
return new Map(
rows.map((s, i) => [
String(s._id),
{
id: String(s._id),
label: storeLabel(s),
slot: seriesSlot(i),
archived: Boolean(s.archivedAt),
} satisfies StoreRef,
]),
);
}
const sortRecords = (a: PriceLean, b: PriceLean) =>
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
async function loadPrices(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
const rows = await ProductPriceModel.find({
householdId,
productId: { $in: productIds },
deletedAt: null,
}).lean<PriceLean[]>();
return rows.sort(sortRecords);
}
function toRow(r: PriceLean, store: StoreRef, inTypeUnit: number | null, today: string): PriceRow {
return {
id: String(r._id),
productId: String(r.productId),
store,
date: r.date,
priceMinor: r.priceMinor,
isPromo: r.isPromo,
promoUntil: r.promoUntil,
promoEnded: promoEnded(r, today),
source: r.source,
note: r.note,
unitPriceMinor: inTypeUnit ? unitPrice(r.priceMinor, inTypeUnit) : null,
};
}
/** Ürün × mağaza başına geçerli fiyat teklifleri (sıralanmamış). */
function offersFor(
products: ProductView[],
prices: PriceLean[],
stores: Map<string, StoreRef>,
today: string,
): StoreOffer[] {
const groups = new Map<string, PriceLean[]>();
for (const r of prices) {
const key = `${r.productId}:${r.storeId}`;
groups.set(key, [...(groups.get(key) ?? []), r]);
}
const byId = new Map(products.map((p) => [p.id, p]));
const out: StoreOffer[] = [];
for (const [key, records] of groups) {
const p = byId.get(String(records[0]!.productId));
const store = stores.get(String(records[0]!.storeId));
if (!p || !store) continue;
const eff = effectivePrice(records, today)!;
const row = toRow(eff.record, store, p.inTypeUnit, today);
out.push({
key,
productId: p.id,
productLabel: p.label,
packageLabel: p.packageLabel,
store,
price: row,
fellBack: eff.fellBack,
promoEnded: eff.promoEnded,
ageDays: eff.ageDays,
stale: eff.stale,
unitPriceMinor: row.unitPriceMinor,
});
}
return out;
}
const activeStores = (stores: Map<string, StoreRef>) =>
[...stores.values()].filter((s) => !s.archived).sort((a, b) => compareTr(a.label, b.label));
/** /stok/urunler/:id — ürünün mağaza bazında fiyatları, geçmişi ve grafiği. */
export async function getProductPrices(
userId: string,
householdId: string,
productId: string,
): Promise<ProductPriceDetail | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const id = toObjectId(productId);
if (!id) return null;
const doc = await ProductModel.findOne({ _id: id, householdId: m.householdId })
.select({ productTypeId: 1 })
.lean<{ productTypeId: Types.ObjectId }>();
if (!doc) return null;
const list = await listProducts(userId, householdId, {
productTypeId: String(doc.productTypeId),
includeArchived: true,
});
const product = list?.products.find((p) => p.id === productId);
if (!product) return null;
const today = todayIstanbul();
const [stores, prices] = await Promise.all([
loadStores(m.householdId),
loadPrices(m.householdId, [id]),
]);
const history = prices
.map((r) => toRow(r, stores.get(String(r.storeId))!, product.inTypeUnit, today))
.filter((r) => r.store)
.reverse();
const seriesIds = [...new Set(prices.map((r) => String(r.storeId)))];
const series = seriesIds
.map((sid) => stores.get(sid)!)
.filter(Boolean)
.sort((a, b) => a.slot - b.slot);
// Aynı gün aynı mağazada birden çok kayıt: son giriş geçerli.
const byDate = new Map<string, Record<string, number | string>>();
for (const r of prices) {
const point = byDate.get(r.date) ?? { date: r.date };
point[`s${String(r.storeId)}`] = r.priceMinor;
byDate.set(r.date, point);
}
// Son nokta bugün: her mağazanın geçerli fiyatı (çizgi bugüne uzar; biten kampanyada normal fiyat).
const offers = offersFor([product], prices, stores, today);
if (offers.length) {
const point = byDate.get(today) ?? { date: today };
for (const o of offers) point[`s${o.store.id}`] ??= o.price.priceMinor;
byDate.set(today, point);
}
return {
product,
offers: rankOffers(offers),
history,
series,
points: [...byDate.values()].sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1)),
stores: activeStores(stores),
canWrite: hasHouseholdRole(m.role, "member"),
today,
};
}
/** Ürün tipi detayı: tüm marka/paket ve mağazalar birim fiyata göre. */
export async function getTypePriceComparison(
userId: string,
householdId: string,
productTypeId: string,
): Promise<TypePriceComparison | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const list = await listProducts(userId, householdId, { productTypeId });
if (!list) return null;
const type = await ProductTypeModel.findOne({
_id: new Types.ObjectId(productTypeId),
householdId: m.householdId,
})
.select({ unit: 1 })
.lean<{ unit: string }>();
if (!type) return null;
const today = todayIstanbul();
const [stores, prices] = await Promise.all([
loadStores(m.householdId),
loadPrices(
m.householdId,
list.products.map((p) => new Types.ObjectId(p.id)),
),
]);
return {
typeUnit: type.unit,
offers: rankOffers(
offersFor(list.products, prices, stores, today).filter((o) => !o.store.archived),
),
products: list.products.map(priceProductOption),
stores: activeStores(stores),
canWrite: list.canWrite,
};
}
/**
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
*/
export async function latestTypeUnitPrices(
householdId: Types.ObjectId,
productTypeIds: Types.ObjectId[],
today = todayIstanbul(),
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
const [products, types] = await Promise.all([
ProductModel.find({ householdId, productTypeId: { $in: productTypeIds } })
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
.lean<
{
_id: Types.ObjectId;
productTypeId: Types.ObjectId;
packageQuantity: number;
packageUnit: string;
typeQuantity: number | null;
}[]
>(),
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
.select({ unit: 1 })
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
]);
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
const prices = await loadPrices(
householdId,
products.map((p) => p._id),
);
const groups = new Map<string, PriceLean[]>();
for (const r of prices) {
const key = `${r.productId}:${r.storeId}`;
groups.set(key, [...(groups.get(key) ?? []), r]);
}
const productById = new Map(products.map((p) => [String(p._id), p]));
const out = new Map<string, { unitPriceMinor: number; date: string }>();
for (const records of groups.values()) {
const p = productById.get(String(records[0]!.productId))!;
const typeId = String(p.productTypeId);
const inType = packageInTypeUnit({
packageQuantity: p.packageQuantity,
packageUnit: p.packageUnit,
typeUnit: unitOf.get(typeId) ?? "",
typeQuantity: p.typeQuantity,
});
if (!inType) continue;
const eff = effectivePrice(records, today)!;
const prev = out.get(typeId);
if (!prev || eff.record.date > prev.date)
out.set(typeId, {
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
date: eff.record.date,
});
}
return out;
}
// --- Yazma ---
async function activeRefs(m: Membership, input: Pick<ProductPriceForm, "productId" | "storeId">) {
const [product, store] = await Promise.all([
ProductModel.exists({
_id: new Types.ObjectId(input.productId),
householdId: m.householdId,
archivedAt: null,
}),
StoreModel.exists({
_id: new Types.ObjectId(input.storeId),
householdId: m.householdId,
archivedAt: null,
}),
]);
if (!product) return fail(t.validation.product, { productId: t.validation.product });
if (!store) return fail(t.validation.store, { storeId: t.validation.store });
return null;
}
/** Fiyat kaydı ekler. Gelecek tarihli fiyat girilemez. */
export async function addProductPrice(
actorId: string,
householdId: string,
input: ProductPriceForm,
source: PriceSource = "manual",
): Promise<ActionResult<{ id: string }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
if (input.date > todayIstanbul())
return fail(t.validation.futureDate, { date: t.validation.futureDate });
const bad = await activeRefs(m, input);
if (bad) return bad;
const doc = await ProductPriceModel.create({
householdId: m.householdId,
productId: new Types.ObjectId(input.productId),
storeId: new Types.ObjectId(input.storeId),
date: input.date,
priceMinor: input.priceMinor,
isPromo: input.isPromo,
promoUntil: input.promoUntil,
source,
note: input.note,
createdBy: new Types.ObjectId(actorId),
});
return ok({ id: String(doc._id) });
}
export async function deleteProductPrice(
actorId: string,
householdId: string,
priceId: string,
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const id = toObjectId(priceId);
const row = id
? await ProductPriceModel.findOne({
_id: id,
householdId: m.householdId,
deletedAt: null,
}).lean<PriceLean>()
: null;
if (!row) return fail(tr.common.notFound);
const actor = new Types.ObjectId(actorId);
await ProductPriceModel.updateOne({ _id: row._id }, { $set: { deletedAt: new Date() } });
await writeAudit({
action: "price.delete",
actorId: actor,
householdId: m.householdId,
targetType: "productPrice",
targetId: priceId,
diff: {
productId: String(row.productId),
storeId: String(row.storeId),
date: row.date,
priceMinor: row.priceMinor,
},
});
return ok(undefined);
}
export type { PriceProductOption };
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { productFormSchema } from "@/shared/schemas/product";
import { createProduct, setProductArchived, updateProduct } from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T, R>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = schema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
/** Yeni (id boş) veya mevcut ürün. */
export async function saveProductAction(productId: unknown, input: unknown) {
if (productId) {
const id = idSchema.safeParse(productId);
if (!id.success) return fail(tr.common.notFound);
return run(productFormSchema, input, (u, h, d) => updateProduct(u, h, id.data, d));
}
return run(productFormSchema, input, createProduct);
}
export async function setProductArchivedAction(productId: unknown, archived: unknown) {
const id = idSchema.safeParse(productId);
if (!id.success) return fail(tr.common.notFound);
return run(z.boolean(), archived, (u, h, a) => setProductArchived(u, h, id.data, a));
}
@@ -0,0 +1,261 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
import type { AttachmentInfo } from "@/features/attachments/service";
import { tr } from "@/i18n/tr";
import { autoConvertible, packageInTypeUnit } from "@/shared/products";
import { formatQuantity, parseQuantity } from "@/shared/quantity";
import { quantityInput, STOCK_UNITS } from "@/shared/stock";
import { saveProductAction } from "../actions";
import type { ProductTypeOption, ProductView } from "../service";
const t = tr.products;
const CUSTOM = "__custom__";
type Errors = Record<string, string | undefined>;
/** Yeni ürün / düzenle: marka, tanım, barkod, paket, gerekirse dönüşüm, görseller. */
export function ProductFormDialog({
product,
types,
defaultTypeId,
onClose,
}: {
product: ProductView | null;
types: ProductTypeOption[];
defaultTypeId?: string;
onClose: () => void;
}) {
const initialType = product?.productTypeId ?? defaultTypeId ?? "";
const typeUnitOf = (id: string) => types.find((x) => x.id === id)?.unit ?? "";
const unitChoices = (typeUnit: string) => [
...new Set([typeUnit, ...STOCK_UNITS].filter(Boolean)),
];
const initialUnit = product?.packageUnit ?? typeUnitOf(initialType) ?? "adet";
const known = unitChoices(typeUnitOf(initialType)).includes(initialUnit);
const [v, setV] = useState({
productTypeId: initialType,
brand: product?.brand ?? "",
name: product?.name ?? "",
barcode: product?.barcode ?? "",
packageQuantity: product ? quantityInput(product.packageQuantity) : "1",
unitChoice: known ? initialUnit || "adet" : CUSTOM,
customUnit: known ? "" : initialUnit,
typeQuantity: product?.typeQuantity ? quantityInput(product.typeQuantity) : "",
note: product?.note ?? "",
});
const [images, setImages] = useState<AttachmentInfo[]>(product?.images ?? []);
const [uploading, setUploading] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
const typeUnit = typeUnitOf(v.productTypeId);
const packageUnit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
const needsConversion =
Boolean(typeUnit && packageUnit) && !autoConvertible(packageUnit, typeUnit);
const pkgQty = parseQuantity(v.packageQuantity);
const pkgText = pkgQty ? formatQuantity(pkgQty, packageUnit) : `1 ${packageUnit}`;
const autoValue =
pkgQty && typeUnit && !needsConversion
? packageInTypeUnit({ packageQuantity: pkgQty, packageUnit, typeUnit, typeQuantity: null })
: null;
const f = t.fields;
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await saveProductAction(product?.id ?? null, {
productTypeId: v.productTypeId,
brand: v.brand,
name: v.name,
barcode: v.barcode,
packageQuantity: v.packageQuantity,
packageUnit,
typeQuantity: needsConversion ? v.typeQuantity : "",
note: v.note,
imageIds: images.map((i) => i.id),
});
setBusy(false);
if (r.ok) {
toast.success(t.toast.saved);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={product ? t.editTitle : t.createTitle}
footer={
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="product-variant-form" disabled={busy || uploading}>
{tr.common.save}
</Button>
</>
}
>
<form id="product-variant-form" onSubmit={submit} noValidate>
<FieldGroup>
<Field data-invalid={Boolean(errors.productTypeId)}>
<FieldLabel htmlFor="pr-type">{f.productType}</FieldLabel>
<Select
value={v.productTypeId}
onValueChange={(x) => {
set("productTypeId", x);
if (!product && v.unitChoice !== CUSTOM) set("unitChoice", typeUnitOf(x) || "adet");
}}
>
<SelectTrigger
id="pr-type"
className="w-full"
aria-invalid={Boolean(errors.productTypeId)}
>
<SelectValue placeholder={t.validation.productType} />
</SelectTrigger>
<SelectContent className="max-h-72">
{types.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name} <span className="text-xs text-muted-foreground">({x.unit})</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.productTypeId ? <FieldError>{errors.productTypeId}</FieldError> : null}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.brand)}>
<FieldLabel htmlFor="pr-brand">{f.brand}</FieldLabel>
<Input
id="pr-brand"
value={v.brand}
placeholder={f.brandPlaceholder}
onChange={(e) => set("brand", e.target.value)}
aria-invalid={Boolean(errors.brand)}
autoFocus={!product}
/>
{errors.brand ? <FieldError>{errors.brand}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="pr-name">{f.name}</FieldLabel>
<Input
id="pr-name"
value={v.name}
placeholder={f.namePlaceholder}
onChange={(e) => set("name", e.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.packageQuantity)}>
<FieldLabel htmlFor="pr-qty">{f.packageQuantity}</FieldLabel>
<Input
id="pr-qty"
inputMode="decimal"
value={v.packageQuantity}
onChange={(e) => set("packageQuantity", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.packageQuantity)}
/>
{errors.packageQuantity ? <FieldError>{errors.packageQuantity}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.packageUnit)}>
<FieldLabel htmlFor="pr-unit">{f.packageUnit}</FieldLabel>
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
<SelectTrigger id="pr-unit" className="w-full">
<SelectValue>
{v.unitChoice === CUSTOM ? tr.stock.fields.customUnit : v.unitChoice}
</SelectValue>
</SelectTrigger>
<SelectContent>
{unitChoices(typeUnit).map((u) => (
<SelectItem key={u} value={u}>
{u}
</SelectItem>
))}
<SelectItem value={CUSTOM}>{tr.stock.fields.customUnitOption}</SelectItem>
</SelectContent>
</Select>
{v.unitChoice === CUSTOM ? (
<Input
value={v.customUnit}
placeholder={tr.stock.fields.customUnitPlaceholder}
aria-label={tr.stock.fields.customUnit}
onChange={(e) => set("customUnit", e.target.value)}
aria-invalid={Boolean(errors.packageUnit)}
/>
) : null}
{errors.packageUnit ? <FieldError>{errors.packageUnit}</FieldError> : null}
</Field>
</div>
{needsConversion ? (
<Field data-invalid={Boolean(errors.typeQuantity)}>
<FieldLabel htmlFor="pr-conv">{f.typeQuantity(typeUnit)}</FieldLabel>
<Input
id="pr-conv"
inputMode="decimal"
value={v.typeQuantity}
onChange={(e) => set("typeQuantity", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.typeQuantity)}
/>
<FieldDescription>{f.typeQuantityHint(pkgText, typeUnit)}</FieldDescription>
{errors.typeQuantity ? <FieldError>{errors.typeQuantity}</FieldError> : null}
</Field>
) : autoValue !== null && packageUnit !== typeUnit ? (
<FieldDescription className="-mt-2">
{f.autoConversion(formatQuantity(autoValue, typeUnit))}
</FieldDescription>
) : null}
<Field data-invalid={Boolean(errors.barcode)}>
<FieldLabel htmlFor="pr-barcode">{f.barcode}</FieldLabel>
<Input
id="pr-barcode"
inputMode="numeric"
value={v.barcode}
onChange={(e) => set("barcode", e.target.value)}
className="tabular-nums"
aria-invalid={Boolean(errors.barcode)}
/>
<FieldDescription>{f.barcodeHint}</FieldDescription>
{errors.barcode ? <FieldError>{errors.barcode}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.imageIds)}>
<FieldLabel>{f.images}</FieldLabel>
<AttachmentPicker
value={images}
onChange={setImages}
allowedKinds={["image"]}
onBusyChange={setUploading}
addLabel={f.addImage}
hint={f.imagesHint}
/>
{errors.imageIds ? <FieldError>{errors.imageIds}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="pr-note">{f.note}</FieldLabel>
<Input id="pr-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,188 @@
"use client";
import {
Archive,
ArchiveRestore,
AlertTriangle,
EllipsisVertical,
ImageIcon,
Pencil,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AttachmentThumb } from "@/features/attachments/components/attachment-strip";
import { AttachmentViewer } from "@/features/attachments/components/attachment-viewer";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { setProductArchivedAction } from "../actions";
import type { ProductTypeOption, ProductView } from "../service";
import { ProductFormDialog } from "./product-dialog";
const t = tr.products;
/** Ürün satırları: görsel, marka/tanım, paket, barkod; düzenle/arşivle. */
export function ProductList({
products,
types,
canWrite,
showType = false,
}: {
products: ProductView[];
types: ProductTypeOption[];
canWrite: boolean;
/** Katalogda ürün tipi bağlantısı. */
showType?: boolean;
}) {
const [editing, setEditing] = useState<ProductView | null>(null);
const [archiving, setArchiving] = useState<ProductView | null>(null);
const [viewer, setViewer] = useState<ProductView | null>(null);
async function setArchived(p: ProductView, archived: boolean) {
const r = await setProductArchivedAction(p.id, archived);
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
else toast.error(r.error);
}
return (
<>
<ul className="divide-y rounded-xl border bg-card">
{products.map((p) => {
const cover = p.images[0];
return (
<li
key={p.id}
className={cn("flex items-center gap-3 p-3", p.archived && "opacity-60")}
>
{cover ? (
<AttachmentThumb item={cover} onClick={() => setViewer(p)} className="size-14" />
) : (
<span
aria-hidden
className="flex size-14 shrink-0 items-center justify-center rounded-md border border-dashed bg-muted/40"
>
<ImageIcon className="size-5 text-muted-foreground/60" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
<Link href={`/stok/urunler/${p.id}`} className="hover:underline">
{p.label}
</Link>
</p>
<p className="truncate text-sm text-muted-foreground tabular-nums">
{p.packageLabel}
{showType ? (
<>
{" · "}
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
{p.productTypeName}
</Link>
</>
) : null}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{p.barcode ? <span className="tabular-nums">{t.barcode(p.barcode)}</span> : null}
{p.images.length > 1 ? <span>{t.images(p.images.length)}</span> : null}
{p.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
{p.conversionMissing ? (
<Badge
variant="outline"
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
title={t.conversionMissingHint}
>
<AlertTriangle className="size-3" aria-hidden />
{t.conversionMissing}
</Badge>
) : null}
</div>
</div>
{canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
aria-label={`${p.label}: ${t.actions.more}`}
>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={p.archived} onSelect={() => setEditing(p)}>
<Pencil />
{t.actions.edit}
</DropdownMenuItem>
<DropdownMenuSeparator />
{p.archived ? (
<DropdownMenuItem onSelect={() => setArchived(p, false)}>
<ArchiveRestore />
{t.actions.unarchive}
</DropdownMenuItem>
) : (
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(p)}>
<Archive />
{t.actions.archive}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</li>
);
})}
</ul>
{editing ? (
<ProductFormDialog product={editing} types={types} onClose={() => setEditing(null)} />
) : null}
<AttachmentViewer
items={viewer?.images ?? []}
startIndex={0}
open={viewer !== null}
onOpenChange={(o) => !o && setViewer(null)}
/>
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
<AlertDialogDescription>
{archiving ? t.archiveDescription(archiving.label) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
const p = archiving;
setArchiving(null);
if (p) await setArchived(p, true);
}}
>
{t.actions.archive}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,110 @@
"use client";
import { ArrowLeft, Plus, Search } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { PageHeader } from "@/components/page-header";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { tr } from "@/i18n/tr";
import { foldTr } from "@/shared/search";
import type { ProductTypeOption, ProductView } from "../service";
import { ProductFormDialog } from "./product-dialog";
import { ProductList } from "./product-list";
const t = tr.products;
/** /stok/urunler: tüm ürünler ürün tipine göre gruplu; marka, tanım veya barkodla arama. */
export function ProductsCatalog({
products,
types,
canWrite,
}: {
products: ProductView[];
types: ProductTypeOption[];
canWrite: boolean;
}) {
const [query, setQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [creating, setCreating] = useState(false);
const groups = useMemo(() => {
const q = foldTr(query.trim());
const digits = query.replace(/\s/g, "");
const match = (p: ProductView) =>
(showArchived || !p.archived) &&
(!q ||
foldTr(`${p.label} ${p.productTypeName}`).includes(q) ||
(/^\d+$/.test(digits) && (p.barcode ?? "").includes(digits)));
const by = new Map<string, { name: string; items: ProductView[] }>();
for (const p of products.filter(match)) {
const g = by.get(p.productTypeId) ?? { name: p.productTypeName, items: [] };
g.items.push(p);
by.set(p.productTypeId, g);
}
return [...by.entries()];
}, [products, query, showArchived]);
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}
actions={
canWrite && types.length ? (
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
{t.new}
</Button>
) : null
}
/>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-48 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t.search}
aria-label={t.search}
className="pl-8"
/>
</div>
{products.some((p) => p.archived) ? (
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
{t.showArchived}
</label>
) : null}
</div>
{products.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
) : groups.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.noMatch}</p>
) : (
groups.map(([typeId, g]) => (
<section key={typeId} className="flex flex-col gap-2">
<h2 className="text-sm font-medium text-muted-foreground">
<Link href={`/stok/${typeId}`} className="hover:underline">
{g.name}
</Link>
</h2>
<ProductList products={g.items} types={types} canWrite={canWrite} />
</section>
))
)}
{creating ? (
<ProductFormDialog product={null} types={types} onClose={() => setCreating(false)} />
) : null}
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
import type { ProductTypeOption, ProductView } from "../service";
import { ProductFormDialog } from "./product-dialog";
import { ProductList } from "./product-list";
const t = tr.products;
/** /stok/:id içinde: bu ürün tipinin markaları ve paketleri. */
export function ProductsSection({
productTypeId,
products,
types,
canWrite,
}: {
productTypeId: string;
products: ProductView[];
types: ProductTypeOption[];
canWrite: boolean;
}) {
const [creating, setCreating] = useState(false);
return (
<section className="flex flex-col gap-2" aria-labelledby="products-title">
<div className="flex items-end justify-between gap-3">
<div>
<h2 id="products-title" className="text-base font-medium">
{t.sectionTitle}
</h2>
<p className="text-xs text-muted-foreground">{t.sectionDescription}</p>
</div>
{canWrite && types.some((x) => x.id === productTypeId) ? (
<Button size="sm" variant="outline" onClick={() => setCreating(true)}>
<Plus />
{t.new}
</Button>
) : null}
</div>
{products.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.emptyType}
</p>
) : (
<ProductList products={products} types={types} canWrite={canWrite} />
)}
{creating ? (
<ProductFormDialog
product={null}
types={types}
defaultTypeId={productTypeId}
onClose={() => setCreating(false)}
/>
) : null}
</section>
);
}
+179
View File
@@ -0,0 +1,179 @@
import { rm } from "node:fs/promises";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readAttachmentForUser, uploadAttachment } from "@/features/attachments/service";
import { env } from "@/lib/env";
import { AttachmentModel } from "@/models/attachment";
import { ProductTypeModel } from "@/models/product-type";
import { productFormSchema, type ProductFormInput } from "@/shared/schemas/product";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
createProduct,
listProducts,
listProductTypeOptions,
setProductArchived,
updateProduct,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
beforeAll(async () => {
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
emre = ids.user("emre");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(async () => {
await teardownDb();
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
});
const typeId = (ref: string) =>
ProductTypeModel.findOne({ seedRef: ref })
.lean()
.then((x) => String(x!._id));
const png = () =>
sharp({ create: { width: 40, height: 30, channels: 3, background: "#cc6633" } })
.png()
.toBuffer();
async function form(over: Partial<ProductFormInput> = {}) {
return productFormSchema.parse({
productTypeId: await typeId("toz-deterjan"),
brand: "Persil",
name: "Matik",
barcode: "",
packageQuantity: "4,5",
packageUnit: "kg",
typeQuantity: "30",
note: "",
imageIds: [],
...over,
});
}
describe("ürünler (marka/paket)", () => {
it("seed: dönüşümler ve paket açıklaması; izolasyon", async () => {
const all = (await listProducts(ayse, cakir))!;
expect(all.products).toHaveLength(22);
const ariel = all.products.find((p) => p.brand === "Ariel")!;
expect(ariel).toMatchObject({
productTypeName: "Toz Deterjan",
typeUnit: "yıkama",
inTypeUnit: 33_000,
packageLabel: "5 kg · 33 yıkama",
conversionMissing: false,
});
const icim = all.products.find((p) => p.brand === "İçim")!;
expect(icim).toMatchObject({ typeQuantity: null, inTypeUnit: 500 });
const peynir = (await listProducts(ayse, cakir, {
productTypeId: await typeId("beyaz-peynir"),
}))!;
expect(peynir.products.map((p) => p.packageLabel)).toEqual(["500 g · 0,5 kg"]);
expect(await listProducts(fatma, cakir)).toBeNull();
expect((await listProductTypeOptions(ayse, cakir))!.length).toBe(19);
});
it("dönüşüm zorunlu; kendiliğinden çevrilebilende yok sayılır; barkod tek", async () => {
const missing = await createProduct(ayse, cakir, await form({ typeQuantity: "" }));
expect(missing).toMatchObject({ ok: false, fieldErrors: { typeQuantity: expect.any(String) } });
const ok = await createProduct(ayse, cakir, await form({ barcode: "8690 0000 0099 9" }));
expect(ok.ok).toBe(true);
const sut = await createProduct(
ayse,
cakir,
await form({
productTypeId: await typeId("sut"),
brand: "Torku",
packageQuantity: "1000",
packageUnit: "ml",
typeQuantity: "7",
}),
);
expect(sut.ok).toBe(true);
const list = (await listProducts(ayse, cakir))!.products;
expect(list.find((p) => p.brand === "Persil")).toMatchObject({
barcode: "8690000000999",
inTypeUnit: 30_000,
});
expect(list.find((p) => p.brand === "Torku")).toMatchObject({
typeQuantity: null,
inTypeUnit: 1_000,
});
const dup = await createProduct(
ayse,
cakir,
await form({ brand: "Başka", barcode: "8690000000999" }),
);
expect(dup).toMatchObject({ ok: false, fieldErrors: { barcode: expect.any(String) } });
expect(productFormSchema.safeParse({ ...(await form()), barcode: "12a" }).success).toBe(false);
expect((await createProduct(zeynep, cakir, await form({ brand: "İzleyici" }))).ok).toBe(false);
});
it("görseller: bağlanır, sıralanır, çıkarılır; okuma hane üyeleriyle sınırlı", async () => {
const a1 = await uploadAttachment(ayse, cakir, { name: "on.png", bytes: await png() });
const a2 = await uploadAttachment(ayse, cakir, { name: "arka.png", bytes: await png() });
const id1 = a1.ok ? a1.data.id : "";
const id2 = a2.ok ? a2.data.id : "";
const r = await createProduct(
ayse,
cakir,
await form({ brand: "Peros", imageIds: [id2, id1] }),
);
expect(r.ok).toBe(true);
const pid = r.ok ? r.data.id : "";
let p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
expect(p.images.map((i) => i.id)).toEqual([id2, id1]);
// Aynı hanenin başka üyesi görür; başka hane görmez.
expect(await readAttachmentForUser(emre, id1, "thumb")).not.toBeNull();
expect(await readAttachmentForUser(fatma, id1, "full")).toBeNull();
// Başkasının yüklediği bekleyen görsel bağlanamaz.
const foreign = await uploadAttachment(emre, cakir, { name: "x.png", bytes: await png() });
const bad = await updateProduct(
ayse,
cakir,
pid,
await form({ brand: "Peros", imageIds: [id1, foreign.ok ? foreign.data.id : ""] }),
);
expect(bad.ok).toBe(false);
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
expect(p.images).toHaveLength(2);
expect(
(await updateProduct(ayse, cakir, pid, await form({ brand: "Peros", imageIds: [id1] }))).ok,
).toBe(true);
expect(await AttachmentModel.exists({ _id: id2, deletedAt: { $ne: null } })).toBeTruthy();
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
expect(p.images.map((i) => i.id)).toEqual([id1]);
});
it("arşiv: listeden çıkar, düzenlenemez, barkod çakışırsa geri alınamaz", async () => {
const r = await createProduct(ayse, cakir, await form({ brand: "Arşiv", barcode: "12345678" }));
const id = r.ok ? r.data.id : "";
expect((await setProductArchived(ayse, cakir, id, true)).ok).toBe(true);
expect((await listProducts(ayse, cakir))!.products.some((p) => p.id === id)).toBe(false);
expect(
(await listProducts(ayse, cakir, { includeArchived: true }))!.products.find(
(p) => p.id === id,
)!.archived,
).toBe(true);
expect((await updateProduct(ayse, cakir, id, await form({ brand: "Arşiv" }))).ok).toBe(false);
expect(
(await createProduct(ayse, cakir, await form({ brand: "Yeni", barcode: "12345678" }))).ok,
).toBe(true);
expect((await setProductArchived(ayse, cakir, id, false)).ok).toBe(false);
});
});
+358
View File
@@ -0,0 +1,358 @@
import "server-only";
import { Types } from "mongoose";
import {
syncOwnerAttachments,
toAttachmentInfo,
type AttachmentInfo,
} from "@/features/attachments/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { resolveMembership, type Membership } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { connectMongoose } from "@/lib/db/mongoose";
import { toObjectId } from "@/lib/object-id";
import { AttachmentModel } from "@/models/attachment";
import { ProductModel } from "@/models/product";
import { ProductTypeModel } from "@/models/product-type";
import { autoConvertible, describePackage, packageInTypeUnit } from "@/shared/products";
import type { ProductForm } from "@/shared/schemas/product";
import { compareTr, foldTr } from "@/shared/search";
/**
* Ürünler (marka/varyant, Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma:
* member. Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa dönüşüm zorunludur.
* Görseller ek dosya altyapısıyla (ownerType "product", yalnızca görsel).
*/
const t = tr.products;
type ProductLean = {
_id: Types.ObjectId;
productTypeId: Types.ObjectId;
brand: string;
name: string;
barcode: string | null;
packageQuantity: number;
packageUnit: string;
typeQuantity: number | null;
note: string;
imageCount: number;
archivedAt: Date | null;
};
type TypeLean = { _id: Types.ObjectId; name: string; unit: string; archivedAt: Date | null };
export type ProductView = {
id: string;
productTypeId: string;
productTypeName: string;
typeUnit: string;
brand: string;
name: string;
/** "Ariel Matik Dağ Esintisi". */
label: string;
barcode: string | null;
packageQuantity: number;
packageUnit: string;
/** Elle girilen dönüşüm (kendiliğinden çevrilebiliyorsa null). */
typeQuantity: number | null;
/** Bir paketin ürün tipi birimindeki karşılığı (çevrilemiyorsa null). */
inTypeUnit: number | null;
/** Ürün tipinin birimi sonradan değişti ve dönüşüm yok: fiyat karşılaştırmasına katılamaz. */
conversionMissing: boolean;
/** "5 kg · 50 yıkama". */
packageLabel: string;
note: string;
archived: boolean;
images: AttachmentInfo[];
};
export type ProductTypeOption = { id: string; name: string; unit: string };
export const productLabel = (p: { brand: string; name: string }) =>
p.name ? `${p.brand} ${p.name}` : p.brand;
function toView(p: ProductLean, type: TypeLean | undefined, images: AttachmentInfo[]): ProductView {
const typeUnit = type?.unit ?? "";
const conv = {
packageQuantity: p.packageQuantity,
packageUnit: p.packageUnit,
typeUnit,
typeQuantity: p.typeQuantity,
};
const inTypeUnit = packageInTypeUnit(conv);
return {
id: String(p._id),
productTypeId: String(p.productTypeId),
productTypeName: type?.name ?? "",
typeUnit,
brand: p.brand,
name: p.name,
label: productLabel(p),
barcode: p.barcode,
packageQuantity: p.packageQuantity,
packageUnit: p.packageUnit,
typeQuantity: p.typeQuantity,
inTypeUnit,
conversionMissing: inTypeUnit === null,
packageLabel: describePackage(conv),
note: p.note,
archived: Boolean(p.archivedAt),
images,
};
}
async function loadImages(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
const rows = await AttachmentModel.find({
householdId,
ownerType: "product",
ownerId: { $in: productIds },
deletedAt: null,
})
.sort({ order: 1, createdAt: 1 })
.lean();
const by = new Map<string, AttachmentInfo[]>();
for (const a of rows) {
const key = String(a.ownerId);
by.set(key, [
...(by.get(key) ?? []),
toAttachmentInfo(a as Parameters<typeof toAttachmentInfo>[0]),
]);
}
return by;
}
/**
* Ürün listesi: ürün tipine göre (verilirse) veya tüm katalog. Sıra: ürün tipi adı, marka,
* tanım. Arşivliler `includeArchived` ile.
*/
export async function listProducts(
userId: string,
householdId: string,
opts: { productTypeId?: string; includeArchived?: boolean } = {},
): Promise<{ products: ProductView[]; canWrite: boolean } | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const typeId = opts.productTypeId ? toObjectId(opts.productTypeId) : null;
if (opts.productTypeId && !typeId) return null;
const rows = await ProductModel.find({
householdId: m.householdId,
...(typeId ? { productTypeId: typeId } : {}),
...(opts.includeArchived ? {} : { archivedAt: null }),
}).lean<ProductLean[]>();
const types = await ProductTypeModel.find({
householdId: m.householdId,
_id: {
$in: [...new Set(rows.map((r) => String(r.productTypeId)))].map(
(id) => new Types.ObjectId(id),
),
},
})
.select({ name: 1, unit: 1, archivedAt: 1 })
.lean<TypeLean[]>();
const typeById = new Map(types.map((x) => [String(x._id), x]));
const images = await loadImages(
m.householdId,
rows.filter((r) => r.imageCount > 0).map((r) => r._id),
);
const products = rows
.map((r) => toView(r, typeById.get(String(r.productTypeId)), images.get(String(r._id)) ?? []))
.sort(
(a, b) =>
compareTr(a.productTypeName, b.productTypeName) ||
compareTr(a.brand, b.brand) ||
compareTr(a.name, b.name),
);
return { products, canWrite: hasHouseholdRole(m.role, "member") };
}
/** Ürün formu için aktif ürün tipleri (ada göre). */
export async function listProductTypeOptions(
userId: string,
householdId: string,
): Promise<ProductTypeOption[] | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const rows = await ProductTypeModel.find({ householdId: m.householdId, archivedAt: null })
.select({ name: 1, unit: 1 })
.lean<TypeLean[]>();
return rows
.map((r) => ({ id: String(r._id), name: r.name, unit: r.unit }))
.sort((a, b) => compareTr(a.name, b.name));
}
/** Ürün tipi, dönüşüm ve barkod denetimi; geçerliyse kaydedilecek dönüşümü döner. */
async function checkInput(
m: Membership,
input: ProductForm,
exceptId?: Types.ObjectId,
): Promise<{ error: ActionResult<never> } | { error: null; typeQuantity: number | null }> {
const type = await ProductTypeModel.findOne({
_id: new Types.ObjectId(input.productTypeId),
householdId: m.householdId,
}).lean<TypeLean>();
if (!type || type.archivedAt)
return { error: fail(t.validation.productType, { productTypeId: t.validation.productType }) };
const auto = autoConvertible(input.packageUnit, type.unit);
if (!auto && !input.typeQuantity) {
const msg = t.validation.conversionRequired(input.packageUnit, type.unit);
return { error: fail(msg, { typeQuantity: msg }) };
}
if (input.barcode) {
const dup = await ProductModel.exists({
householdId: m.householdId,
barcode: input.barcode,
archivedAt: null,
...(exceptId ? { _id: { $ne: exceptId } } : {}),
});
if (dup)
return { error: fail(t.validation.barcodeTaken, { barcode: t.validation.barcodeTaken }) };
}
return { error: null, typeQuantity: auto ? null : input.typeQuantity };
}
/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
class ImageError extends Error {
constructor(readonly result: ActionResult<never>) {
super(result.ok ? "" : result.error);
}
}
async function save(
m: Membership,
actorId: string,
input: ProductForm,
typeQuantity: number | null,
existing: Types.ObjectId | null,
): Promise<ActionResult<{ id: string }>> {
const actor = new Types.ObjectId(actorId);
const fields = {
productTypeId: new Types.ObjectId(input.productTypeId),
brand: input.brand,
name: input.name,
searchKey: foldTr(`${input.brand} ${input.name}`),
barcode: input.barcode,
packageQuantity: input.packageQuantity,
packageUnit: input.packageUnit,
typeQuantity,
note: input.note,
updatedBy: actor,
};
const mongoose = await connectMongoose();
let id = existing;
try {
await mongoose.connection.transaction(async (session) => {
if (existing) {
await ProductModel.updateOne(
{ _id: existing, householdId: m.householdId },
{ $set: fields },
{ session },
);
} else {
const [doc] = await ProductModel.create(
[{ householdId: m.householdId, ...fields, createdBy: actor }],
{ session },
);
id = doc!._id;
}
const synced = await syncOwnerAttachments({
householdId: m.householdId,
actorId,
ownerType: "product",
ownerId: id!,
ids: input.imageIds,
allowedKinds: ["image"],
session,
});
if (!synced.ok) throw new ImageError(synced);
await ProductModel.updateOne(
{ _id: id!, householdId: m.householdId },
{ $set: { imageCount: synced.data } },
{ session },
);
});
} catch (error) {
if (error instanceof ImageError) return error.result;
throw error;
}
return ok({ id: String(id) });
}
export async function createProduct(
actorId: string,
householdId: string,
input: ProductForm,
): Promise<ActionResult<{ id: string }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const c = await checkInput(m, input);
if (c.error) return c.error;
return save(m, actorId, input, c.typeQuantity, null);
}
export async function updateProduct(
actorId: string,
householdId: string,
productId: string,
input: ProductForm,
): Promise<ActionResult<{ id: string }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const id = toObjectId(productId);
const current = id
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
: null;
if (!current) return fail(tr.common.notFound);
if (current.archivedAt) return fail(t.validation.archived);
const c = await checkInput(m, input, current._id);
if (c.error) return c.error;
return save(m, actorId, input, c.typeQuantity, current._id);
}
export async function setProductArchived(
actorId: string,
householdId: string,
productId: string,
archived: boolean,
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const id = toObjectId(productId);
const current = id
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
: null;
if (!current) return fail(tr.common.notFound);
if (!archived) {
const type = await ProductTypeModel.exists({
_id: current.productTypeId,
householdId: m.householdId,
archivedAt: null,
});
if (!type) return fail(t.validation.productType);
if (
current.barcode &&
(await ProductModel.exists({
householdId: m.householdId,
barcode: current.barcode,
archivedAt: null,
_id: { $ne: current._id },
}))
)
return fail(t.validation.barcodeTaken);
}
const actor = new Types.ObjectId(actorId);
await ProductModel.updateOne(
{ _id: current._id, householdId: m.householdId },
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
);
await writeAudit({
action: archived ? "product.archive" : "product.unarchive",
actorId: actor,
householdId: m.householdId,
targetType: "product",
targetId: productId,
diff: { label: productLabel(current) },
});
return ok(undefined);
}
+7
View File
@@ -9,6 +9,7 @@ import {
productTypeFormSchema, productTypeFormSchema,
stockCategoryFormSchema, stockCategoryFormSchema,
stockCountFormSchema, stockCountFormSchema,
stockCountSheetSchema,
stockMovementFormSchema, stockMovementFormSchema,
} from "@/shared/schemas/stock"; } from "@/shared/schemas/stock";
import { import {
@@ -21,6 +22,7 @@ import {
quickConsume, quickConsume,
setProductTypeArchived, setProductTypeArchived,
setStockCategoryArchived, setStockCategoryArchived,
submitCount,
updateProductType, updateProductType,
updateStockCategory, updateStockCategory,
type StockOption, 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) { export async function deleteStockMovementAction(movementId: unknown) {
return withId(movementId, (id) => return withId(movementId, (id) =>
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, 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 Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -9,7 +9,7 @@ import { getStock } from "../service";
const t = tr.stock; const t = tr.stock;
const LIMIT = 5; 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({ export async function LowStockCard({
userId, userId,
householdId, householdId,
@@ -18,7 +18,8 @@ export async function LowStockCard({
householdId: string; householdId: string;
}) { }) {
const data = await getStock(userId, householdId); 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 low = data.suggestions.slice(0, LIMIT);
const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2); const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
return ( return (
@@ -26,7 +27,11 @@ export async function LowStockCard({
<CardHeader> <CardHeader>
<CardTitle className="text-base">{t.dashboard.title}</CardTitle> <CardTitle className="text-base">{t.dashboard.title}</CardTitle>
<CardDescription> <CardDescription>
{t.dashboard.description(data.suggestions.length, data.expiring.length)} {t.dashboard.description(
data.suggestions.length,
data.expiring.length,
data.countDue.length,
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
@@ -69,6 +74,23 @@ export async function LowStockCard({
</span> </span>
</li> </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> </ul>
{data.suggestions.length > low.length ? ( {data.suggestions.length > low.length ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -37,7 +37,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatStock } from "@/shared/stock"; import { formatStock, roundForDisplay } from "@/shared/stock";
import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions"; import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service"; import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
import { import {
@@ -46,7 +46,14 @@ import {
StockMovementDialog, StockMovementDialog,
type MovementKindInput, type MovementKindInput,
} from "./stock-dialogs"; } 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; const t = tr.stock;
@@ -71,9 +78,12 @@ function Stat({ label, children }: { label: string; children: React.ReactNode })
export function ProductDetailView({ export function ProductDetailView({
data, data,
categories, categories,
products,
}: { }: {
data: ProductTypeDetail; data: ProductTypeDetail;
categories: StockOption[]; categories: StockOption[];
/** Marka/paket bölümü (Faz 12). */
products?: React.ReactNode;
}) { }) {
const [pending, setPending] = useState<Pending>(null); const [pending, setPending] = useState<Pending>(null);
const close = () => setPending(null); const close = () => setPending(null);
@@ -156,6 +166,53 @@ export function ProductDetailView({
<Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat> <Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat>
</div> </div>
<StockBar item={data} /> <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 ? ( {data.location ? (
<p className="text-sm"> <p className="text-sm">
<span className="text-muted-foreground">{t.detail.location}:</span> {data.location} <span className="text-muted-foreground">{t.detail.location}:</span> {data.location}
@@ -227,6 +284,8 @@ export function ProductDetailView({
</CardContent> </CardContent>
</Card> </Card>
{products}
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title"> <section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
<h2 id="stock-movements-title" className="text-base font-medium"> <h2 id="stock-movements-title" className="text-base font-medium">
{t.detail.movements} {t.detail.movements}
@@ -0,0 +1,209 @@
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 { formatDate, formatMoney } from "@/shared/format";
import { formatUnitPrice } from "@/shared/prices";
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 totalCost = data.rows.reduce((sum, r) => sum + (r.avgMonthlyCostMinor ?? 0), 0);
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>
) : (
<>
{totalCost > 0 ? <p className="text-sm">{t.totalCost(formatMoney(totalCost))}</p> : null}
<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(5,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.avgCost}</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(5,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.avgCost}
</span>
<span
className={cn(
"tabular-nums",
r.avgMonthlyCostMinor === null && "text-muted-foreground",
)}
title={
r.unitPrice
? t.costHint(
formatUnitPrice(r.unitPrice.minor, r.unit),
formatDate(`${r.unitPrice.date}T12:00:00+03:00`),
)
: t.noPrice
}
>
{r.avgMonthlyCostMinor === null
? t.noData
: formatMoney(r.avgMonthlyCostMinor)}
</span>
</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>
);
}
+106 -17
View File
@@ -1,6 +1,16 @@
"use client"; "use client";
import { AlertTriangle, ChevronRight, Clock, FolderTree, Plus, Search } from "lucide-react"; import {
AlertTriangle,
ChartColumn,
ChevronRight,
ClipboardCheck,
Clock,
FolderTree,
Plus,
Search,
Tags,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
@@ -111,6 +121,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 }) { function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
return ( return (
<div className="min-w-0"> <div className="min-w-0">
@@ -144,6 +165,24 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href="/stok/urunler">
<Tags />
{tr.products.catalogLink}
</Link>
</Button>
<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> <Button variant="ghost" size="sm" asChild>
<Link href="/stok/kategoriler"> <Link href="/stok/kategoriler">
<FolderTree /> <FolderTree />
@@ -182,26 +221,76 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p> <p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
) : ( ) : (
<ul className="divide-y text-sm"> <ul className="divide-y text-sm">
{data.suggestions.map((i) => ( {data.suggestions.map((i) => {
<li key={i.id} className="flex items-center justify-between gap-3 py-2"> const reasons = suggestionReasons(i);
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline"> return (
<span className="block truncate font-medium">{i.name}</span> <li key={i.id} className="flex flex-col gap-0.5 py-2">
<span className="block text-xs text-muted-foreground tabular-nums"> <div className="flex items-center justify-between gap-3">
{t.list.of( <Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
formatStock(Math.max(0, i.current), i.unit), <span className="block truncate font-medium">{i.name}</span>
formatStock(i.targetQuantity, i.unit), <span className="block text-xs text-muted-foreground tabular-nums">
)} {t.list.of(
</span> formatStock(Math.max(0, i.current), i.unit),
</Link> formatStock(i.targetQuantity, i.unit),
<span className="shrink-0 text-right font-medium tabular-nums"> )}
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))} </span>
</span> </Link>
</li> <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> </ul>
)} )}
</CardContent> </CardContent>
</Card> </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 ? ( {data.expiring.length ? (
<Card size="sm"> <Card size="sm">
<CardHeader> <CardHeader>
+139
View File
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { AuditLogModel } from "@/models/audit-log"; import { AuditLogModel } from "@/models/audit-log";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement"; import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates"; import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence"; import { addDays } from "@/shared/recurrence";
@@ -8,6 +9,7 @@ import {
productTypeFormSchema, productTypeFormSchema,
stockCategoryFormSchema, stockCategoryFormSchema,
stockCountFormSchema, stockCountFormSchema,
stockCountSheetSchema,
stockMovementFormSchema, stockMovementFormSchema,
type ProductTypeFormInput, type ProductTypeFormInput,
} from "@/shared/schemas/stock"; } from "@/shared/schemas/stock";
@@ -18,13 +20,16 @@ import {
createProductType, createProductType,
createStockCategory, createStockCategory,
deleteStockMovement, deleteStockMovement,
getCountSheet,
getProductType, getProductType,
getStock, getStock,
getStockAnalysis,
getStockOptions, getStockOptions,
listStockCategories, listStockCategories,
quickConsume, quickConsume,
setProductTypeArchived, setProductTypeArchived,
setStockCategoryArchived, setStockCategoryArchived,
submitCount,
updateStockCategory, updateStockCategory,
} from "./service"; } from "./service";
@@ -305,3 +310,137 @@ describe("stok kategorileri", () => {
).toBe(false); ).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();
});
});
+411 -41
View File
@@ -12,6 +12,7 @@ import {
} from "@/features/categories/tree"; } from "@/features/categories/tree";
import { hasHouseholdRole } from "@/features/households/roles"; import { hasHouseholdRole } from "@/features/households/roles";
import { resolveMembership, type Membership } from "@/features/households/service"; import { resolveMembership, type Membership } from "@/features/households/service";
import { latestTypeUnitPrices } from "@/features/prices/service";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result"; import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit"; import { writeAudit } from "@/lib/audit";
@@ -20,26 +21,36 @@ import { toObjectId } from "@/lib/object-id";
import { CategoryModel } from "@/models/category"; import { CategoryModel } from "@/models/category";
import { ProductTypeModel } from "@/models/product-type"; import { ProductTypeModel } from "@/models/product-type";
import { StockCategoryModel } from "@/models/stock-category"; import { StockCategoryModel } from "@/models/stock-category";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement"; import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates"; import { todayIstanbul } from "@/shared/dates";
import { shiftMonth } from "@/shared/budget";
import { QUANTITY_SCALE } from "@/shared/quantity";
import type { import type {
ProductTypeForm, ProductTypeForm,
StockCategoryForm, StockCategoryForm,
StockCountForm, StockCountForm,
StockCountSheet,
StockMovementForm, StockMovementForm,
} from "@/shared/schemas/stock"; } from "@/shared/schemas/stock";
import { compareTr, foldTr } from "@/shared/search"; import { compareTr, foldTr } from "@/shared/search";
import { import {
countSchedule,
currentQuantity, currentQuantity,
expiringLots, expiringLots,
expiryStatus, expiryStatus,
fifoLots, fifoLots,
formatStock, formatStock,
refillQuantity, monthlyUsage,
roundForDisplay,
signedDelta, signedDelta,
stockLevel, stockLevel,
suggestPurchase,
type CountSchedule,
type ExpiringLot, type ExpiringLot,
type ExpiryStatus, type ExpiryStatus,
type MonthUsage,
type PurchaseSuggestion,
type StockLevel, type StockLevel,
type StockMovementKind, type StockMovementKind,
type WasteReason, type WasteReason,
@@ -77,6 +88,7 @@ type ProductLean = {
countEveryDays: number | null; countEveryDays: number | null;
note: string; note: string;
archivedAt: Date | null; archivedAt: Date | null;
createdAt: Date;
}; };
type MoveLean = { type MoveLean = {
@@ -124,16 +136,30 @@ export type ProductTypeView = {
current: number; current: number;
level: StockLevel; level: StockLevel;
suggestedQuantity: number; 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[]; expiring: ExpiringLot[];
lastMovementDate: string | null; lastMovementDate: string | null;
movementCount: number; 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 = { export type StockView = {
categories: StockCategoryView[]; categories: StockCategoryView[];
items: ProductTypeView[]; items: ProductTypeView[];
suggestions: ProductTypeView[]; suggestions: ProductTypeView[];
expiring: { item: ProductTypeView; lot: ExpiringLot }[]; expiring: { item: ProductTypeView; lot: ExpiringLot }[];
countDue: CountDueGroup[];
canWrite: boolean; canWrite: boolean;
today: string; today: string;
}; };
@@ -261,19 +287,43 @@ function categoryViews(
}); });
} }
function toView( /** Ürün başına son sayım tarihi (sayım oturumlarından). */
p: ProductLean, async function loadLastCounts(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
moves: MoveLean[], const rows = await StockCountModel.aggregate<{ _id: Types.ObjectId; date: string }>([
categoryPath: string, { $match: { householdId, "items.productTypeId": { $in: productIds } } },
today: string, { $unwind: "$items" },
): ProductTypeView { { $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 current = currentQuantity(moves);
const level = stockLevel(current, p.targetQuantity, p.thresholdPct); const level = stockLevel(current, p.targetQuantity, p.thresholdPct);
const suggestion = suggestPurchase({
current,
target: p.targetQuantity,
step: p.step,
moves,
today,
});
return { return {
id: String(p._id), id: String(p._id),
name: p.name, name: p.name,
categoryId: String(p.categoryId), categoryId: String(p.categoryId),
categoryPath, categoryPath: ctx.categoryPath,
unit: p.unit, unit: p.unit,
targetQuantity: p.targetQuantity, targetQuantity: p.targetQuantity,
thresholdPct: p.thresholdPct, thresholdPct: p.thresholdPct,
@@ -284,7 +334,14 @@ function toView(
archived: Boolean(p.archivedAt), archived: Boolean(p.archivedAt),
current, current,
level, 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), expiring: expiringLots(fifoLots(moves), today),
lastMovementDate: moves.at(-1)?.date ?? null, lastMovementDate: moves.at(-1)?.date ?? null,
movementCount: moves.length, movementCount: moves.length,
@@ -308,15 +365,22 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
includeArchived: true, includeArchived: true,
}); });
const order = new Map(categories.map((c, i) => [c.id, i])); const order = new Map(categories.map((c, i) => [c.id, i]));
const pathOf = new Map(categories.map((c) => [c.id, c.path])); const catOf = new Map(categories.map((c) => [c.id, c]));
const moves = await loadMoves( const ids = products.map((p) => p._id);
m.householdId, const [moves, lastCounts] = await Promise.all([
products.map((p) => p._id), loadMoves(m.householdId, ids),
); loadLastCounts(m.householdId, ids),
]);
const items = products const items = products
.map((p) => .map((p) => {
toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today), 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( .sort(
(a, b) => (a, b) =>
(order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) || (order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) ||
@@ -333,11 +397,31 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
items, items,
suggestions, suggestions,
expiring, expiring,
countDue: groupCountDue(items),
canWrite: hasHouseholdRole(m.role, "member"), canWrite: hasHouseholdRole(m.role, "member"),
today, 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) { async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) {
const id = toObjectId(productTypeId); const id = toObjectId(productTypeId);
if (!id) return null; if (!id) return null;
@@ -357,11 +441,14 @@ export async function getProductType(
if (!p) return null; if (!p) return null;
const today = todayIstanbul(); const today = todayIstanbul();
const cats = await loadCategories(m.householdId); const cats = await loadCategories(m.householdId);
const path = const cat = categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find( (c) => c.id === String(p.categoryId),
(c) => c.id === String(p.categoryId), );
)?.path ?? ""; const [movesBy, lastCounts] = await Promise.all([
const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? []; loadMoves(m.householdId, [p._id]),
loadLastCounts(m.householdId, [p._id]),
]);
const moves = movesBy.get(String(p._id)) ?? [];
let running = 0; let running = 0;
const rows = moves.map((mv): StockMovementRow => { const rows = moves.map((mv): StockMovementRow => {
const delta = signedDelta(mv); const delta = signedDelta(mv);
@@ -379,7 +466,12 @@ export async function getProductType(
}; };
}); });
return { 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(), movements: rows.reverse(),
lots: fifoLots(moves).map((l) => ({ lots: fifoLots(moves).map((l) => ({
date: l.date, date: l.date,
@@ -877,8 +969,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 * Tek ürün sayımı (ürün detayından): toplu sayımın tek satırlık hâli. Fark yoksa düzeltme
* düzeltmesi olur. Fark yoksa kayıt oluşmaz. Farkı (işaretli) döner. * oluşmaz ama sayım kaydı tutulur. Farkı (işaretli) döner.
*/ */
export async function countStock( export async function countStock(
actorId: string, actorId: string,
@@ -886,22 +978,21 @@ export async function countStock(
productTypeId: string, productTypeId: string,
input: StockCountForm, input: StockCountForm,
): Promise<ActionResult<{ diff: number }>> { ): Promise<ActionResult<{ diff: number }>> {
const m = await resolveMembership(actorId, householdId, "member"); const r = await submitCount(actorId, householdId, {
if (!m) return fail(tr.common.unauthorized); date: input.date,
const r = await activeProductWithStock(m, productTypeId, input.date); categoryId: null,
if (r.error !== null) return fail(r.error); note: input.note,
const diff = input.actualQuantity - r.current; rows: [
if (diff !== 0) {
await StockMovementModel.create({ productTypeId,
householdId: m.householdId, actualQuantity: input.actualQuantity,
productTypeId: r.p._id, wasteQuantity: null,
kind: "count_adjust", wasteReason: null,
date: input.date, },
quantity: diff, ],
note: input.note, });
createdBy: new Types.ObjectId(actorId), if (!r.ok) return r;
}); return ok({ diff: r.data.diffs[0] ?? 0 });
return ok({ diff });
} }
export async function deleteStockMovement( export async function deleteStockMovement(
@@ -936,3 +1027,282 @@ export async function deleteStockMovement(
}); });
return ok(undefined); 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;
/** Son bilinen birim fiyat (ürün tipi biriminde, kesirli kuruş) ve tarihi. */
unitPrice: { minor: number; date: string } | null;
/** Aylık ortalama maliyet tahmini: ortalama tüketim × son birim fiyat (kuruş). */
avgMonthlyCostMinor: 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 ids = data.items.map((i) => new Types.ObjectId(i.id));
const [moves, prices] = await Promise.all([
loadMoves(m.householdId, ids),
latestTypeUnitPrices(m.householdId, ids, data.today),
]);
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");
const price = prices.get(item.id) ?? null;
const avgUsed = full.length ? used / full.length : null;
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,
unitPrice: price ? { minor: price.unitPriceMinor, date: price.date } : null,
avgMonthlyCostMinor:
price && avgUsed !== null
? Math.round((avgUsed * price.unitPriceMinor) / QUANTITY_SCALE)
: null,
thisMonth: current,
};
});
return { months, thisMonth, rows };
}
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { storeFormSchema } from "@/shared/schemas/store";
import { createStore, setStoreArchived, updateStore } from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T, R>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = schema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
/** Yeni (id boş) veya mevcut mağaza. */
export async function saveStoreAction(storeId: unknown, input: unknown) {
if (storeId) {
const id = idSchema.safeParse(storeId);
if (!id.success) return fail(tr.common.notFound);
return run(storeFormSchema, input, (u, h, d) => updateStore(u, h, id.data, d));
}
return run(storeFormSchema, input, createStore);
}
export async function setStoreArchivedAction(storeId: unknown, archived: unknown) {
const id = idSchema.safeParse(storeId);
if (!id.success) return fail(tr.common.notFound);
return run(z.boolean(), archived, (u, h, a) => setStoreArchived(u, h, id.data, a));
}
@@ -0,0 +1,239 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import { MEAL_CARD_PROVIDERS, type MealCardProvider } from "@/shared/accounts";
import { formatMoneyInput } from "@/shared/money";
import { quantityInput } from "@/shared/stock";
import { effortScore, STORE_KINDS, type StoreKind } from "@/shared/stores";
import { saveStoreAction } from "../actions";
import type { StoreView } from "../service";
const t = tr.stores;
type Errors = Record<string, string | undefined>;
/** Yeni mağaza / düzenle. Online mağazada uzaklık yerine teslimat ücretleri. */
export function StoreFormDialog({
store,
onClose,
}: {
store: StoreView | null;
onClose: () => void;
}) {
const [v, setV] = useState({
name: store?.name ?? "",
kind: (store?.kind ?? "market") as StoreKind,
branch: store?.branch ?? "",
distanceKm: store?.distanceM != null ? quantityInput(store.distanceM) : "",
carryDifficulty: String(store?.carryDifficulty ?? 2),
deliveryFee: store?.deliveryFeeMinor != null ? formatMoneyInput(store.deliveryFeeMinor) : "",
freeDeliveryMin:
store?.freeDeliveryMinMinor != null ? formatMoneyInput(store.freeDeliveryMinMinor) : "",
mealCardAccepted: store?.mealCardAccepted ?? false,
mealCardProviders: (store?.mealCardProviders ?? []) as MealCardProvider[],
note: store?.note ?? "",
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
const online = v.kind === "online";
const f = t.fields;
// Önizleme: girilen uzaklık ve taşıma puanıyla zahmet skoru.
const meters = Number(v.distanceKm.replace(",", ".")) * 1000;
const preview = effortScore({
kind: v.kind,
distanceM: Number.isFinite(meters) && v.distanceKm.trim() ? meters : null,
carryDifficulty: Number(v.carryDifficulty),
});
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await saveStoreAction(store?.id ?? null, {
...v,
carryDifficulty: v.carryDifficulty,
});
setBusy(false);
if (r.ok) {
toast.success(t.toast.saved);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
const toggleProvider = (p: MealCardProvider, on: boolean) =>
set(
"mealCardProviders",
on ? [...v.mealCardProviders, p] : v.mealCardProviders.filter((x) => x !== p),
);
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={store ? t.editTitle : t.createTitle}
footer={
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="store-form" disabled={busy}>
{tr.common.save}
</Button>
</>
}
>
<form id="store-form" onSubmit={submit} noValidate>
<FieldGroup>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.name)} className="col-span-2 sm:col-span-1">
<FieldLabel htmlFor="st-name">{f.name}</FieldLabel>
<Input
id="st-name"
value={v.name}
placeholder={f.namePlaceholder}
onChange={(e) => set("name", e.target.value)}
aria-invalid={Boolean(errors.name)}
autoFocus={!store}
/>
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
</Field>
<Field className="col-span-2 sm:col-span-1">
<FieldLabel htmlFor="st-branch">{f.branch}</FieldLabel>
<Input
id="st-branch"
value={v.branch}
placeholder={f.branchPlaceholder}
onChange={(e) => set("branch", e.target.value)}
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="st-kind">{f.kind}</FieldLabel>
<Select value={v.kind} onValueChange={(x) => set("kind", x as StoreKind)}>
<SelectTrigger id="st-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STORE_KINDS.map((k) => (
<SelectItem key={k} value={k}>
{t.kinds[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
{online ? (
<>
<Field data-invalid={Boolean(errors.deliveryFee)}>
<FieldLabel htmlFor="st-fee">{f.deliveryFee} (₺)</FieldLabel>
<Input
id="st-fee"
inputMode="decimal"
value={v.deliveryFee}
onChange={(e) => set("deliveryFee", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.deliveryFee)}
/>
{errors.deliveryFee ? <FieldError>{errors.deliveryFee}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.freeDeliveryMin)}>
<FieldLabel htmlFor="st-free">{f.freeDeliveryMin} (₺)</FieldLabel>
<Input
id="st-free"
inputMode="decimal"
value={v.freeDeliveryMin}
onChange={(e) => set("freeDeliveryMin", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.freeDeliveryMin)}
/>
{errors.freeDeliveryMin ? (
<FieldError>{errors.freeDeliveryMin}</FieldError>
) : null}
</Field>
</>
) : (
<Field data-invalid={Boolean(errors.distanceKm)}>
<FieldLabel htmlFor="st-distance">{f.distance}</FieldLabel>
<Input
id="st-distance"
inputMode="decimal"
value={v.distanceKm}
onChange={(e) => set("distanceKm", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.distanceKm)}
/>
{errors.distanceKm ? <FieldError>{errors.distanceKm}</FieldError> : null}
</Field>
)}
<Field className={online ? "col-span-2" : undefined}>
<FieldLabel htmlFor="st-carry">{f.carry}</FieldLabel>
<Select value={v.carryDifficulty} onValueChange={(x) => set("carryDifficulty", x)}>
<SelectTrigger id="st-carry" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5].map((n) => (
<SelectItem key={n} value={String(n)}>
{n} · {t.carryLevels[n]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<FieldDescription className="-mt-2">
{online ? null : `${f.distanceHint} `}
{f.carryHint} <span className="font-medium">{t.effortValue(preview)}</span>
</FieldDescription>
<Field orientation="horizontal">
<Checkbox
id="st-meal"
checked={v.mealCardAccepted}
onCheckedChange={(x) => set("mealCardAccepted", x === true)}
/>
<FieldLabel htmlFor="st-meal">{f.mealCard}</FieldLabel>
</Field>
{v.mealCardAccepted ? (
<Field data-invalid={Boolean(errors.mealCardProviders)}>
<FieldLabel>{f.mealCardProviders}</FieldLabel>
<div className="grid grid-cols-2 gap-2">
{MEAL_CARD_PROVIDERS.map((p) => (
<label key={p} className="flex items-center gap-2 text-sm">
<Checkbox
checked={v.mealCardProviders.includes(p)}
onCheckedChange={(x) => toggleProvider(p, x === true)}
/>
{tr.accounts.providers[p]}
</label>
))}
</div>
{errors.mealCardProviders ? (
<FieldError>{errors.mealCardProviders}</FieldError>
) : null}
</Field>
) : null}
<Field>
<FieldLabel htmlFor="st-note">{f.note}</FieldLabel>
<Input id="st-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,275 @@
"use client";
import {
Archive,
ArchiveRestore,
EllipsisVertical,
Globe,
MapPin,
Pencil,
Plus,
ShoppingBasket,
Store,
Utensils,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import {
formatDistance,
sortStores,
STORE_SORTS,
type StoreKind,
type StoreSort,
} from "@/shared/stores";
import { setStoreArchivedAction } from "../actions";
import type { StoreView } from "../service";
import { StoreFormDialog } from "./store-dialog";
const t = tr.stores;
const KIND_ICON: Record<StoreKind, typeof Store> = {
market: Store,
online: Globe,
bazaar: ShoppingBasket,
};
/** Zahmet çubuğu: 0–100, düzey etiketiyle (renk tek başına anlam taşımaz). */
function EffortMeter({ store }: { store: StoreView }) {
return (
<div className="flex items-center gap-2" title={t.effortHint}>
<span className="w-14 shrink-0 text-xs text-muted-foreground">{t.effort}</span>
<div
className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
role="meter"
aria-label={t.effortValue(store.effort)}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={store.effort}
>
<div
className={cn(
"h-full rounded-full",
store.effortLevel === "high"
? "bg-expense"
: store.effortLevel === "medium"
? "bg-amber-500"
: "bg-income",
)}
style={{ width: `${Math.max(store.effort, 2)}%` }}
/>
</div>
<span className="w-20 shrink-0 text-right text-xs tabular-nums">
{store.effort} · {t.effortLevels[store.effortLevel]}
</span>
</div>
);
}
/** /magazalar: sıralama (ad, en yakın, en uzak, en zahmetli), kartlar, ekle/düzenle/arşivle. */
export function StoresView({ stores, canWrite }: { stores: StoreView[]; canWrite: boolean }) {
const [sort, setSort] = useState<StoreSort>("name");
const [showArchived, setShowArchived] = useState(false);
const [editing, setEditing] = useState<StoreView | "new" | null>(null);
const [archiving, setArchiving] = useState<StoreView | null>(null);
const visible = useMemo(
() =>
sortStores(
stores.filter((s) => showArchived || !s.archived),
sort,
),
[stores, sort, showArchived],
);
const hasArchived = stores.some((s) => s.archived);
async function setArchived(store: StoreView, archived: boolean) {
const r = await setStoreArchivedAction(store.id, archived);
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
else toast.error(r.error);
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<Select value={sort} onValueChange={(x) => setSort(x as StoreSort)}>
<SelectTrigger className="w-44" aria-label={t.sort}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STORE_SORTS.map((s) => (
<SelectItem key={s} value={s}>
{t.sorts[s]}
</SelectItem>
))}
</SelectContent>
</Select>
{hasArchived ? (
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
{t.showArchived}
</label>
) : null}
{canWrite ? (
<Button size="sm" className="ml-auto" onClick={() => setEditing("new")}>
<Plus />
{t.new}
</Button>
) : null}
</div>
{visible.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
) : (
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{visible.map((s) => {
const Icon = KIND_ICON[s.kind];
return (
<li
key={s.id}
className={cn(
"flex flex-col gap-3 rounded-xl border bg-card p-4",
s.archived && "opacity-60",
)}
>
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{s.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[t.kindShort[s.kind], s.branch].filter(Boolean).join(" · ")}
</p>
</div>
{canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={s.archived} onSelect={() => setEditing(s)}>
<Pencil />
{t.actions.edit}
</DropdownMenuItem>
<DropdownMenuSeparator />
{s.archived ? (
<DropdownMenuItem onSelect={() => setArchived(s, false)}>
<ArchiveRestore />
{t.actions.unarchive}
</DropdownMenuItem>
) : (
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(s)}>
<Archive />
{t.actions.archive}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{s.kind === "online" ? (
<span className="text-muted-foreground">
{s.deliveryFeeMinor
? t.delivery(formatMoney(s.deliveryFeeMinor))
: t.freeDelivery}
{s.deliveryFeeMinor && s.freeDeliveryMinMinor
? ` · ${t.freeDeliveryFrom(formatMoney(s.freeDeliveryMinMinor))}`
: ""}
</span>
) : (
<span className="flex items-center gap-1 tabular-nums">
<MapPin className="size-3.5 text-muted-foreground" aria-hidden />
{s.distanceM !== null ? formatDistance(s.distanceM) : t.distanceUnknown}
</span>
)}
<span className="text-muted-foreground">
{t.fields.carry}: {s.carryDifficulty} · {t.carryLevels[s.carryDifficulty]}
</span>
</div>
<EffortMeter store={s} />
<div className="flex flex-wrap gap-1.5">
{s.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
{s.mealCardAccepted ? (
<Badge
variant="outline"
className="h-auto max-w-full gap-1 text-left whitespace-normal"
>
<Utensils className="size-3" aria-hidden />
{t.mealCardBadge}:{" "}
{s.mealCardProviders.map((p) => tr.accounts.providers[p]).join(", ")}
</Badge>
) : null}
</div>
{s.note ? <p className="text-xs text-muted-foreground">{s.note}</p> : null}
</li>
);
})}
</ul>
)}
{editing ? (
<StoreFormDialog
store={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
/>
) : null}
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
<AlertDialogDescription>
{archiving ? t.archiveDescription(archiving.label) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
const s = archiving;
setArchiving(null);
if (s) await setArchived(s, true);
}}
>
{t.actions.archive}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AuditLogModel } from "@/models/audit-log";
import { storeFormSchema, type StoreFormInput } from "@/shared/schemas/store";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import { createStore, listStores, setStoreArchived, updateStore } from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let zeynep = "";
let fatma = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
const form = (over: Partial<StoreFormInput> = {}) =>
storeFormSchema.parse({
name: "Macrocenter",
kind: "market",
branch: "Caddebostan",
distanceKm: "4,5",
carryDifficulty: "3",
deliveryFee: "",
freeDeliveryMin: "",
mealCardAccepted: false,
mealCardProviders: [],
note: "",
...over,
});
describe("mağazalar", () => {
it("seed: zahmet skoru hesaplanır; izolasyon", async () => {
const s = (await listStores(ayse, cakir))!;
expect(s.stores).toHaveLength(8);
const carrefour = s.stores.find((x) => x.name === "CarrefourSA")!;
// 3,8 km → 9,5; taşıma 4 → 37,5 → 47.
expect(carrefour).toMatchObject({ effort: 47, effortLevel: "medium", mealCardAccepted: true });
const getir = s.stores.find((x) => x.kind === "online")!;
expect(getir).toMatchObject({ distanceM: null, effort: 0, deliveryFeeMinor: 39_90 });
expect(s.stores.find((x) => x.name === "Metro Toptancı")!.effortLevel).toBe("high");
expect(await listStores(fatma, cakir)).toBeNull();
expect((await listStores(zeynep, cakir))!.canWrite).toBe(false);
});
it("şema: online mağazada uzaklık yok; yemek kartı seçilince sağlayıcı zorunlu", () => {
expect(form({ kind: "online", deliveryFee: "29,90", distanceKm: "3" })).toMatchObject({
distanceM: null,
deliveryFeeMinor: 29_90,
});
expect(form({ deliveryFee: "29,90" })).toMatchObject({
distanceM: 4_500,
deliveryFeeMinor: null,
});
expect(
storeFormSchema.safeParse({ ...form(), mealCardAccepted: true, mealCardProviders: [] })
.success,
).toBe(false);
expect(
storeFormSchema.safeParse({
name: "X",
kind: "market",
branch: "",
distanceKm: "abc",
carryDifficulty: "6",
deliveryFee: "",
freeDeliveryMin: "",
mealCardAccepted: false,
mealCardProviders: [],
}).success,
).toBe(false);
});
it("oluştur, aynı ad + şube reddi, düzenle, arşiv denetimi", async () => {
const r = await createStore(ayse, cakir, form());
expect(r.ok).toBe(true);
const id = r.ok ? r.data.id : "";
expect((await createStore(ayse, cakir, form({ name: "macrocenter" }))).ok).toBe(false);
// Farklı şube serbest.
expect((await createStore(ayse, cakir, form({ branch: "Ataşehir" }))).ok).toBe(true);
expect(
(
await updateStore(
ayse,
cakir,
id,
form({ mealCardAccepted: true, mealCardProviders: ["pluxee", "pluxee"] }),
)
).ok,
).toBe(true);
let s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!;
expect(s.mealCardProviders).toEqual(["pluxee"]);
expect((await createStore(zeynep, cakir, form({ name: "İzleyici" }))).ok).toBe(false);
expect((await setStoreArchived(ayse, cakir, id, true)).ok).toBe(true);
expect(await AuditLogModel.exists({ action: "store.archive", targetId: id })).toBeTruthy();
expect((await updateStore(ayse, cakir, id, form())).ok).toBe(false);
// Arşivliyken aynı ad + şube yeniden eklenebilir; arşivden çıkarma çakışırsa reddedilir.
expect((await createStore(ayse, cakir, form())).ok).toBe(true);
expect((await setStoreArchived(ayse, cakir, id, false)).ok).toBe(false);
s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!;
expect(s.archived).toBe(true);
// Başka hanenin kimliğiyle işlem yok.
expect((await setStoreArchived(fatma, cakir, id, true)).ok).toBe(false);
});
});
+189
View File
@@ -0,0 +1,189 @@
import "server-only";
import { Types } from "mongoose";
import { hasHouseholdRole } from "@/features/households/roles";
import { resolveMembership } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { toObjectId } from "@/lib/object-id";
import { StoreModel } from "@/models/store";
import type { MealCardProvider } from "@/shared/accounts";
import type { StoreForm } from "@/shared/schemas/store";
import { foldTr } from "@/shared/search";
import {
effortLevel,
effortScore,
sortStores,
type EffortLevel,
type StoreKind,
} from "@/shared/stores";
/**
* Mağazalar (Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma: member.
* Zahmet skoru okuma sırasında hesaplanır (saf motor: shared/stores).
*/
const t = tr.stores;
type StoreLean = {
_id: Types.ObjectId;
name: string;
kind: StoreKind;
branch: string;
distanceM: number | null;
carryDifficulty: number;
deliveryFeeMinor: number | null;
freeDeliveryMinMinor: number | null;
mealCardAccepted: boolean;
mealCardProviders: MealCardProvider[];
note: string;
archivedAt: Date | null;
};
export type StoreView = {
id: string;
name: string;
kind: StoreKind;
branch: string;
/** "Migros · Kadıköy". */
label: string;
distanceM: number | null;
carryDifficulty: number;
deliveryFeeMinor: number | null;
freeDeliveryMinMinor: number | null;
mealCardAccepted: boolean;
mealCardProviders: MealCardProvider[];
note: string;
archived: boolean;
effort: number;
effortLevel: EffortLevel;
};
export const storeLabel = (s: { name: string; branch: string }) =>
s.branch ? `${s.name} · ${s.branch}` : s.name;
function toView(s: StoreLean): StoreView {
const effort = effortScore(s);
return {
id: String(s._id),
name: s.name,
kind: s.kind,
branch: s.branch,
label: storeLabel(s),
distanceM: s.distanceM,
carryDifficulty: s.carryDifficulty,
deliveryFeeMinor: s.deliveryFeeMinor,
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
mealCardAccepted: s.mealCardAccepted,
mealCardProviders: s.mealCardProviders,
note: s.note,
archived: Boolean(s.archivedAt),
effort,
effortLevel: effortLevel(effort),
};
}
export async function listStores(
userId: string,
householdId: string,
): Promise<{ stores: StoreView[]; canWrite: boolean } | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const rows = await StoreModel.find({ householdId: m.householdId }).lean<StoreLean[]>();
return {
stores: sortStores(rows.map(toView), "name"),
canWrite: hasHouseholdRole(m.role, "member"),
};
}
/** Aynı ad + şube (Türkçe katlanmış) aktif mağazalarda tektir. */
async function isDuplicate(
householdId: Types.ObjectId,
input: Pick<StoreForm, "name" | "branch">,
except?: Types.ObjectId,
) {
const rows = await StoreModel.find({
householdId,
nameKey: foldTr(input.name),
archivedAt: null,
...(except ? { _id: { $ne: except } } : {}),
})
.select({ branch: 1 })
.lean<{ branch: string }[]>();
return rows.some((r) => foldTr(r.branch) === foldTr(input.branch));
}
const fields = (input: StoreForm) => ({ ...input, nameKey: foldTr(input.name) });
export async function createStore(
actorId: string,
householdId: string,
input: StoreForm,
): Promise<ActionResult<{ id: string }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
if (await isDuplicate(m.householdId, input))
return fail(t.validation.duplicate, { name: t.validation.duplicate });
const actor = new Types.ObjectId(actorId);
const doc = await StoreModel.create({
householdId: m.householdId,
...fields(input),
createdBy: actor,
updatedBy: actor,
});
return ok({ id: String(doc._id) });
}
export async function updateStore(
actorId: string,
householdId: string,
storeId: string,
input: StoreForm,
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const id = toObjectId(storeId);
const current = id
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean()
: null;
if (!current) return fail(tr.common.notFound);
if (current.archivedAt) return fail(t.validation.archived);
if (await isDuplicate(m.householdId, input, current._id))
return fail(t.validation.duplicate, { name: t.validation.duplicate });
await StoreModel.updateOne(
{ _id: current._id, householdId: m.householdId },
{ $set: { ...fields(input), updatedBy: new Types.ObjectId(actorId) } },
);
return ok(undefined);
}
export async function setStoreArchived(
actorId: string,
householdId: string,
storeId: string,
archived: boolean,
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const id = toObjectId(storeId);
const current = id
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean<StoreLean>()
: null;
if (!current) return fail(tr.common.notFound);
if (!archived && (await isDuplicate(m.householdId, current, current._id)))
return fail(t.validation.duplicate);
const actor = new Types.ObjectId(actorId);
await StoreModel.updateOne(
{ _id: current._id, householdId: m.householdId },
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
);
await writeAudit({
action: archived ? "store.archive" : "store.unarchive",
actorId: actor,
householdId: m.householdId,
targetType: "store",
targetId: storeId,
diff: { name: storeLabel(current) },
});
return ok(undefined);
}
+292 -2
View File
@@ -66,6 +66,7 @@ export const tr = {
contacts: "Cari hesaplar", contacts: "Cari hesaplar",
savings: "Birikim", savings: "Birikim",
stock: "Ev stoğu", stock: "Ev stoğu",
stores: "Mağazalar",
shopping: "Alışveriş", shopping: "Alışveriş",
reports: "Raporlar", reports: "Raporlar",
settings: "Ayarlar", settings: "Ayarlar",
@@ -935,6 +936,71 @@ export const tr = {
"Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.", "Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.",
newProduct: "Yeni ürün tipi", newProduct: "Yeni ürün tipi",
categoriesLink: "Stok kategorileri", 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, fire ve tahmini maliyet. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet: aylık ortalama tüketim × son bilinen birim fiyat.",
avgCost: "Aylık ort. maliyet",
costHint: (price: string, date: string) => `Son birim fiyat ${price} (${date})`,
noPrice: "Fiyat yok",
totalCost: (total: string) => `Fiyatı bilinen ürünlerde aylık tahmini maliyet: ${total}`,
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.", readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
summary: { summary: {
products: "Ürün tipi", products: "Ürün tipi",
@@ -947,6 +1013,10 @@ export const tr = {
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.", 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.", empty: "Eşiğin altına düşen ürün yok.",
suggested: (q: string) => `Öneri: ${q}`, 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: { expiring: {
title: "Son kullanma tarihi", title: "Son kullanma tarihi",
@@ -1031,6 +1101,13 @@ export const tr = {
target: "Hedef", target: "Hedef",
threshold: "Eşik", threshold: "Eşik",
suggested: "Önerilen alım", 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", location: "Saklama yeri",
lots: "Evdeki partiler", lots: "Evdeki partiler",
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).", lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
@@ -1103,17 +1180,20 @@ export const tr = {
wasteReason: "Fire nedenini seçin", wasteReason: "Fire nedenini seçin",
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz", expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
notEnough: (q: string) => `Stokta yalnızca ${q} var.`, 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ş.", emptyStock: "Stok zaten boş.",
duplicate: "Bu adla bir ürün tipi zaten var.", duplicate: "Bu adla bir ürün tipi zaten var.",
categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.", categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.",
archived: "Ürün tipi arşivli.", archived: "Ürün tipi arşivli.",
}, },
dashboard: { dashboard: {
title: "Azalan stoklar", title: "Stok uyarıları",
description: (low: number, expiring: number) => description: (low: number, expiring: number, countDue = 0) =>
[ [
low ? `${low} ürün eşiğin altında` : null, low ? `${low} ürün eşiğin altında` : null,
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null, expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
countDue ? `${countDue} kategoride sayım zamanı` : null,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "), .join(" · "),
@@ -1121,6 +1201,216 @@ export const tr = {
open: "Ev stoğuna git", open: "Ev stoğuna git",
}, },
}, },
stores: {
title: "Mağazalar",
description:
"Alışveriş yaptığınız marketler, online platformlar ve pazarlar. Uzaklık ve taşıma güçlüğünden zahmet skoru hesaplanır; alışveriş önerisi bunu dikkate alır.",
new: "Yeni mağaza",
createTitle: "Yeni mağaza",
editTitle: "Mağazayı düzenle",
empty: "Henüz mağaza yok. “Yeni mağaza” ile ekleyin.",
readOnly: "İzleyici rolündesiniz; mağazaları değiştiremezsiniz.",
sort: "Sırala",
sorts: {
name: "Ada göre",
nearest: "En yakın",
farthest: "En uzak",
effort: "En zahmetli",
},
showArchived: "Arşivi göster",
archived: "Arşivli",
kinds: {
market: "Fiziksel market",
online: "Online platform",
bazaar: "Pazar",
},
kindShort: {
market: "Market",
online: "Online",
bazaar: "Pazar",
},
fields: {
name: "Ad",
namePlaceholder: "ör. Migros, A101, Semt pazarı",
kind: "Tür",
branch: "Şube",
branchPlaceholder: "ör. Kadıköy",
distance: "Eve uzaklık (km)",
distanceHint: "ör. 1,5. Online mağazada gerekmez.",
carry: "Taşıma güçlüğü",
carryHint: "1 = kolay (kapının önü), 5 = çok zor (uzak, yokuş, araç gerekir).",
deliveryFee: "Teslimat ücreti",
freeDeliveryMin: "Ücretsiz teslimat alt limiti",
mealCard: "Yemek kartı geçerli",
mealCardProviders: "Geçerli yemek kartları",
note: "Not",
},
carryLevels: {
1: "Kolay",
2: "Rahat",
3: "Orta",
4: "Zor",
5: "Çok zor",
} as Record<number, string>,
effort: "Zahmet",
effortLevels: { low: "Düşük", medium: "Orta", high: "Yüksek" },
effortValue: (score: number) => `Zahmet ${score}/100`,
effortHint:
"Zahmet skoru 0–100: uzaklık (20 km'ye kadar) ve taşıma güçlüğü eşit ağırlıkla. Online mağazada uzaklık sayılmaz.",
distanceUnknown: "Uzaklık girilmedi",
delivery: (fee: string) => `Teslimat ${fee}`,
freeDeliveryFrom: (min: string) => `${min} üzeri ücretsiz`,
freeDelivery: "Ücretsiz teslimat",
mealCardBadge: "Yemek kartı",
actions: {
edit: "Düzenle",
archive: "Arşivle",
unarchive: "Arşivden çıkar",
more: "Mağaza işlemleri",
},
archiveTitle: "Mağaza arşivlensin mi?",
archiveDescription: (name: string) =>
`“${name}” yeni fiyat ve alışverişlerde seçilemez. Geçmiş kayıtları silinmez.`,
toast: {
saved: "Mağaza kaydedildi.",
archived: "Mağaza arşivlendi.",
unarchived: "Mağaza arşivden çıkarıldı.",
},
validation: {
distance: "Geçerli bir uzaklık girin (ör. 2,5)",
carry: "1 ile 5 arasında seçin",
providers: "En az bir yemek kartı seçin",
duplicate: "Bu ad ve şubeyle bir mağaza zaten var.",
archived: "Mağaza arşivli.",
},
},
products: {
title: "Ürünler",
description:
"Marka ve paket düzeyinde ürünler (ör. “Ariel Matik 5 kg”). Her ürün bir ürün tipine bağlıdır; stok ürün tipinde tutulur, fiyatlar üründe.",
catalogLink: "Ürün kataloğu",
back: "Ev stoğuna dön",
new: "Yeni ürün",
createTitle: "Yeni ürün",
editTitle: "Ürünü düzenle",
sectionTitle: "Ürünler (marka / paket)",
sectionDescription:
"Bu ürün tipinin markaları ve paketleri. Fiyatlar ve birim fiyat karşılaştırması bu ürünler üzerinden yapılır.",
empty: "Henüz ürün yok.",
emptyType: "Bu ürün tipine henüz marka/paket eklenmedi.",
search: "Marka, tanım veya barkod ara",
noMatch: "Aramaya uyan ürün yok.",
showArchived: "Arşivi göster",
archived: "Arşivli",
barcode: (code: string) => `Barkod ${code}`,
images: (n: number) => `${n} görsel`,
conversionMissing: "Dönüşüm gerekli",
conversionMissingHint:
"Ürün tipinin birimi değişti; bu paketin karşılığını girmeden fiyatı karşılaştırılamaz.",
fields: {
productType: "Ürün tipi",
brand: "Marka",
brandPlaceholder: "ör. Ariel, Fairy, Yayla",
name: "Tanım",
namePlaceholder: "ör. Matik Dağ Esintisi",
barcode: "Barkod",
barcodeHint: "İsteğe bağlı, 8–14 rakam.",
packageQuantity: "Paket miktarı",
packageUnit: "Paket birimi",
typeQuantity: (unit: string) => `Bir paket kaç ${unit}?`,
typeQuantityHint: (pkg: string, unit: string) =>
`${pkg} ürün tipinin birimine (${unit}) kendiliğinden çevrilemiyor; ör. 5 kg = 50 yıkama.`,
autoConversion: (pkg: string) => `Bir paket = ${pkg}`,
images: "Ürün görselleri",
addImage: "Görsel ekle",
imagesHint: "Ambalajın önü, arkası veya raf etiketi",
note: "Not",
},
actions: {
edit: "Düzenle",
archive: "Arşivle",
unarchive: "Arşivden çıkar",
more: "Ürün işlemleri",
},
archiveTitle: "Ürün arşivlensin mi?",
archiveDescription: (name: string) =>
`“${name}” yeni fiyat ve alışveriş listelerinde seçilemez. Görselleri ve geçmişi silinmez.`,
toast: {
saved: "Ürün kaydedildi.",
archived: "Ürün arşivlendi.",
unarchived: "Ürün arşivden çıkarıldı.",
},
validation: {
productType: "Aktif bir ürün tipi seçin",
barcode: "Barkod 8–14 rakam olmalı",
barcodeTaken: "Bu barkod başka bir üründe kayıtlı.",
packageQuantity: "Geçerli bir paket miktarı girin (ör. 5 veya 0,5)",
packageUnit: "Paket birimi 1–20 karakter olmalı",
typeQuantity: "Geçerli bir miktar girin",
conversionRequired: (pkgUnit: string, typeUnit: string) =>
`${pkgUnit} → ${typeUnit} dönüşümünü girin (bir paket kaç ${typeUnit}?).`,
archived: "Ürün arşivli.",
},
},
productPrices: {
add: "Fiyat gir",
addTitle: "Fiyat gir",
comparisonTitle: "Birim fiyat karşılaştırması",
comparisonDescription: (unit: string) =>
`Marka ve mağazaların son bilinen fiyatları, 1 ${unit} başına sıralı. Süresi biten kampanyada önceki normal fiyat kullanılır.`,
noPrices: "Henüz fiyat girilmedi.",
noProducts: "Fiyat girmek için önce bir ürün (marka/paket) ekleyin.",
cheapest: "En uygun",
diff: (pct: number) => `%${pct} pahalı`,
perUnit: "Birim fiyat",
packagePrice: "Paket fiyatı",
store: "Mağaza",
product: "Ürün",
date: "Tarih",
promo: "Kampanya",
promoUntil: (date: string) => `${date} tarihine kadar`,
promoEnded: "Kampanya bitti",
fellBack: "Kampanya bitti, normal fiyat",
stale: (days: number) => `${days} gün önce`,
staleHint: "90 günden eski fiyat; güncel olmayabilir.",
noConversion: "Dönüşüm yok",
sources: { manual: "Elle", receipt: "Fiş", provider: "Sağlayıcı" } as Record<string, string>,
detail: {
back: "Ürün kataloğu",
typeLink: (name: string) => `Ürün tipi: ${name}`,
currentTitle: "Mağazalardaki fiyatlar",
currentDescription: "Her mağazadaki geçerli (son) fiyat; birim fiyata göre sıralı.",
chartTitle: "Fiyat geçmişi",
chartDescription: "Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli sayılır.",
chartLabel: (name: string) => `${name}: mağaza başına fiyat geçmişi`,
historyTitle: "Fiyat kayıtları",
deletePrice: "Fiyat kaydını sil",
deleteTitle: "Fiyat kaydı silinsin mi?",
deleteDescription: "Kayıt fiyat geçmişinden ve karşılaştırmadan çıkarılır.",
images: "Görseller",
barcode: "Barkod",
package: "Paket",
},
fields: {
product: "Ürün",
store: "Mağaza",
date: "Tarih",
price: "Paket fiyatı (₺)",
isPromo: "Kampanya / indirim",
promoUntil: "Kampanya bitişi",
promoUntilHint: "İsteğe bağlı. Bitince karşılaştırmada önceki normal fiyat kullanılır.",
note: "Not",
unitPreview: (value: string) => `Birim fiyat: ${value}`,
},
toast: { saved: "Fiyat kaydedildi.", deleted: "Fiyat kaydı silindi." },
validation: {
product: "Aktif bir ürün seçin",
store: "Aktif bir mağaza seçin",
price: "Geçerli bir fiyat girin (ör. 89,90)",
promoUntil: "Kampanya bitişi fiyat tarihinden önce olamaz",
futureDate: "Gelecek tarihli fiyat girilemez",
},
},
prices: { prices: {
title: "Fiyat tablosu", title: "Fiyat tablosu",
description: description:
+1 -1
View File
@@ -2,7 +2,7 @@ import { model, models, Schema, type InferSchemaType, type Model } from "mongoos
import { ATTACHMENT_KINDS } from "@/shared/attachments"; import { ATTACHMENT_KINDS } from "@/shared/attachments";
/** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */ /** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */
export const ATTACHMENT_OWNER_TYPES = ["transaction"] as const; export const ATTACHMENT_OWNER_TYPES = ["transaction", "product"] as const;
export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number]; export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number];
/** /**
+5
View File
@@ -40,6 +40,11 @@ export const AUDIT_ACTIONS = [
"stock.product.archive", "stock.product.archive",
"stock.product.unarchive", "stock.product.unarchive",
"stock.movement.delete", "stock.movement.delete",
"store.archive",
"store.unarchive",
"product.archive",
"product.unarchive",
"price.delete",
] as const; ] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number]; export type AuditAction = (typeof AUDIT_ACTIONS)[number];
+38
View File
@@ -0,0 +1,38 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Ürün fiyat kaydı (Bölüm 6.11): ürün × mağaza × tarih × paket fiyatı (kuruş). Kampanya işareti
* ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:<ad>`. Hane bazlı. Birim fiyat
* okuma sırasında ürünün dönüşümüyle hesaplanır (saklanmaz).
*/
const productPriceSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
productId: { type: Schema.Types.ObjectId, required: true },
storeId: { type: Schema.Types.ObjectId, required: true },
/** "YYYY-MM-DD" (İstanbul). */
date: { type: String, required: true },
priceMinor: { type: Number, required: true, min: 1 },
isPromo: { type: Boolean, default: false },
promoUntil: { type: String, default: null },
source: { type: String, required: true, match: /^(manual|receipt|provider:[a-z0-9-]{1,40})$/ },
note: { type: String, default: "", maxlength: 300 },
deletedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "productPrices" },
);
productPriceSchema.index({ householdId: 1, productId: 1, deletedAt: 1, date: 1, createdAt: 1 });
productPriceSchema.index({ householdId: 1, storeId: 1, date: -1 });
productPriceSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type ProductPrice = InferSchemaType<typeof productPriceSchema>;
export const ProductPriceModel: Model<ProductPrice> =
(models.ProductPrice as Model<ProductPrice> | undefined) ??
model<ProductPrice>("ProductPrice", productPriceSchema);
+45
View File
@@ -0,0 +1,45 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Ürün (marka/varyant, Bölüm 6.11): "Ariel Matik 5 kg / 50 yıkama". Bir ürün tipine bağlıdır;
* fiyatlar (Faz 12b) bu seviyede tutulur. Paket miktarı binde bir. `typeQuantity`: paket birimi
* ürün tipinin birimine kendiliğinden çevrilemiyorsa bir paketin ürün tipi birimindeki karşılığı
* (zorunlu); çevrilebiliyorsa null. Görseller `attachments` (ownerType "product").
*/
const productSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
productTypeId: { type: Schema.Types.ObjectId, required: true },
brand: { type: String, required: true, trim: true, maxlength: 60 },
/** Tanım (ör. "Matik Dağ Esintisi"). */
name: { type: String, default: "", trim: true, maxlength: 120 },
/** Arama anahtarı: marka + tanım (Türkçe katlanmış). */
searchKey: { type: String, required: true },
barcode: { type: String, default: null },
packageQuantity: { type: Number, required: true, min: 1 },
packageUnit: { type: String, required: true, trim: true, maxlength: 20 },
typeQuantity: { type: Number, default: null, min: 1 },
note: { type: String, default: "", maxlength: 300 },
imageCount: { type: Number, default: 0 },
archivedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "products" },
);
productSchema.index({ householdId: 1, productTypeId: 1, archivedAt: 1 });
productSchema.index(
{ householdId: 1, barcode: 1 },
{ partialFilterExpression: { barcode: { $type: "string" } } },
);
productSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type Product = InferSchemaType<typeof productSchema>;
export const ProductModel: Model<Product> =
(models.Product as Model<Product> | undefined) ?? model<Product>("Product", productSchema);
+45
View File
@@ -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);
+43
View File
@@ -0,0 +1,43 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { MEAL_CARD_PROVIDERS } from "@/shared/accounts";
import { STORE_KINDS } from "@/shared/stores";
/**
* Mağaza (Bölüm 6.11): fiziksel market, online platform veya pazar. Hane bazlı (hane içinde
* ortak). Uzaklık metre; teslimat ücretleri kuruş. Zahmet skoru okuma sırasında hesaplanır.
*/
const storeSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
name: { type: String, required: true, trim: true, maxlength: 80 },
nameKey: { type: String, required: true },
kind: { type: String, enum: STORE_KINDS, required: true },
branch: { type: String, default: "", maxlength: 80 },
/** Eve uzaklık (metre); online mağazada null. */
distanceM: { type: Number, default: null, min: 0 },
/** Taşıma güçlüğü 1 (kolay) – 5 (çok zor). */
carryDifficulty: { type: Number, default: 1, min: 1, max: 5 },
/** Online: teslimat ücreti ve ücretsiz teslimat alt limiti (kuruş). */
deliveryFeeMinor: { type: Number, default: null, min: 0 },
freeDeliveryMinMinor: { type: Number, default: null, min: 0 },
mealCardAccepted: { type: Boolean, default: false },
mealCardProviders: { type: [{ type: String, enum: MEAL_CARD_PROVIDERS }], default: [] },
note: { type: String, default: "", maxlength: 300 },
archivedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "stores" },
);
storeSchema.index({ householdId: 1, archivedAt: 1, nameKey: 1 });
storeSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type Store = InferSchemaType<typeof storeSchema>;
export const StoreModel: Model<Store> =
(models.Store as Model<Store> | undefined) ?? model<Store>("Store", storeSchema);
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
effectivePrice,
formatUnitPrice,
PRICE_SOURCE_PATTERN,
rankOffers,
seriesSlot,
unitPrice,
unitPriceBase,
} from "./prices";
const r = (
date: string,
priceMinor: number,
isPromo = false,
promoUntil: string | null = null,
) => ({
date,
priceMinor,
isPromo,
promoUntil,
});
describe("birim fiyat", () => {
it("paket fiyatı / ürün tipi birimindeki miktar; yuvarlama yalnızca gösterimde", () => {
// Ariel 5 kg = 33 yıkama, 519,90 TL → 15,754… TL/yıkama.
const u = unitPrice(519_90, 33_000);
expect(u).toBeCloseTo(1575.4545, 3);
expect(formatUnitPrice(u, "yıkama")).toMatch(/15,75\/yıkama$/);
// 500 g beyaz peynir 210 TL, ürün tipi g olsaydı: 0,42 TL/g → 420 TL/kg.
expect(formatUnitPrice(unitPrice(210_00, 500_000), "g")).toMatch(/420,00\/kg$/);
expect(unitPriceBase("ml")).toEqual({ unit: "lt", factor: 1000 });
expect(unitPriceBase("adet")).toEqual({ unit: "adet", factor: 1 });
});
it("kaynak biçimi", () => {
expect(
["manual", "receipt", "provider:marketfiyati"].every((s) => PRICE_SOURCE_PATTERN.test(s)),
).toBe(true);
expect(PRICE_SOURCE_PATTERN.test("provider:")).toBe(false);
expect(PRICE_SOURCE_PATTERN.test("scrape")).toBe(false);
});
});
describe("geçerli fiyat", () => {
const today = "2026-11-06";
it("son kayıt; süresi biten kampanyada önceki normal fiyata dönülür", () => {
expect(effectivePrice([], today)).toBeNull();
const normal = effectivePrice([r("2026-10-01", 100_00), r("2026-11-01", 110_00)], today)!;
expect(normal).toMatchObject({ record: { priceMinor: 110_00 }, fellBack: false, ageDays: 5 });
const ended = effectivePrice(
[r("2026-10-01", 100_00), r("2026-10-20", 80_00, true, "2026-10-31")],
today,
)!;
expect(ended).toMatchObject({
record: { priceMinor: 100_00 },
fellBack: true,
promoEnded: false,
});
const active = effectivePrice(
[r("2026-10-01", 100_00), r("2026-11-02", 80_00, true, "2026-11-10")],
today,
)!;
expect(active).toMatchObject({ record: { priceMinor: 80_00 }, fellBack: false });
// Dönülecek normal fiyat yoksa kampanya fiyatı işaretle.
const only = effectivePrice([r("2026-10-20", 80_00, true, "2026-10-31")], today)!;
expect(only).toMatchObject({ fellBack: false, promoEnded: true });
expect(effectivePrice([r("2026-07-01", 1)], today)!.stale).toBe(true);
expect(effectivePrice([r("2026-09-01", 1)], today)!.stale).toBe(false);
});
});
describe("sıralama", () => {
it("birim fiyata göre artan, eşitler en uygun, hesaplanamayan sonda", () => {
const ranked = rankOffers([
{ id: "a", unitPriceMinor: 1_500 },
{ id: "b", unitPriceMinor: null },
{ id: "c", unitPriceMinor: 1_200 },
{ id: "d", unitPriceMinor: 1_200 },
{ id: "e", unitPriceMinor: 1_800 },
]);
expect(ranked.map((o) => [o.id, o.rank, o.cheapest, o.diffPct])).toEqual([
["c", 1, true, 0],
["d", 2, true, 0],
["a", 3, false, 25],
["e", 4, false, 50],
["b", null, false, null],
]);
expect([seriesSlot(0), seriesSlot(7), seriesSlot(8)]).toEqual([1, 8, 1]);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { daysBetween } from "./budget";
import { formatMoney } from "./format";
import { QUANTITY_SCALE } from "./quantity";
/**
* Ürün fiyatı ve birim fiyat motoru (Bölüm 6.11). Saf fonksiyonlar.
*
* Fiyat paket fiyatıdır (kuruş). Birim fiyat, ürün tipinin biriminde 1 birimin fiyatıdır:
* birim fiyat = paket fiyatı × 1000 / (paketin ürün tipi birimindeki miktarı, binde bir)
* Kesirli kuruş olarak tutulur; yuvarlama yalnızca gösterimde. Gösterimde g → kg, ml → lt.
*/
export const PRICE_SOURCE_PATTERN = /^(manual|receipt|provider:[a-z0-9-]{1,40})$/;
export type PriceSource = "manual" | "receipt" | `provider:${string}`;
/** Bu kadar günden eski fiyat "eski" sayılır. */
export const PRICE_STALE_DAYS = 90;
export function unitPrice(priceMinor: number, packageInTypeUnit: number): number {
return (priceMinor * QUANTITY_SCALE) / packageInTypeUnit;
}
/** Gösterim birimi: küçük birimler büyüğe (g → kg, ml → lt). */
export function unitPriceBase(typeUnit: string): { unit: string; factor: number } {
if (typeUnit === "g") return { unit: "kg", factor: 1000 };
if (typeUnit === "ml") return { unit: "lt", factor: 1000 };
return { unit: typeUnit, factor: 1 };
}
/** 4250 kuruş/kg → "₺42,50/kg"; g biriminde 4,25 kuruş/g → "₺42,50/kg". */
export function formatUnitPrice(unitPriceMinor: number, typeUnit: string): string {
const base = unitPriceBase(typeUnit);
return `${formatMoney(Math.round(unitPriceMinor * base.factor))}/${base.unit}`;
}
export type PriceRecordLike = {
date: string;
priceMinor: number;
isPromo: boolean;
/** Kampanya geçerlilik sonu (dahil); yoksa süresiz. */
promoUntil: string | null;
};
export const promoEnded = (r: PriceRecordLike, today: string) =>
r.isPromo && r.promoUntil !== null && r.promoUntil < today;
export type EffectivePrice<T> = {
record: T;
/** Son kayıt süresi biten kampanyaydı; önceki normal fiyata dönüldü. */
fellBack: boolean;
/** Geçerli fiyat süresi biten kampanya (dönülecek normal fiyat yok). */
promoEnded: boolean;
ageDays: number;
stale: boolean;
};
/**
* Geçerli fiyat: tarih (ve giriş) sırasındaki son kayıt. Son kayıt süresi biten kampanyaysa,
* ondan önceki son kampanyasız (veya hâlâ geçerli kampanyalı) kayda dönülür; yoksa kampanya
* fiyatı işaretle kullanılır. `records` eskiden yeniye sıralı olmalı.
*/
export function effectivePrice<T extends PriceRecordLike>(
records: readonly T[],
today: string,
): EffectivePrice<T> | null {
const latest = records.at(-1);
if (!latest) return null;
let record = latest;
let fellBack = false;
if (promoEnded(latest, today)) {
const prev = [...records].reverse().find((r) => !promoEnded(r, today));
if (prev) {
record = prev;
fellBack = true;
}
}
const ageDays = record.date <= today ? daysBetween(record.date, today) - 1 : 0;
return {
record,
fellBack,
promoEnded: promoEnded(record, today),
ageDays,
stale: ageDays > PRICE_STALE_DAYS,
};
}
export type Offer = { unitPriceMinor: number | null };
export type RankedOffer<T> = T & {
rank: number | null;
cheapest: boolean;
/** En uygundan yüzde fark (tamsayı); en uygunda 0. */
diffPct: number | null;
};
/**
* Birim fiyata göre sıralar (artan); birim fiyatı hesaplanamayanlar (dönüşüm eksik) sonda.
* En düşük birim fiyatlı tüm teklifler (eşitlik dahil) "en uygun".
*/
export function rankOffers<T extends Offer>(offers: readonly T[]): RankedOffer<T>[] {
const priced = offers.filter((o) => o.unitPriceMinor !== null);
const min = priced.length ? Math.min(...priced.map((o) => o.unitPriceMinor!)) : null;
const sorted = [...offers].sort((a, b) => {
if (a.unitPriceMinor === null || b.unitPriceMinor === null)
return a.unitPriceMinor === null ? (b.unitPriceMinor === null ? 0 : 1) : -1;
return a.unitPriceMinor - b.unitPriceMinor;
});
let rank = 0;
return sorted.map((o) => {
if (o.unitPriceMinor === null || min === null)
return { ...o, rank: null, cheapest: false, diffPct: null };
rank++;
return {
...o,
rank,
cheapest: Math.abs(o.unitPriceMinor - min) < 1e-9,
diffPct: min > 0 ? Math.round(((o.unitPriceMinor - min) * 100) / min) : 0,
};
});
}
/** Grafik/legend için mağaza renk yuvası (1–8): mağazanın hanedeki ekleniş sırasına göre sabit. */
export const SERIES_SLOTS = 8;
export const seriesSlot = (storeIndex: number) => (storeIndex % SERIES_SLOTS) + 1;
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { autoConvertible, BARCODE_PATTERN, describePackage, packageInTypeUnit } from "./products";
const p = (
packageQuantity: number,
packageUnit: string,
typeUnit: string,
typeQuantity: number | null = null,
) => ({
packageQuantity,
packageUnit,
typeUnit,
typeQuantity,
});
describe("paketin ürün tipi birimindeki karşılığı", () => {
it("aynı birim ve aynı boyut kendiliğinden", () => {
expect(packageInTypeUnit(p(5_000, "kg", "kg"))).toBe(5_000);
expect(packageInTypeUnit(p(500_000, "g", "kg"))).toBe(500);
expect(packageInTypeUnit(p(1_500, "lt", "ml"))).toBe(1_500_000);
expect(packageInTypeUnit(p(40_000, "tablet", "tablet"))).toBe(40_000);
expect(autoConvertible("g", "kg")).toBe(true);
expect(autoConvertible("kg", "lt")).toBe(false);
expect(autoConvertible("paket", "adet")).toBe(false);
});
it("farklı boyut veya özel birimde elle dönüşüm, yoksa null", () => {
expect(packageInTypeUnit(p(5_000, "kg", "yıkama", 50_000))).toBe(50_000);
expect(packageInTypeUnit(p(1_000, "paket", "tablet", 40_000))).toBe(40_000);
expect(packageInTypeUnit(p(5_000, "kg", "yıkama"))).toBeNull();
// Kendiliğinden çevrilebiliyorsa elle girilen yok sayılır.
expect(packageInTypeUnit(p(500_000, "g", "kg", 999))).toBe(500);
});
it("paket açıklaması ve barkod", () => {
expect(describePackage(p(5_000, "kg", "yıkama", 50_000))).toBe("5 kg · 50 yıkama");
expect(describePackage(p(500_000, "g", "kg"))).toBe("500 g · 0,5 kg");
expect(describePackage(p(2_000, "kg", "kg"))).toBe("2 kg");
expect(BARCODE_PATTERN.test("8690000000001")).toBe(true);
expect(BARCODE_PATTERN.test("123")).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { convertQuantity, formatQuantity, UNITS, type Unit } from "./quantity";
/**
* Ürün (marka/varyant) motoru (Bölüm 6.11). Bir paketin ürün tipinin birimindeki karşılığı:
* - paket birimi ürün tipinin birimiyle aynıysa paket miktarı,
* - aynı boyuttaysa (kg↔g, lt↔ml) çevrilmiş miktar,
* - değilse elle girilen dönüşüm ("5 kg = 50 yıkama", "1 paket = 40 tablet"); yoksa null
* (kayıtta zorunlu; ürün tipinin birimi sonradan değişirse "dönüşüm gerekli" uyarısı).
* Miktarlar binde bir.
*/
const isStandard = (u: string): u is Unit => Object.hasOwn(UNITS, u);
/** Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyor mu? */
export function autoConvertible(packageUnit: string, typeUnit: string): boolean {
if (packageUnit === typeUnit) return true;
return (
isStandard(packageUnit) &&
isStandard(typeUnit) &&
UNITS[packageUnit].dimension === UNITS[typeUnit].dimension
);
}
/** Bir paketin ürün tipinin birimindeki miktarı (çevrilemiyorsa null). */
export function packageInTypeUnit(input: {
packageQuantity: number;
packageUnit: string;
typeUnit: string;
/** Elle girilen dönüşüm: bir paket = bu kadar ürün tipi birimi. */
typeQuantity: number | null;
}): number | null {
const { packageQuantity, packageUnit, typeUnit } = input;
if (packageUnit === typeUnit) return packageQuantity;
if (autoConvertible(packageUnit, typeUnit))
return convertQuantity(packageQuantity, packageUnit as Unit, typeUnit as Unit);
return input.typeQuantity && input.typeQuantity > 0 ? input.typeQuantity : null;
}
/** "5 kg", dönüşüm varsa "5 kg · 50 yıkama". */
export function describePackage(input: {
packageQuantity: number;
packageUnit: string;
typeUnit: string;
typeQuantity: number | null;
}): string {
const pkg = formatQuantity(input.packageQuantity, input.packageUnit);
if (input.packageUnit === input.typeUnit) return pkg;
const inType = packageInTypeUnit(input);
return inType === null ? pkg : `${pkg} · ${formatQuantity(inType, input.typeUnit)}`;
}
/** Barkod: 8–14 rakam (EAN-8, UPC-A, EAN-13, GTIN-14). */
export const BARCODE_PATTERN = /^\d{8,14}$/;
+41
View File
@@ -0,0 +1,41 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
const v = tr.validation;
const s = tr.productPrices.validation;
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
const id = (message: string) => z.string().regex(/^[a-f0-9]{24}$/i, message);
/** Elle fiyat girişi (kaynak: manual). Paket fiyatı TL; kampanyada geçerlilik sonu isteğe bağlı. */
export const productPriceFormSchema = z
.object({
productId: id(s.product),
storeId: id(s.store),
date: ymd,
price: z
.string()
.trim()
.transform((x, ctx) => {
const minor = parseMoney(x);
if (minor === null || minor <= 0) {
ctx.addIssue({ code: "custom", message: s.price });
return z.NEVER;
}
return minor;
}),
isPromo: z.boolean(),
promoUntil: z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()),
note: z.string().trim().max(300).default(""),
})
.superRefine((x, ctx) => {
if (x.isPromo && x.promoUntil && x.promoUntil < x.date)
ctx.addIssue({ code: "custom", path: ["promoUntil"], message: s.promoUntil });
})
.transform(({ price, ...x }) => ({
...x,
priceMinor: price,
promoUntil: x.isPromo ? x.promoUntil : null,
}));
export type ProductPriceFormInput = z.input<typeof productPriceFormSchema>;
export type ProductPriceForm = z.output<typeof productPriceFormSchema>;
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
import { BARCODE_PATTERN } from "../products";
import { parseQuantity } from "../quantity";
const v = tr.validation;
const s = tr.products.validation;
const quantity = (message: string) =>
z
.string()
.trim()
.transform((x, ctx) => {
const q = parseQuantity(x);
if (q === null || q <= 0) {
ctx.addIssue({ code: "custom", message });
return z.NEVER;
}
return q;
});
/**
* Ürün (marka/varyant). Dönüşümün zorunlu olup olmadığı ürün tipinin birimine bağlı olduğundan
* serviste denetlenir; burada yalnızca biçim.
*/
export const productFormSchema = z.object({
productTypeId: z.string().regex(/^[a-f0-9]{24}$/i, s.productType),
brand: z.string().trim().min(1, tr.common.required).max(60, v.nameMax),
name: z.string().trim().max(120).default(""),
barcode: z
.string()
.trim()
.transform((x) => x.replace(/\s/g, ""))
.refine((x) => x === "" || BARCODE_PATTERN.test(x), s.barcode)
.transform((x) => (x === "" ? null : x)),
packageQuantity: quantity(s.packageQuantity),
packageUnit: z.string().trim().min(1, s.packageUnit).max(20, s.packageUnit),
typeQuantity: z.preprocess(
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
quantity(s.typeQuantity).nullable(),
),
note: z.string().trim().max(300).default(""),
imageIds: z.array(z.string().regex(/^[a-f0-9]{24}$/i)).max(MAX_ATTACHMENTS_PER_RECORD),
});
export type ProductFormInput = z.input<typeof productFormSchema>;
export type ProductForm = z.output<typeof productFormSchema>;
+40
View File
@@ -118,3 +118,43 @@ export const stockCountFormSchema = z.object({
}); });
export type StockCountFormInput = z.input<typeof stockCountFormSchema>; export type StockCountFormInput = z.input<typeof stockCountFormSchema>;
export type StockCountForm = z.output<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>;
+72
View File
@@ -0,0 +1,72 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { MEAL_CARD_PROVIDERS } from "../accounts";
import { parseMoney } from "../money";
import { parseQuantity } from "../quantity";
import { STORE_KINDS } from "../stores";
const v = tr.validation;
const s = tr.stores.validation;
/** Boş → null; dolu → kuruş (≥ 0). */
const optionalMoney = z
.string()
.trim()
.transform((x, ctx) => {
if (x === "") return null;
const minor = parseMoney(x);
if (minor === null || minor < 0) {
ctx.addIssue({ code: "custom", message: v.money });
return z.NEVER;
}
return minor;
});
/** "2,5" km → 2500 m (3 ondalık = metre). Boş → null. */
const distance = z
.string()
.trim()
.transform((x, ctx) => {
if (x === "") return null;
const m = parseQuantity(x);
if (m === null || m > 1_000_000) {
ctx.addIssue({ code: "custom", message: s.distance });
return z.NEVER;
}
return m;
});
export const storeFormSchema = z
.object({
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
kind: z.enum(STORE_KINDS),
branch: z.string().trim().max(80).default(""),
distanceKm: distance,
carryDifficulty: z.coerce.number().int().min(1, s.carry).max(5, s.carry),
deliveryFee: optionalMoney,
freeDeliveryMin: optionalMoney,
mealCardAccepted: z.boolean(),
mealCardProviders: z.array(z.enum(MEAL_CARD_PROVIDERS)).max(MEAL_CARD_PROVIDERS.length),
note: z.string().trim().max(300).default(""),
})
.superRefine((x, ctx) => {
if (x.mealCardAccepted && x.mealCardProviders.length === 0)
ctx.addIssue({ code: "custom", path: ["mealCardProviders"], message: s.providers });
})
.transform((x) => {
const online = x.kind === "online";
return {
name: x.name,
kind: x.kind,
branch: x.branch,
distanceM: online ? null : x.distanceKm,
carryDifficulty: x.carryDifficulty,
deliveryFeeMinor: online ? x.deliveryFee : null,
freeDeliveryMinMinor: online ? x.freeDeliveryMin : null,
mealCardAccepted: x.mealCardAccepted,
mealCardProviders: x.mealCardAccepted ? [...new Set(x.mealCardProviders)] : [],
note: x.note,
};
});
export type StoreFormInput = z.input<typeof storeFormSchema>;
export type StoreForm = z.output<typeof storeFormSchema>;
+128
View File
@@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
countSchedule,
currentQuantity, currentQuantity,
expiringLots, expiringLots,
expiryStatus, expiryStatus,
fifoLots, fifoLots,
monthlyUsage,
quantityInput, quantityInput,
recentWaste,
roundForDisplay,
refillQuantity, refillQuantity,
stepLabel, stepLabel,
stockLevel, stockLevel,
suggestPurchase,
usageRate,
type StockMove, type StockMove,
} from "./stock"; } from "./stock";
@@ -94,8 +100,130 @@ describe("eşik ve öneri", () => {
expect(quantityInput(250)).toBe("0,25"); 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", () => { it("azaltma adımı etiketi", () => {
expect(stepLabel(1_000, "adet")).toBe("−1 adet"); expect(stepLabel(1_000, "adet")).toBe("−1 adet");
expect(stepLabel(500, "kg")).toBe("−0,5 kg"); expect(stepLabel(500, "kg")).toBe("−0,5 kg");
}); });
}); });
describe("fire oranı ve önerilen alım", () => {
const wasteful = [
m("purchase", "2026-11-01", 3_000),
m("waste", "2026-11-02", 900),
m("purchase", "2026-11-03", 3_000),
m("waste", "2026-11-04", 900),
m("consume", "2026-11-05", 4_200),
];
it("fire FIFO ile alımlara yazılır; son alımların oranı", () => {
expect(recentWaste(wasteful)).toEqual({
purchases: 2,
purchased: 6_000,
wasted: 1_800,
pct: 30,
});
// Başlangıç partisinden düşen fire alım oranına girmez.
expect(
recentWaste([
{ ...m("count_adjust", "2026-10-01", 1_000), initial: true } as StockMove,
m("waste", "2026-10-02", 500),
m("purchase", "2026-10-03", 2_000),
]),
).toMatchObject({ purchases: 1, wasted: 0, pct: 0 });
expect(recentWaste([m("consume", "2026-10-01", 1)])).toBeNull();
});
it("%30 fire öneriyi düşürür, adıma yuvarlanır", () => {
const s = suggestPurchase({
current: 0,
target: 3_000,
step: 500,
moves: wasteful,
today: "2026-11-06",
});
// 3 kg × %70 = 2,1 kg → 0,5'in katı 2 kg. Geçmiş 14 günden kısa: kullanım yok.
expect(s).toMatchObject({ base: 3_000, quantity: 2_000, roundTo: 500, usage: null });
expect(s.waste?.pct).toBe(30);
});
it("tüketim hızına (haftalık kullanımın katına) yuvarlanır, hedefi aşmaz", () => {
const moves = [
{ ...m("count_adjust", "2026-10-10", 10_000), initial: true },
m("consume", "2026-11-01", 8_400),
];
expect(usageRate(moves, "2026-11-06")).toEqual({ perDay: 300, days: 28 });
const s = suggestPurchase({
current: 1_600,
target: 6_000,
step: 500,
moves,
today: "2026-11-06",
});
// Haftalık 2,1 → 2 kg; taban 4,4 → 2 hafta = 4 kg; (1,6 + 4) / 0,3 = 18 gün.
expect(s).toMatchObject({
base: 4_400,
quantity: 4_000,
roundTo: 2_000,
roundedByUsage: true,
waste: null,
coverDays: 18,
});
// Taban küçükse en az bir adım; hedef doluysa öneri yok.
expect(
suggestPurchase({ current: 950, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
.quantity,
).toBe(100);
expect(
suggestPurchase({ current: 1_000, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
.quantity,
).toBe(0);
});
});
describe("sayım takvimi ve aylık kullanım", () => {
it("son sayımdan n gün sonra sayım gelir", () => {
expect(countSchedule(15, "2026-10-20", "2026-01-01", "2026-11-06")).toEqual({
everyDays: 15,
lastDate: "2026-10-20",
nextDate: "2026-11-04",
due: true,
overdueDays: 2,
});
expect(countSchedule(15, null, "2026-10-25", "2026-11-06")).toMatchObject({
nextDate: "2026-11-09",
due: false,
overdueDays: 0,
});
expect(countSchedule(7, "2026-10-30", "2026-10-01", "2026-11-06")).toMatchObject({
due: true,
overdueDays: 0,
});
expect(countSchedule(null, null, "2026-10-01", "2026-11-06")).toBeNull();
});
it("kullanım = tüketim + sayım eksiği; fire ayrı", () => {
expect(
monthlyUsage(
[
{ ...m("count_adjust", "2026-10-01", 5_000), initial: true },
m("consume", "2026-10-05", 1_000),
m("count_adjust", "2026-10-10", -200),
m("waste", "2026-10-12", 300),
m("count_adjust", "2026-10-15", 100),
m("consume", "2026-11-02", 500),
],
["2026-09", "2026-10", "2026-11"],
),
).toEqual([
{ month: "2026-09", used: 0, wasted: 0 },
{ month: "2026-10", used: 1_200, wasted: 300 },
{ month: "2026-11", used: 500, wasted: 0 },
]);
});
});
+219 -9
View File
@@ -1,4 +1,5 @@
import { daysBetween } from "./budget"; import { daysBetween } from "./budget";
import { addDays } from "./recurrence";
import { formatQuantity, QUANTITY_SCALE } from "./quantity"; import { formatQuantity, QUANTITY_SCALE } from "./quantity";
/** /**
@@ -58,13 +59,11 @@ export type Lot = {
remaining: number; remaining: number;
}; };
/** type TrackedLot = Lot & { kind: StockMovementKind; wasted: 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 /** FIFO çalıştırır; her girişin kalanını ve fireyle düşülen kısmını izler. */
* tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner. function runFifo(moves: readonly StockMove[]): TrackedLot[] {
*/ const lots: TrackedLot[] = [];
export function fifoLots(moves: readonly StockMove[]): Lot[] {
const lots: Lot[] = [];
let deficit = 0; let deficit = 0;
moves.forEach((m, index) => { moves.forEach((m, index) => {
const delta = signedDelta(m); const delta = signedDelta(m);
@@ -73,10 +72,12 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] {
deficit -= covered; deficit -= covered;
lots.push({ lots.push({
index, index,
kind: m.kind,
date: m.date, date: m.date,
expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null, expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null,
quantity: delta, quantity: delta,
remaining: delta - covered, remaining: delta - covered,
wasted: 0,
}); });
return; return;
} }
@@ -85,11 +86,29 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] {
if (out === 0) break; if (out === 0) break;
const take = Math.min(lot.remaining, out); const take = Math.min(lot.remaining, out);
lot.remaining -= take; lot.remaining -= take;
if (m.kind === "waste") lot.wasted += take;
out -= take; out -= take;
} }
deficit += out; 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"; 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 { export function refillQuantity(current: number, target: number): number {
return Math.max(0, target - Math.max(0, current)); 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)}`; 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). */ /** Form alanı için gruplamasız metin: 1500 → "1,5"; 1_000_000 → "1000" (parseQuantity ile uyumlu). */
export function quantityInput(quantity: number): string { export function quantityInput(quantity: number): string {
const int = Math.trunc(quantity / QUANTITY_SCALE); const int = Math.trunc(quantity / QUANTITY_SCALE);
const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, ""); const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, "");
return frac ? `${int},${frac}` : String(int); 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)!);
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { effortLevel, effortScore, formatDistance, sortStores, type StoreKind } from "./stores";
const s = (name: string, kind: StoreKind, distanceM: number | null, carryDifficulty: number) => ({
name,
branch: "",
kind,
distanceM,
carryDifficulty,
});
describe("zahmet skoru", () => {
it("uzaklık ve taşıma eşit ağırlıklı; online uzaklık saymaz", () => {
expect(effortScore(s("A", "market", 0, 1))).toBe(0);
expect(effortScore(s("A", "market", 20_000, 5))).toBe(100);
expect(effortScore(s("A", "market", 50_000, 5))).toBe(100);
expect(effortScore(s("A", "market", 2_000, 3))).toBe(30);
expect(effortScore(s("A", "bazaar", 1_000, 4))).toBe(40);
expect(effortScore(s("A", "online", 30_000, 1))).toBe(0);
expect(effortScore(s("A", "market", null, 2))).toBe(13);
expect([effortLevel(0), effortLevel(33), effortLevel(34), effortLevel(67)]).toEqual([
"low",
"low",
"medium",
"high",
]);
});
});
describe("sıralama", () => {
const list = [
s("Migros", "market", 1_200, 2),
s("Pazar", "bazaar", 600, 4),
s("Getir", "online", null, 1),
s("BİM", "market", 300, 2),
s("Metro", "market", 9_000, 3),
s("Bakkal", "market", null, 1),
];
const names = (by: Parameters<typeof sortStores>[1]) => sortStores(list, by).map((x) => x.name);
it("en yakın / en uzak: online ve uzaklığı bilinmeyenler sonda", () => {
expect(names("nearest")).toEqual(["BİM", "Pazar", "Migros", "Metro", "Bakkal", "Getir"]);
expect(names("farthest")).toEqual(["Metro", "Migros", "Pazar", "BİM", "Bakkal", "Getir"]);
});
it("en zahmetli ve ada göre (Türkçe)", () => {
expect(names("effort")).toEqual(["Metro", "Pazar", "Migros", "BİM", "Bakkal", "Getir"]);
expect(names("name")).toEqual(["Bakkal", "BİM", "Getir", "Metro", "Migros", "Pazar"]);
});
it("uzaklık gösterimi", () => {
expect(formatDistance(800)).toBe("800 m");
expect(formatDistance(2_500)).toBe("2,5 km");
expect(formatDistance(12_000)).toBe("12 km");
});
});
+79
View File
@@ -0,0 +1,79 @@
import { compareTr } from "./search";
/**
* Mağaza motoru (Bölüm 6.11). Saf fonksiyonlar.
*
* Zahmet skoru (0–100): uzaklık ve taşıma güçlüğünün eşit ağırlıklı bileşimi.
* uzaklık payı = min(uzaklık, 20 km) / 20 km × 50 (online mağazada 0: kapıya teslim)
* taşıma payı = (taşıma puanı − 1) / 4 × 50 (1 = kolay … 5 = çok zor)
* Faz 13'te akıllı öneri bu skoru maliyet cezasına çevirir.
*/
export const STORE_KINDS = ["market", "online", "bazaar"] as const;
export type StoreKind = (typeof STORE_KINDS)[number];
/** Uzaklık metre cinsinden tamsayı saklanır (2,5 km → 2500). */
export const EFFORT_MAX_DISTANCE_M = 20_000;
export const CARRY_MIN = 1;
export const CARRY_MAX = 5;
export type StoreEffortInput = {
kind: StoreKind;
/** Metre; bilinmiyorsa null. */
distanceM: number | null;
/** 1–5. */
carryDifficulty: number;
};
export function effortScore(s: StoreEffortInput): number {
const distance =
s.kind === "online" ? 0 : Math.min(Math.max(0, s.distanceM ?? 0), EFFORT_MAX_DISTANCE_M);
const carry = Math.min(CARRY_MAX, Math.max(CARRY_MIN, s.carryDifficulty));
return Math.round(
(distance / EFFORT_MAX_DISTANCE_M) * 50 + ((carry - CARRY_MIN) / (CARRY_MAX - CARRY_MIN)) * 50,
);
}
export type EffortLevel = "low" | "medium" | "high";
export function effortLevel(score: number): EffortLevel {
if (score < 34) return "low";
if (score < 67) return "medium";
return "high";
}
export const STORE_SORTS = ["name", "nearest", "farthest", "effort"] as const;
export type StoreSort = (typeof STORE_SORTS)[number];
type Sortable = StoreEffortInput & { name: string; branch: string };
/**
* Sıralama. "En yakın/en uzak" yalnızca uzaklığı bilinen fiziksel mağazaları sıralar; online ve
* uzaklığı bilinmeyenler sonda (ada göre). "En zahmetli": skor büyükten küçüğe.
*/
export function sortStores<T extends Sortable>(stores: readonly T[], by: StoreSort): T[] {
const byName = (a: T, b: T) => compareTr(a.name, b.name) || compareTr(a.branch, b.branch);
const known = (s: T) => s.kind !== "online" && s.distanceM !== null;
return [...stores].sort((a, b) => {
switch (by) {
case "name":
return byName(a, b);
case "effort":
return effortScore(b) - effortScore(a) || byName(a, b);
case "nearest":
case "farthest": {
if (known(a) !== known(b)) return known(a) ? -1 : 1;
if (!known(a)) return byName(a, b);
const d = a.distanceM! - b.distanceM!;
return (by === "nearest" ? d : -d) || byName(a, b);
}
}
});
}
/** 2500 → "2,5 km"; 800 → "800 m". */
export function formatDistance(meters: number): string {
if (meters < 1000) return `${meters} m`;
const km = meters / 1000;
return `${km.toLocaleString("tr-TR", { maximumFractionDigits: 1 })} km`;
}