Compare commits
127 Commits
v0.1.0
..
a8ad362f8b
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ad362f8b | |||
| 5793490cb4 | |||
| ebcdf3f741 | |||
| c52e8f9976 | |||
| b11972fda3 | |||
| 19536f45de | |||
| c0b114efeb | |||
| afc6da1606 | |||
| 18c892bdcd | |||
| add845684c | |||
| 092db5a8c2 | |||
| 6ce8ffa4a5 | |||
| 1bdcedefdc | |||
| 7d7dd61c60 | |||
| 42561b570a | |||
| 4b5def9452 | |||
| c3c3c43bd1 | |||
| 8623c5a02e | |||
| d9a22551e1 | |||
| 0c421a2d34 | |||
| 29d7b29cfa | |||
| bc2a8a3bde | |||
| d77a2efcb8 | |||
| 47396cba52 | |||
| 6ec29aa030 | |||
| c8678da7b5 | |||
| bd5a20ce64 | |||
| 3dd9834a3d | |||
| 55f2269f82 | |||
| fa70168622 | |||
| 8ab2244755 | |||
| 0b7de17b5c | |||
| d46869c644 | |||
| 9e32f2988d | |||
| e67a1d5411 | |||
| 78503b1787 | |||
| d52f41ab0c | |||
| f0b570284e | |||
| 8e7e374778 | |||
| 6cc698f70b | |||
| 07ba531d31 | |||
| 814bdbbc2a | |||
| 5e0f1b5901 | |||
| 868bb23fd9 | |||
| d9de29c18d | |||
| abf2ea6dd7 | |||
| 0c5d959058 | |||
| d8f442c1aa | |||
| 39aa19d168 | |||
| 21b3715d35 | |||
| b5078d9251 | |||
| a274d29728 | |||
| 62023ef35d | |||
| 3fa1e949e2 | |||
| 6de6d07c66 | |||
| 660c6d5f2d | |||
| c53fe80241 | |||
| b022c1c3b0 | |||
| 9a60085d14 | |||
| 20e5a90df4 | |||
| b8306b3b5d | |||
| a818c110cf | |||
| 890ba9f521 | |||
| baec70d324 | |||
| 7a6414df7a | |||
| 571bf7690d | |||
| 380c9b3647 | |||
| 3cd8d98324 | |||
| 9781cd9ed2 | |||
| 1062b2d96c | |||
| 015906392c | |||
| 5648eafd7c | |||
| b6c6be5c97 | |||
| 5d370a062e | |||
| dfb83d6fd4 | |||
| b18f918baf | |||
| c711eb8d28 | |||
| 6ae98a1cb4 | |||
| 0ad52757ad | |||
| cc624af69f | |||
| 57067b0fd0 | |||
| 8c4771908c | |||
| 5bd2c372bf | |||
| cfc2b2c974 | |||
| 04a17d2775 | |||
| eaafe5dd9b | |||
| 2d3a9e3a68 | |||
| 43053898b4 | |||
| 8a9f5ddc7d | |||
| 2e2e33233f | |||
| 2e1f4d7929 | |||
| 99fc9dfea6 | |||
| 00f8809823 | |||
| 3acd71fd59 | |||
| dcfa2f8c8d | |||
| 1208e2d1eb | |||
| d18c8a0d76 | |||
| a00c953dc0 | |||
| 904a969b18 | |||
| 0558d92203 | |||
| d2aa9d8ae2 | |||
| 8dd5fd42e3 | |||
| 380ddaf303 | |||
| eaf80c6bb4 | |||
| 7fda139199 | |||
| be9f2a5c3a | |||
| b79951601c | |||
| 3644e081b1 | |||
| de91f2505a | |||
| 291a117b4f | |||
| cd98694f0d | |||
| 2b648975ae | |||
| 38e2a7a26d | |||
| 3e11a34235 | |||
| cdbd7a0446 | |||
| fc3e0ae21b | |||
| 97e7ebe9aa | |||
| 2035f73e7d | |||
| 5191a5cce1 | |||
| 381214c2cb | |||
| e57e7b5517 | |||
| 9ae3eaf030 | |||
| 3b94bceaf0 | |||
| 1cf9fd9d8c | |||
| 4dbd2eb441 | |||
| c875847b65 | |||
| 4f41c0f668 |
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -10,6 +11,7 @@ next-env.d.ts
|
||||
.env*
|
||||
!.env.example
|
||||
uploads
|
||||
import
|
||||
|
||||
# Geliştirme araçları
|
||||
.git
|
||||
|
||||
@@ -12,6 +12,8 @@ BETTER_AUTH_URL="http://localhost:3000"
|
||||
# Faz 4 seed'i için ilk admin hesabı
|
||||
SEED_ADMIN_EMAIL="admin@butcem.local"
|
||||
SEED_ADMIN_PASSWORD=""
|
||||
# Örnek aile kullanıcılarının ortak şifresi (yalnızca geliştirme; production'da örnek kullanıcı oluşturulmaz)
|
||||
SEED_USER_PASSWORD=""
|
||||
|
||||
# Ek dosyalar (canlıda Docker volume: /app/uploads)
|
||||
UPLOAD_DIR="./uploads"
|
||||
|
||||
@@ -46,3 +46,7 @@ next-env.d.ts
|
||||
|
||||
# local tooling
|
||||
/scratch/
|
||||
/dist
|
||||
|
||||
# Kişisel ekstre dosyaları (içe aktarma için) — asla repoya girmez
|
||||
/import/
|
||||
|
||||
@@ -5,3 +5,4 @@ coverage
|
||||
uploads
|
||||
next-env.d.ts
|
||||
docs/PROMPT.md
|
||||
dist/
|
||||
|
||||
@@ -6,6 +6,471 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
Faz 17a: Kakeibo K2 — ekstre içe aktarma altyapısı.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- **Ekstre içe aktarma** (`/islemler/ice-aktar`, İşlemler sayfasında "İçe aktar"): Kakeibo çalışma kitabı (.xlsx; "Kredi Kartı" ve "Maaş Hesabı" sayfaları) yüklenir, önce taslak olarak önizlenir, sonra tek seferde işlenir.
|
||||
- Kurulum adımı: dosyanın ihtiyaç duyduğu hesaplar (kredi kartı, maaş hesabı, nakit, ek hesap) mevcut hesaplardan seçilir ya da önerilen açılış bakiyesiyle tek tıkla oluşturulur (açılış bakiyesi dosyadaki devreden bakiyeden, kart borcu ilk kart ödemesinden).
|
||||
- Kontroller: her ekstre için **ekstre toplamı = satır toplamı**, maaş hesabı bakiye zinciri ve son bakiye, daha önce aktarılmış satırlar, bekletilen altın satırları ve oluşturulacak kategoriler.
|
||||
- Önizleme: satır başına tür (gider / gelir / atla), kategori ve Kakeibo grubu düzenlenebilir; "aynı açıklamalı tüm satırlara uygula"; İnceleme gereken / Grupsuz / Transfer / Atlanacak süzgeçleri.
|
||||
- Muhasebe kuralları: kart ödemesi, ATM çekimi ve hesaplar arası havale transfer (çift sayma yok); kampanya iadesi ve iadeler gelir ("İade"); KMH faizi, KKDF/BSMV ve banka ücretleri banka maliyeti; taksitler ekstreye yansıdığı kesim tarihinde, ortak taksit planıyla; aynı gün çıkan promosyon çifti atlanır.
|
||||
- Tekrar engeli: her satırın parmak izi saklanır; aynı dosya yeniden yüklendiğinde aktarılmış satırlar atlanır. **Toplu geri alma** aktarmanın tüm işlemlerini siler ve dosya yeniden aktarılabilir hale gelir.
|
||||
- Eksik Kakeibo kategorileri (Sigorta, Online alışveriş, Elektronik ve hobi, Kitap ve kültür; Prim, Yol parası, Avans, Eş katkısı, Aile desteği) işlenirken oluşturulur.
|
||||
- Migration `017-imports`: aktarma partileri ve işlem parmak izi dizinleri (geri alınabilir).
|
||||
- Testler: çalışma kitabı ayrıştırma, taksit metni, ekstre ve bakiye kontrolleri (birim); taslak, hesap oluşturma, toplu düzenleme, işleme, bakiyeler, tekrar yükleme ve geri alma (entegrasyon).
|
||||
|
||||
### Bilinen sınırlamalar
|
||||
|
||||
- Altın alım/satım satırları Faz 17b'de altın hesabı dosyasıyla eşleştirilene dek bekletilir; o zamana dek maaş hesabı bakiyesi bu satırların neti kadar farklı görünür.
|
||||
- Yapı Kredi "Güncel Dönem" (.xls) ve Ek Hesap / Altın Hesabı (PDF) dosyaları Faz 17b'de.
|
||||
|
||||
## [1.1.0] - 2026-11-07
|
||||
|
||||
Faz 16: Kakeibo K1 — dört harcama grubu.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Kakeibo grupları: gider kategorilerine **Zorunlu / İstek / Kültür / Beklenmeyen** grubu; alt kategori grubu üst kategoriden devralır. Kategori formunda grup seçimi ve açıklaması; kategori listesinde grup rozeti (devralınan soluk) ve grupsuz kategori uyarısı.
|
||||
- **Banka maliyeti:** KMH faizi, KKDF/BSMV, kart ve hesap ücretleri Kakeibo harcamasına ve dört gruba girmez, ayrı gösterilir (kategori düzeyinde işaret, alt ağaç devralır).
|
||||
- İşlem formunda **Kakeibo grubu**: varsayılan kategoriden ("Kategoriden: Zorunlu"), bu kayıt için değiştirilebilir; bölünmüş işlemde kalem başına.
|
||||
- Kakeibo raporu (`/raporlar/kakeibo`): Kakeibo harcaması ve grup payları (yığılmış çubuk), grup başına tutar, pay, önceki aya göre değişim ve kategori kırılımı; banka maliyeti ve grupsuz harcamalar ayrı; CSV. Bütçe sayfasında seçili ayın Kakeibo özeti.
|
||||
- Migration `016-kakeibo-groups`: varsayılan kategoriler beceri eşlemesine göre doldurulur (kullanıcı kategorileri grupsuz kalır, mevcut seçimlere dokunulmaz, kategori silinmez). Migration'lar geri alınabilir: `npm run migrate -- --down <ad>`.
|
||||
- Testler: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti ve sıfıra bölme (birim); varsayılan gruplar, rapor toplamı = bütçenin gerçekleşeni, grup değiştirme, kategori güncelleme, migration geri alma/yeniden uygulama (entegrasyon).
|
||||
|
||||
### Güvenlik
|
||||
|
||||
- `import/` klasörü (kişisel ekstre dosyaları) git'e ve Docker imajına girmez.
|
||||
|
||||
## [1.0.0] - 2026-11-07
|
||||
|
||||
Faz 15: canlıya alma (Dokploy + Gitea, `butcem.ta1sph.website`).
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Konteyner başlatma adımı (`docker-entrypoint.sh` → `scripts/bootstrap.mjs`): MongoDB replica set ve PRIMARY kontrolü (yoksa açık hata mesajıyla durur), bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin hesabı (varsa dokunulmaz). esbuild ile tek dosyaya derlenir; imajda `tsx` gerekmez. Acil durumda `SKIP_BOOTSTRAP=1`.
|
||||
- `/api/health` yanıtında `replicaSet` alanı (transaction desteğinin canlıda doğrulanması için).
|
||||
- `docs/DEPLOY.md`: Dokploy uygulama kurulumu, ortam değişkenleri, volume, domain/HTTPS, Gitea webhook ile otomatik deploy, güncelleme/geri alma, yedekleme, geri yükleme ve sorun giderme; her komut açıklamalı.
|
||||
- `docs/MONGODB-KURULUM.md`: mongo:7 servisinde replica set kontrolü, keyfile ile tek düğümlü replica set'e geçiş, uygulama kullanıcısı yetkileri, bağlantı dizesi, transaction denemesi.
|
||||
- `scripts/smoke-test.sh`: sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS, oturumsuz erişim engeli ve isteğe bağlı giriş kontrolü; veri değiştirmez.
|
||||
- `scripts/backup/`: günlük yedek betiği (mongodump + uploads arşivi, SHA-256 özetleri, 7 gün saklama, parola komut satırında görünmez), onaylı geri yükleme betiği ve ayar örneği.
|
||||
|
||||
### Değişti
|
||||
|
||||
- Docker imajı: `ENTRYPOINT` başlatma adımıyla; sağlık kontrolü başlangıç payı 60 sn.
|
||||
- Uygulama mongo:7.0 ile uyumlu (tüm entegrasyon testleri mongo:7 replica set'e karşı geçti).
|
||||
|
||||
## [0.14.0] - 2026-11-07
|
||||
|
||||
Faz 14: dashboard ana grafiği ve raporlar (14a ve 14b alt fazları).
|
||||
|
||||
### Eklendi — Faz 14b: cari, birikim, alışveriş, birim fiyat ve fire raporları
|
||||
|
||||
- Cari ve aynî borçlar (`/raporlar/cari`): bakiyesi olan cariler (bana borçlu / ben borçluyum, vade durumu), açık aynî borç ve alacaklar kalan miktar ve güncel TL değeriyle; toplamlar.
|
||||
- Birikim kâr/zarar (`/raporlar/birikim`): varlık başına miktar, maliyet, güncel değer, gerçekleşmemiş ve gerçekleşen K/Z; son 3/6/12 ay veya tümü için dönem özeti (başlangıç değeri, yatırılan, satıştan alınan, bitiş değeri, dönem kârı).
|
||||
- Alışveriş (`/raporlar/alisveris`): tamamlanan listelerde planlanan / gerçekleşen / fark, eşleşme sayıları ve farkın nedenleri; mağaza bazlı harcama (alıcısı mağaza adıyla başlayan giderler, şubeler birlikte, "Alışverişi tamamla"dan gelen kısım ayrıca).
|
||||
- Birim fiyat geçmişi (`/raporlar/birim-fiyat`): ürün tipi seçimi, son 12 bütçe ayında aylık en düşük ve ortalama birim fiyat (grafik ve tablo), mağazalarda dönemin en düşük birim fiyatı.
|
||||
- Fire / zarar (`/raporlar/fire`): ürün tipi başına fire, alım, fire/alım oranı, nedenlere göre dağılım ve tahmini maliyet (fire × son bilinen birim fiyat).
|
||||
- Hepsinde CSV dışa aktarma; rapor ana sayfasında yeni rapor kartları.
|
||||
- Testler: mağaza adı eşleşmesi ve aylık birim fiyat özeti (birim); cari/aynî, birikim, alışveriş (tamamlanan liste dahil), birim fiyat ve fire raporları (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 14a: dashboard ana grafiği ve finans raporları
|
||||
|
||||
- Dashboard ana çizgi grafiği: Planlanan gelir (yeşil), Planlanan gider / bütçe (mavi, kesikli), Gerçekleşen gider (kırmızı). Periyotlar: Haftalık (bu hafta, günlük), Aylık (bütçe ayı, günlük), Altı aylık ve Yıllık (bütçe ayları). Kümülatif / dönemsel görünüm; seçim URL'de (`?periyot=…&gorunum=…`). Bütçesi olmayan dönemde bütçe çizgisi boş, gelecek günlerde gerçekleşen çizilmez; grafiğin altında tablo görünümü.
|
||||
- Raporlar (`/raporlar`): bu bütçe ayının özeti (gelir, gider, net, tasarruf oranı, en çok harcanan kategoriler) ve rapor sayfaları; her raporda ay seçici ve CSV dışa aktarma (Türkçe Excel uyumlu).
|
||||
- Kategori dağılımı: halka grafik ve tablo; üst kategori toplamları ve alt kategori kırılımı, toplam gidere oran (%14,3 gibi), önceki aya göre tutar ve yüzde değişim. Dilim renkleri kategorinin kendi rengi; 7'den fazla kategori "Diğer"de toplanır.
|
||||
- Aylık trend: son 12 bütçe ayı gelir ve gider sütunları, ayrı grafikte tasarruf oranı (devam eden ay grafikte gösterilmez), net ve 12 ay toplamı.
|
||||
- Kişi ve hesap bazlı: gideri kaydeden üyeye göre harcama ve pay; hesap başına gelir, gider, gelen/giden transfer ve net değişim. Başka üyelerin "yalnızca ben" hesapları hesap tablosunda hiç görünmez.
|
||||
- Bütçe ve gerçekleşen: kategori bazında limit, gerçekleşen, kalan ve kullanım; son 6 ayın limit/gerçekleşen karşılaştırması.
|
||||
- Kurallar bütçeyle aynı: bütçe ayı, gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve gizli hesap giderleri hane ayarına göre.
|
||||
- Testler: periyot aralıkları ve kümülatif dönüşüm, kategori payı/değişim/tasarruf oranı ve alt kategori kırılımı (birim); rapor toplamlarının bütçeyle tutarlılığı, gizli hesap görünürlüğü, dashboard periyotları (entegrasyon).
|
||||
|
||||
### Değişti — Faz 14a
|
||||
|
||||
- Dashboard'daki günlük plan kartı istatistik ve tabloya indi; kümülatif ay grafiği ana grafiğin "Aylık" görünümüne taşındı.
|
||||
- Bütçe, gelir planı ve günlük plan hesapları bağlam alan yardımcılara ayrıldı (raporlarda birden çok ay için yeniden kullanılır).
|
||||
|
||||
## [0.13.0] - 2026-11-06
|
||||
|
||||
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
|
||||
|
||||
### Eklendi — Faz 13b: alışveriş sonrası giriş, karşılaştırma ve "Alışverişi tamamla"
|
||||
|
||||
- Alışveriş sonrası giriş (`/alisveris/:id/tamamla`): liste kalemleri tek tek işaretlenir; her biri için alınan ürün (aynı ya da aynı ürün tipinden muadil; ürün tipi kaleminde marka belirtmeden de), mağaza, paket adedi ve ödenen tutar. Tutar, bilinen paket fiyatıyla ön doldurulur. Listede olmayan ekstra ürünler eklenebilir. Mağaza başına ödeme hesabı (yemek kartı geçen mağazada uygun yemek kartı önseçili) ve fiş görselleri.
|
||||
- "Alışverişi tamamla" tek MongoDB transaction'ı: stok alım hareketleri, fiyat kayıtları (kaynak `receipt`; ürün × mağaza başına, paket fiyatı = tutar / paket), mağaza başına bölünmüş gider işlemi (kalemler stok kategorisinin eşlendiği gider kategorisine; eşlenmemiş ve serbest metin kalemleri seçilen yedek kategoriye), fiş görselleri işlemin eki, alınmayanların taşınması ve listenin kapanması. Herhangi bir adım başarısızsa hiçbir şey yazılmaz.
|
||||
- Alınmayanlar: başka aktif listeye taşı, yeni liste aç ya da bırak.
|
||||
- Karşılaştırma raporu (tamamlanan listede): planlanan (tamamlama anındaki akıllı öneri) vs. gerçekleşen tutar ve fark; farkın nedenleri (aynı üründe fiyat farkı, miktar farkı, muadil, alınmayanlar, plansız ekstralar, planda fiyatı olmayanlar; toplamları farka eşit); kalem bazında Aynı / Muadil / Alınmadı / Plansız ekstra; mağaza giderleri ve kullanılan hesaplar. Etiketler tamamlama anındaki hâliyle saklanır.
|
||||
- Liste sayfasında ve alışveriş modunda "Alışverişi tamamla" düğmesi.
|
||||
- Stok hareketleri ve işlemlerde `shoppingListId` (kaynağı izlemek için).
|
||||
- Testler: eşleşme ve fark ayrıştırması (birim); tamamlama ekranı verisi, doğrulamalar (muadil tipi, gelecek tarih, izleyici), ek dosya hatasında geri alma, stok/fiyat/mağaza başına bölünmüş gider, markasız kalem, kalanların taşınması ve rapor (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 13a: alışveriş listeleri, stok önerileri, akıllı mağaza önerisi, alışveriş modu
|
||||
|
||||
- Alışveriş listeleri (`shoppingLists`, `/alisveris`): birden çok aktif liste; kalem olarak ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. Miktar ürün tipinde tipin biriminde, üründe paket adedi; boşsa en az bir paket. Aynı ürün listede iki kez olamaz; kalem düzenleme, çıkarma ve başka aktif listeye taşıma. Liste silme soft delete ve denetim kaydı. Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve 30 saniyede bir yenilenir.
|
||||
- Stok eşiği önerileri: eşiğin altındaki ürün tipleri hiçbir aktif listede yoksa liste sayfasında onayla/reddet önerisi olarak gelir (fire ayarlı önerilen miktar ve nedeni). Onaylanan önerilen miktarla eklenir ("Stok önerisi" rozeti), "Tümünü ekle" ile toplu. Reddedilen öneri, ürün için yeni alım girilene (stok yenilenene) kadar gösterilmez.
|
||||
- Akıllı mağaza önerisi (saf fonksiyon, birim testli): son bilinen fiyatlarla her kalem için mağaza ve marka; ihtiyacı karşılayan paket sayısı × paket fiyatı. Fiziksel mağaza ziyareti ve hacimli kalemler için zahmet skoruna bağlı maliyet cezası, online teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı önceliği (kartın geçtiği mağazada kartla ödenebilecek tutar avantajlı sayılır; bakiye mağazalar arasında bir kez harcanır). En az mağazalı eksiksiz plandan başlanır; ek mağaza ancak tasarruf hane ayarındaki eşiği aşarsa eklenir. Çıktı: "Tek mağaza en iyi seçenek: X" ya da "Önerilen dağılım: A (7 ürün), B (3 ürün): Y (Z tasarruf)"; önerilen ve tek mağaza dağılımları, fiyatı bilinmeyen kalemler.
|
||||
- Alışveriş modu (`/alisveris/:id/mod`): plana göre mağazaya gruplanmış, büyük dokunma alanlı kontrol listesi; "sepete alındı" işareti anında görünür ve kaydedilir, ilerleme çubuğu; önerilen / tek mağaza planı arasında geçiş.
|
||||
- Ürün tipine "Ağır / hacimli" işareti (akıllı öneride taşıma cezası).
|
||||
- Dashboard'da "Alışveriş" kartı (aktif listeler ve bekleyen öneri sayısı); stok sayfasındaki "Alınması önerilenler" alışveriş listelerine bağlanır. Kenar menüde "Alışveriş".
|
||||
- Migration `015-shopping-lists`. Seed: "Haftalık market" (9 kalem: tip, marka, serbest metin, fiyatı olmayan domates) ve "Ay sonu toptan" listeleri; deterjan, yumuşatıcı, çamaşır suyu ve tuvalet kağıdı hacimli.
|
||||
- Testler: planlayıcı (paket yuvarlama, marka kısıtı, eşik, eksik kapsama, zahmet ve hacimli cezası, teslimat, yemek kartı), miktar ve öneri reddi kuralları (birim); liste/kalem işlemleri, yetki, öneri onay/red, plan çıktısı, silme ve denetim (entegrasyon).
|
||||
|
||||
### Değişti — Faz 13a
|
||||
|
||||
- Fiyat servisinde ürün tipi teklifleri ortak yükleyicide (`loadTypeOffers`); tüketim analizindeki son birim fiyat da bunu kullanır.
|
||||
|
||||
## [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
|
||||
|
||||
- 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`.
|
||||
- Ürün tipleri (`productTypes`): kategori, temel ölçü birimi (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik yüzdesi (varsayılan %20), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki başlangıç miktarı.
|
||||
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı SKT), tüketim, fire/zarar (nedenli) ve sayım düzeltmesi (gerçek miktar girilir, fark işlenir). Mevcut miktar hareketlerden türetilir; stokta olandan fazla tüketim/fire reddedilir. Hareket silme denetim kaydına yazılır.
|
||||
- SKT takibi FIFO partileriyle: çıkışlar en eski girişten düşer; evde kalan miktarın SKT'si 7 gün içinde dolan veya geçmiş partiler uyarılır.
|
||||
- `/stok`: özet, "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT uyarıları, kategoriye göre ürün listesi (mevcut/hedef çubuğu ve eşik çizgisi, arama, "yalnızca azalanlar"), tek dokunuşla "1 azalt". `/stok/:id`: partiler ve hareket geçmişi.
|
||||
- Dashboard "Azalan stoklar" kartı.
|
||||
- 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).
|
||||
|
||||
## [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ı).
|
||||
|
||||
### Eklendi — Faz 10b: gelir planı ve günlük plan
|
||||
|
||||
- Gelir planı (`/butce` altında): gerçekleşen gelirler, henüz gerçekleşmemiş planlı (düzenli) gelirler ve elle eklenen beklenen gelirler (`budgets.incomeItems`, ör. prim); beklenen toplam. "Plana göre elde kalacak" (beklenen gelir − bütçe) ve "Bu tempoda elde kalacak" (beklenen gelir − ay sonu tahmini gider).
|
||||
- Günlük plan (dashboard): ayın bütçesi günlere yayılır; tarihe bağlı giderler (düzenli işlemler, taksitler) kendi günlerine, kalan değişken bütçe günlere eşit veya hane ayarına göre önceki ayın günlük örüntüsüyle (kuruşu kuruşuna toplam korunur). "Bugün ayın %X'i geçti, bütçenin %Y'si harcandı", bugün için harcanabilir tutar (önceki günlerden kalan/aşan dahil), bugüne kadar planlanan ve harcanan, ay sonunda elde kalacak.
|
||||
- Kümülatif planlanan / gerçekleşen gider grafiği, beklenen gelir referans çizgisi ve gün tablosu; sonraki ayın beklenen geliri ile bütçesi karşılaştırması.
|
||||
- Dashboard özet kartları: "Bu ay gelir", "Bu ay gider" ve "Kalan" gerçek veriyle (bütçe ayına göre).
|
||||
- Testler: dağıtım ve kuruş yuvarlama, örüntü uyarlama, günlük plan ve bugünkü pay, taban hızlı tahmin (birim); gelir planı, gizli hesap ayarı, elle kalemler, yetki, günlük plan tutarlılığı ve örüntü modu (entegrasyon).
|
||||
|
||||
### Değişti — Faz 10b
|
||||
|
||||
- Ay sonu tahmini: değişken giderin günlük hızı, bu ayın hızı ile önceki dönemin değişken harcama hızının karışımı (ay ilerledikçe bu ayın ağırlığı artar); ay başındaki tek bir büyük harcama artık tüm aya yayılmıyor. Geçmişi olmayan hanede yalnızca bu ayın hızı.
|
||||
|
||||
### Düzeltildi — Faz 10b
|
||||
|
||||
- Bütçede "sabit gider" (düzenli işlem / taksit) ayrımı her işlemi sabit sayıyordu (`installment` boşken alan yolu eksik değer verir ve `null`'a eşit sayılmaz); ay sonu tahmini değişken gideri hiç yansıtmıyordu. `$ifNull` ile düzeltildi, regresyon testi eklendi.
|
||||
|
||||
### Eklendi — Faz 10a: aylık bütçe
|
||||
|
||||
- Bütçe sayfası (`/butce?ay=YYYY-MM`): ay gezintisi, dönem sınırları hane ayarı `budgetMonthStartDay`'e göre (ör. 15'i → 15 Kasım – 14 Aralık).
|
||||
- Kategori limitleri (`budgets`): her gider kategorisine limit; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır ve toplam bütçede iki kez sayılmaz. Limitsiz alt kategoriler "Üst kategori limitinde" olarak gösterilir; hiçbir limitin kapsamadığı harcama "Bütçesiz harcama" toplamında.
|
||||
- Gerçekleşen / limit / kalan, yüzde çubuğu (ayın geçen kısmı işaretli), %80'de uyarı, %100'de "Limit doldu", aşımda "Limit aşıldı".
|
||||
- Ay sonu tahmini: sabit giderler (düzenli işlem, taksit) olduğu gibi + kalan planlı giderler + değişken giderin günlük hızıyla ay sonuna yansıtılması; tahmin limiti aşıyorsa işaret.
|
||||
- Bölünmüş işlemler kalem bazında; taksitler hane ayarına göre her biri kendi ayına (varsayılan) veya tamamı alış ayına. Başkasının "yalnızca ben" hesabından giderler hane ayarına göre yalnızca tutar olarak sayılır.
|
||||
- "Önceki aydan kopyala" ve "Önceki ayın harcamalarından oluştur" (ana kategori bazında, 100 TL'ye yukarı yuvarlanır); mevcut limitler varsa onay istenir.
|
||||
- Limit düzenleyici: tüm etkin gider kategorileri ağaç sırasıyla, anlık toplam bütçe.
|
||||
- Dashboard: %80'i geçilen veya aşılan kategori sayısıyla uygulama içi bütçe uyarısı.
|
||||
- Seed: Çakır Ailesi'nde önceki ve bu ay için harcamalardan oluşturulmuş bütçeler (bu ay Market limiti düşük, Elektrik alt limiti).
|
||||
- `010` migration'ı: `budgets` index'i (hane × ay benzersiz).
|
||||
- Testler: dönem sınırları, eşikler, tahmin, üst/alt limit toplama, bütçesiz harcama, sıra (birim); limitler, bölünmüş işlem, planlı, gizli hesap ayarı, taksit modları, başlangıç günü, kopyala/oluştur, yetki (entegrasyon).
|
||||
|
||||
## [0.9.0] - 2026-11-06
|
||||
|
||||
Faz 9: birikim ve varlık takibi, fiyat tablosu (elle), kâr/zarar, net varlık (9a ve 9b alt fazları).
|
||||
|
||||
### Eklendi — Faz 9b: dönemsel rapor ve net varlık
|
||||
|
||||
- Dönemsel rapor (`/birikim` toplam, `/birikim/:id` varlık bazında): 3 ay / 6 ay / 12 ay / tümü (`?donem=`); dönem başı değer, yatırılan TL, dönem sonu değer, satış geliri, gelen/çıkan hediye ve dönem kâr/zararı (TL ve %, dönemde bağlanan sermayeye oranla). Formül sayfada yazılıdır.
|
||||
- Aylık değer grafiği (shadcn charts / Recharts): güncel değer (düz çizgi) ve maliyet (kesikli, basamaklı), lejant, ipucu ve tablo görünümü. Fiyatı olmayan varlık toplam rapora katılmaz ve belirtilir.
|
||||
- Net varlık (`/birikim` üstünde): hesap bakiyeleri + birikim (alış fiyatı) + cari alacaklar + aynî alacaklar (alış) − kart/KMH borçları − cari borçlar − aynî borçlar (satış). Gizli varlık ve cariler yalnızca sahibinin net varlığına girer; gizli hesap bakiyeleri hane ayarına göre.
|
||||
- Dashboard: "Toplam hesap bakiyesi", "Kart borçları" ve "Net varlık" kartları gerçek veriyle (gelir/gider kartları Faz 14).
|
||||
- Aynî kayıt ↔ varlık bağlantısı: "Ödeme gerçekleşti → Aynı varlıkla" diyaloğunda isteğe bağlı kutucuk. Borç ödemesi seçilen varlıktan ortalama maliyetle düşer; alacak tahsilatı varlığa borç tarihindeki birim fiyatla eklenir. Ödeme ve varlık hareketi tek transaction'da; varlık yetersizse hiçbir şey yazılmaz. Bağlı hareket Birikim'den tek başına silinemez; aynî kayıt silinince bağ çözülür.
|
||||
- Fiyat okuma yardımcıları ayrı modülde (`features/in-kind/prices.ts`).
|
||||
- Testler: dönem noktaları, örnek senaryo ("10 gr'ı 25.000'e aldım, her ay ekledim"), başlangıç değeri, fiyat yokluğu, toplam (birim); dönem raporu, gizli varlık ve fiyatsız varlık, net varlık formülü ve değişmezleri (makas, cari borç), aynî borç/alacak bağlantısı, yetersiz varlıkta geri alma, tür uyuşmazlığı (entegrasyon).
|
||||
|
||||
### Değişti — Faz 9b
|
||||
|
||||
- "Aynı varlıkla" alacak tahsilatında anlamsız olan "varlığı almak için harcanan" tutar alanı kaldırıldı (yalnızca borç ödemesinde gösterilir).
|
||||
- `recharts` bağımlılığı ve `components/ui/chart.tsx` eklendi.
|
||||
- CHANGELOG sürüm karşılaştırma bağlantıları 0.6.0–0.9.0 için tamamlandı.
|
||||
|
||||
### Eklendi — Faz 9a: varlık lotları ve kâr/zarar
|
||||
|
||||
- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir.
|
||||
- Varlık hareketleri (`assetMovements`): alım, gelen hediye/miras, satış, hediye/çıkış; tarih, miktar, tutar veya birim fiyat (ikisi de boşsa fiyat tablosundan: alımda satış, satış ve hediyede alış fiyatı), hesap, saklama yeri, not. Geçmiş tarihli satış/çıkış o tarihteki miktarla sınırlıdır.
|
||||
- Hesap bağlantısı: alım hesaptan varlığa, satış varlıktan hesaba transfer olarak kaydedilir (bakiye değişir, gelir/gider raporlarına girmez). İşlem listesinde varlık adıyla görünür; bağlı işlem tek başına düzenlenemez/silinemez, hareket silinince birlikte silinir.
|
||||
- Hesap motoru (`shared/portfolio.ts`): ağırlıklı ortalama maliyet, satışta ortalama maliyetle gerçekleşmiş K/Z, çıkışta K/Z yazılmaz, gelen hediyenin maliyeti o günkü değer (yatırım sayılmaz); güncel değer alış fiyatıyla (Faz 0), gerçekleşmemiş K/Z TL ve %.
|
||||
- `/birikim/:id`: özet (eldeki miktar, ortalama maliyet, maliyet, güncel değer, K/Z, yatırılan, satış geliri), hareket geçmişi (kalan miktar, satış K/Z'si), hareket silme (sonraki çıkışı eksiye düşürecekse reddedilir).
|
||||
- "Yalnızca ben" varlık: varlık, hareketleri ve bağlı hesap işlemleri yalnızca sahibine görünür.
|
||||
- Seed: aylık alımlı ve bir satışlı gram altın birikimi, düğün ziynetleri (hediye giriş ve çıkış), Emre'nin "yalnızca ben" dolar varlığı, Annemler'de fiyatsız bilezikler.
|
||||
- `009` migration'ı: `assets`, `assetMovements` index'leri, işlemlerde `assetId`. Denetim kaydı: `asset.delete`, `asset.movement.delete`.
|
||||
- Testler: ortalama maliyet, satış/çıkış, hediye, eksi miktar, oran biçimi (birim); alım/satış transferleri ve bakiye, fiyat tablosundan tutar, gerçekleşmiş K/Z, kilitli işlem, hareket silme, gizli varlık, izleyici ve başka hane (entegrasyon).
|
||||
|
||||
## [0.8.0] - 2026-11-05
|
||||
|
||||
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
|
||||
|
||||
### Eklendi — Faz 8b: aynî borç/alacak ve fiyat tablosu
|
||||
|
||||
- Varlık türleri: gram altın, çeyrek, yarım, tam, Cumhuriyet altını, 22 ayar bilezik, gümüş, USD, EUR, GBP (birim: gr / adet / birim; miktarlar binde bir hassasiyetle).
|
||||
- Fiyat tablosu (`/fiyatlar`, `assetPrices`): varlık × tarih × alış/satış, elle giriş; tek fiyat girilirse ikisine de yazılır; gün başına tek kayıt; son fiyatlar ve geçmiş. Faz 9 varlık takibi aynı tabloyu kullanacak.
|
||||
- Aynî borç/alacak (`inKindDebts`), cari sayfasında: cari, yön (ben borçluyum / bana borçlu), varlık, miktar, borç tarihi; o tarihteki birim fiyat ve TL karşılığı not olarak saklanır (boşsa fiyat tablosundan). Değerleme: borç satış, alacak alış fiyatıyla.
|
||||
- Borca bağlı birikim planı: birikim hesabı ve aylık tutar; "Birikim ekle" kaynak hesaptan birikim hesabına transfer oluşturur, borç kapanmaz. Kartta güncel değer, biriken, kalan ihtiyaç ve tahmini kapanış ayı.
|
||||
- "Ödeme gerçekleşti": aynı varlıkla veya TL karşılığıyla; tarih, miktar/tutar, hesap. TL ödemede miktar boşsa güncel fiyattan hesaplanır; kısmi ödeme miktarı düşürür, kalanı aşan ödeme kalana sınırlanır, tam ödemede kayıt kapanır. Borç ödemesi hesaptan çıkış, alacak tahsilatı hesaba giriş işlemi olarak aynı transaction'da oluşturulur; denetim kaydı (`inkind.payment`, `inkind.close`).
|
||||
- Cari listesinde açık aynî kayıtlar rozet olarak.
|
||||
- Aynî borç hareketleri cari TL bakiyesine ve gelir/gider toplamlarına girmez; "yalnızca ben" carinin aynî kayıtları ve hareketleri başkasına görünmez (hesap ekstresinde ad ve açıklama gizlenir). Ödeme işlemi tek başına silinemez.
|
||||
- Seed: son 6 ayın örnek altın/döviz fiyatları (gerçek piyasa verisi değildir), birikim planlı 5 gr altın borcu (iki katkıyla), USD alacağı, Annemler'de çeyrek altın alacağı.
|
||||
- `008` migration'ı: `assetPrices`, `inKindDebts` index'leri, işlemlerde aynî borç alanları.
|
||||
- Testler: değer/miktar karşılığı, değerleme fiyatı, ilerleme ve tahmini kapanış (birim); fiyat tablosu, tarih fiyatı, birikim katkısı, kısmi/tam ödeme, kapanış, alacak tahsilatı, cari bakiye ve gelir/gider ayrımı, gizli cari ve yetki (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 8a: cari hesaplar
|
||||
|
||||
- Cari kişiler (`contacts`): ad, yakınlık, telefon, not, açılış bakiyesi (+ bana borçlu / − ben borçluyum), vade tarihi ve hatırlatma günü; arşivleme; aynı adda iki aktif cari engellenir (Türkçe normalize).
|
||||
- "Yalnızca ben" cari: kişi ve hareketleri yalnızca sahibine görünür; ortak hesabın ekstresinde bu hareketler görünür ama kişi adı "Gizli cari" olarak maskelenir.
|
||||
- Cari hareketleri: "Borç verdim", "Borç ödedim" (hesaptan cariye), "Borç aldım", "Tahsilat" (cariden hesaba). Hepsi transferdir: hesap bakiyesi değişir, gelir/gider toplamlarına girmez. Kısmi ödeme bakiyeyle doğal olarak desteklenir.
|
||||
- `/cari`: bana borçlu / ben borçluyum / net özeti, cari listesi (bakiye, son hareket, vade rozeti), yeni cari, hareket ekle, düzenle, arşivle.
|
||||
- `/cari/:id`: cari ekstresi (tarih sıralı hareketler ve yürüyen bakiye, açılış satırı); hareketi düzenleme ve geri alınabilir silme.
|
||||
- Vade uyarısı (uygulama içi): vade geçtiyse veya hatırlatma penceresindeyse cari listesinde rozet ve dashboard'da uyarı.
|
||||
- İşlem listesi ve ayrıntısında cari hareketleri kişi adı ve hareket türüyle; ayrıntıdan cariye bağlantı. CSV'de kategori sütununda "Cari: Ad (tür)".
|
||||
- Seed: 5 örnek cari (vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş alacak, "yalnızca ben" cari, Annemler hanesinde bir borç).
|
||||
- `007` migration'ı: `contacts` index'leri, işlemlerde cari alanları. Denetim kaydı: cari arşivleme.
|
||||
- Testler: yön/bakiye ve vade durumu (birim); defter, bakiye ve gelir/gider ayrımı, açılış bakiyesi, ekstre, tür değiştirme, vade uyarısı, çift ad, arşiv, yetki, başka hanenin hesabı ve gizli cari izolasyonu (entegrasyon).
|
||||
|
||||
### Değişti — Faz 8a
|
||||
|
||||
- İşlemlerde kaynak hesap (`accountId`) artık boş olabilir: cariden girişte para yalnızca hedef hesaba (`toAccountId`) girer. Bakiye, ekstre, kart ve KMH hesapları ek kural olmadan doğru çalışır.
|
||||
|
||||
## [0.7.0] - 2026-11-05
|
||||
|
||||
Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları).
|
||||
|
||||
### Eklendi — Faz 7b: kart ve KMH dönemleri, taksitler
|
||||
|
||||
- Dönem hesabı (`shared/card-periods.ts`): kesim günü dahil "Hesap Kesim Dönemi", sonrası "Güncel Dönem"; kesim günü ayda yoksa ayın son günü; kesim günü bitmeden dönem kapanmış sayılmaz. Son ödeme: kesimden N gün sonra veya ayın sabit günü.
|
||||
- Kart özeti (`/hesaplar/:id`, kredi kartı): toplam borç, kullanılabilir limit (gelecek taksitler dahil düşülür) ve kullanım çubuğu; hesap kesim dönemi borcu, asgari ödeme, ödenen, kalan, son ödeme tarihi ve kalan gün; güncel dönem harcaması; dönem dönem yaklaşan taksitler; planlı harcamalar; son 6 ekstre.
|
||||
- Asgari ödeme: limit 50.000 TL altı %20, üstü %40; kart bazında oran girilebilir (hesap formu).
|
||||
- "Borcu öde": ödeme hesabından karta transfer formu kalan dönem borcuyla dolu açılır; ödemeler dönem borcundan düşer (kısmi ödeme desteklenir).
|
||||
- Taksitli harcama: kredi kartı giderinde taksit sayısı (2–36). Her taksit ayrı işlem ("Hepsiburada 4/9"); toplam kuruş kaybı olmadan bölünür; vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler planlıdır ve bakiyeyi etkilemez. Ayrıntıdan "Kalan taksitleri iptal et".
|
||||
- KMH özeti (KMH'li banka hesabı): limit, kullanılan, kullanılabilir; geçen ve bu dönem için gün sonu eksi bakiyelerden tahmini faiz; "Faiz kaydı ekle" kesim tarihli, "Banka › Faiz" kategorili gider formunu doldurur (vergiler hariç not).
|
||||
- `statements` koleksiyonu: kart/KMH dönem kayıtları (borç, asgari, ödenen, harcama, tahmini faiz); kart özeti okunurken işlemlerden yeniden hesaplanır.
|
||||
- İşlem formuna ön doldurma desteği (kart ödemesi, faiz kaydı).
|
||||
- Seed: iki taksitli kart alışverişi.
|
||||
- `006` migration'ı: `statements` index'leri, taksit planı index'i.
|
||||
- Testler: dönem/son ödeme/asgari/taksit planı/KMH faizi (birim); kart özeti tutarlılığı (bağımsız bakiye hesabıyla), ödeme etkisi, taksit üretimi ve otomatik gerçekleşme, taksit iptali, taksit kuralları, KMH faiz tahmini ve yetki (entegrasyon).
|
||||
|
||||
### Düzeltildi — Faz 7b
|
||||
|
||||
- Seed: aynı aydaki satıcısız/kategorisiz transferler aynı `seedRef`'i aldığı için kart borcu ödemesi ve aylık birikim transferleri hiç oluşmuyordu; artık oluşuyor (mevcut kayıtlar kopyalanmaz). Kart ödemesi son ödeme gününden önceye alındı.
|
||||
|
||||
### Eklendi — Faz 7a: planlı ve düzenli işlemler
|
||||
|
||||
- Düzenli işlem kuralları (`recurringRules`): gelir, gider, transfer; sabit veya tahmini tutar; günlük/haftalık/aylık/yıllık, "her N birimde bir", ayın N. günü (ayda yoksa son gün) veya ayın son günü; bitiş yok, tekrar sayısı (ör. 12) veya bitiş tarihi. Formda Türkçe tekrar açıklaması ve ilk tarihlerin önizlemesi.
|
||||
- Her tekrar için `planned` durumunda planlı kayıt; bitiş tarihsiz kurallarda 12 ay ileriye kadar. Ufuk sayfa okunurken ilerletilir (zamanlanmış görev gerekmez); üretim idempotent (`recurringRuleId + occurrenceIndex` benzersiz). Planlı kayıtlar bakiyeyi etkilemez.
|
||||
- `/planli`: vadesi geçmiş, yaklaşan (60 gün) ve kurallar sekmeleri; "Onayla" (tutar ve tarih düzeltilebilir), "Ertele", "Atla"; seri pozisyonu ("3/12") ve "Tahmini" rozetleri.
|
||||
- Seri düzenleme: "Yalnızca bu" (kayıt seriden ayrılır), "Bu ve sonrakiler" (seri bölünür), "Tümü" (onaylanmamış planlı kayıtlar yeniden üretilir; gerçekleşen, atlanan ve ayrılmış kayıtlar korunur). Silmede "bu ve sonrakiler" / "tüm seri"; gerçekleşmiş kayıtlar silinmez.
|
||||
- Dashboard'da vadesi geçmiş planlı işlem uyarısı.
|
||||
- Seed: 11 örnek düzenli işlem (maaş, kira, aidat, tahmini faturalar, abonelikler, emekli maaşı; vadesi geçmiş örnek olarak 12 taksitli spor salonu).
|
||||
- `005` migration'ı: `recurringRules` index'leri, işlemlerde tekrar alanları. Denetim kaydı: `recurring.delete`.
|
||||
- Testler: tekrar tarihi hesabı (ay sonu, artık yıl, aralık, sayı/bitiş/ufuk) ve form şeması (birim); üretim, onay/atla/ertele, seri düzenleme ve silme, bakiye etkisizliği, yetki ve gizli hesap izolasyonu (entegrasyon).
|
||||
|
||||
### Değişti — Faz 7a
|
||||
|
||||
- İşlem listesi varsayılan olarak planlı kayıtları göstermez (durum filtresiyle görülebilir); özet toplamları seçili duruma göre hesaplanır.
|
||||
|
||||
## [0.6.0] - 2026-11-05
|
||||
|
||||
Faz 6: hesaplar, kategoriler, işlemler, ek dosyalar ve mutabakat (6a, 6b, 6c alt fazları).
|
||||
|
||||
### Eklendi — Faz 6c: ek dosyalar, carousel, ekstre ve mutabakat
|
||||
|
||||
- Depolama soyutlaması `lib/storage/` (`put`, `get`, `delete`); yerel disk sürücüsü (`UPLOAD_DIR`), atomik yazma, rastgele anahtarlar, yol geçişine karşı anahtar doğrulaması.
|
||||
- Ek dosya yükleme `POST /api/files`: tür dosya içeriğinden (imza) tespit edilir — JPEG, PNG, WebP, HEIC, PDF; uzantıya güvenilmez. Dosya başına `MAX_UPLOAD_MB` (10 MB), kayıt başına 10 dosya. Origin kontrolü.
|
||||
- Görseller sharp ile EXIF yönüne göre döndürülür, en fazla 2000 px'e küçültülür, WebP'ye çevrilir; EXIF/GPS dahil tüm meta veri silinir; 320 px küçük resim üretilir. iPhone HEIC dosyaları libheif (WASM, `heic-decode`) ile çözülür. PDF'ler yapı kontrolünden sonra olduğu gibi saklanır.
|
||||
- Yetkili servis `GET /api/files/:id` (`?v=thumb`, `&indir=1`): oturum + hane üyeliği; bekleyen ek yalnızca yükleyene; silinmiş işlemin veya başkasının gizli hesabındaki işlemin eki görünmez (404). Dosyalara özel sıkı CSP, `nosniff`, `private` önbellek.
|
||||
- `attachments` koleksiyonu (`ownerType`, `ownerId`, tür, boyut, yükleyen, sıra). Ekler işlemle aynı MongoDB transaction'ında bağlanır; hata olursa işlem de oluşmaz. Gider: yalnızca görsel; gelir ve transfer: görsel veya PDF.
|
||||
- İşlem formunda küçük "Ek ekle" alanı (çoklu seçim, yükleme göstergesi, küçük önizleme, kaldırma, sayaç); yüklemeler bitmeden kaydedilemez. Kopyada ekler taşınmaz.
|
||||
- Görüntüleyici: shadcn Carousel (Embla) — oklar, kaydırma, klavye okları, tam ekran, "2 / 5" göstergesi, indirme; PDF'ler ikon + "Aç" düğmesiyle. Ayrıntıda en fazla 4 küçük resim + "+N".
|
||||
- Temizlik `npm run cleanup`: 24 saatten eski bağlanmamış ekler, 30 günden önce kayıttan çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri (dosyalarıyla) silinir.
|
||||
- Hesap ekstresi `/hesaplar/:id?ay=YYYY-MM`: ay başı/ay sonu bakiyesi, giriş/çıkış, yürüyen bakiye, mutabakat işaretleri; işlem ayrıntısı ekstreden açılır. Başkasının gizli hesabına transfer ekstrede görünür ama karşı hesap adı gizlenir.
|
||||
- Mutabakat: ekstre tarihi ve bakiyesi girilir, uygulama bakiyesiyle karşılaştırılır (kredi kartında borç pozitif girilir). Fark varsa onayla "Mutabakat düzeltmesi" işlemi oluşturulur; o tarihe kadarki işlemler hesap bazında işaretlenir; `reconciliations` kaydı ve denetim kaydı (`account.reconcile`) tek transaction'da.
|
||||
- `004` migration'ı: `attachments` ve `reconciliations` index'leri; işlemlerde `reconciledAccountIds` ve `isAdjustment`.
|
||||
- Testler: dosya türü tespiti, depolama, ekstre yardımcıları (birim); yükleme/dönüştürme (EXIF silme, yön, boyut, HEIC), bağlama kuralları, erişim izolasyonu, temizlik, ekstre tutarlılığı, mutabakat (entegrasyon).
|
||||
|
||||
### Değişti — Faz 6c
|
||||
|
||||
- Hesap adı artık hesap ekstresini açar (gizli hesaplarda bağlantı yok).
|
||||
- Bakiyeyi etkileyen alanı (tutar, tarih, tür, hesap) değiştirilen işlemin mutabakat işareti kalkar. Mutabakat düzeltmesi düzenlenemez, yalnızca silinebilir.
|
||||
- İşlemlerdeki kullanılmayan `reconciledAt` alanı, hesap bazında `reconciledAccountIds` ile değiştirildi.
|
||||
|
||||
### Düzeltildi
|
||||
|
||||
- `seed:reset` işlemleri ve satıcıları silmiyordu; artık işlemler, satıcılar, ekler (diskteki dosyalarıyla) ve mutabakatlar da temizlenir.
|
||||
|
||||
### Eklendi — Faz 6b: işlemler
|
||||
|
||||
- İşlemler (`/islemler`): gelir, gider ve transfer (hesap ↔ hesap). Tutar her zaman pozitif; işaret türden gelir. Hesap bakiyeleri artık açılış bakiyesi + gerçekleşmiş işlemlerden hesaplanır (tek doğruluk kaynağı: işlemler).
|
||||
- Bölünmüş işlem: en az 2 kalem, kalemler toplamı = tutar (istemci ve sunucuda aynı zod şeması); kalan tutar göstergesi, "kalanı son kaleme ekle"; listede tek satır, ayrıntıda kalemler.
|
||||
- Akıllı giriş: satıcı önerileri (Türkçe normalize arama, kullanım sıklığı) ve satıcının son kullanılan kategori/hesabı; işlemi kopyalama.
|
||||
- Liste: güne göre gruplama ve günlük net, gelir/gider/net özetleri, sayfalama; filtreler (tarih aralığı İstanbul gününe göre, tür, durum, hesap — transfer hedefi dahil, kategori — alt kategoriler ve bölünmüş kalemler dahil, ekleyen kişi, tutar aralığı, etiket, metin arama); filtreler URL'de.
|
||||
- İşlem ayrıntısı, düzenleme ve yumuşak silme; silme bildiriminde "Geri al".
|
||||
- CSV dışa aktarma (`/islemler/disa-aktar`): listedeki filtrelerle, bölünmüş işlem kalem başına satır; Türkçe Excel uyumlu (noktalı virgül, ondalık virgül, UTF-8 BOM), formül enjeksiyonu koruması.
|
||||
- Hızlı ekle her ekrandan doğrudan işlem formunu açar (gider/gelir/transfer sekmeleri).
|
||||
- Gizlilik: başkasının "yalnızca ben" hesabına dokunan işlemler listede, ayrıntıda ve CSV'de görünmez; o hesaba yalnızca sahibi işlem girer.
|
||||
- Seed: son 6 ayın 221 gerçekçi işlemi (faker.seed(42); maaş, kira, faturalar — kışın doğalgaz yüksek, market — bir kısmı bölünmüş, yakıt, kafe, kart ödemesi, birikim, nakit çekme), satıcı kuralları; idempotent (`seedRef`).
|
||||
- `003` migration'ı: `transactions` ve `payees` index'leri. Denetim kaydı: işlem silme/geri alma.
|
||||
- Testler: bakiye tutarlılığı (bağımsız doğrulama dahil), izolasyon, gizli hesap, arşivli kategori, filtreler, satıcı kuralları, CSV.
|
||||
|
||||
### Değişti
|
||||
|
||||
- `npm run typecheck` önce route tiplerini üretir (`next typegen`).
|
||||
|
||||
### Eklendi — Faz 6a: para, hesaplar ve kategoriler
|
||||
|
||||
- `shared/money.ts`: Türkçe tutar ayrıştırma (`"1.250,50"` → 125050 kuruş; binlik/ondalık kuralları belgeli), `formatMoneyInput`, `sumMoney`, kuruş kaybı olmadan oransal bölme (`allocate`, `splitEvenly`).
|
||||
- `shared/quantity.ts`: miktarlar birimin binde biri tamsayı (`1,5 kg` → 1500); ayrıştırma, biçimlendirme, kg↔g / lt↔ml dönüşümü.
|
||||
- `shared/dates.ts`: İstanbul günü yardımcıları (gün başlangıcı, UTC → yerel gün).
|
||||
- Hesaplar (`/hesaplar`): nakit, vadesiz banka (KMH limiti, aylık faiz, kesim günü), birikim, kredi kartı (limit, kesim günü, son ödeme kuralı, ödeme hesabı), yemek kartı (sağlayıcı); hesap sahibi kişi ve "yalnızca ben" (bakiyeyi yalnızca hesap sahibi görür, toplamlar hane ayarına uyar); renk/ikon, sıralama, arşiv. Varlık / kart borcu / net özet.
|
||||
- Kategoriler (`/kategoriler`): gelir ve gider ağaçları (5 seviyeye kadar), ekleme, düzenleme, taşıma (alt ağaçla, transaction), sıralama, arşivleme (alt ağaçla); kardeşler arasında ad tekrarı engeli (Türkçe normalize).
|
||||
- Varsayılan Türkçe kategori seti (44 kategori): yeni hanede aynı transaction'da, mevcut hanelere `002` migration'ıyla, idempotent (`defaultKey`).
|
||||
- Kategori ağacı saf fonksiyonları (`buildTree`, `toOptions`, `checkMove`, `rollupTotals` …) ve birim testleri.
|
||||
- Renk paleti (13 renk, açık/koyu tema değişkenleri) ve ikon kayıt tablosu; ikon/renk seçiciler, `MoneyInput`.
|
||||
- Seed: varsayılan kategoriler ve 7 örnek hesap (KMH'li banka, kredi kartı, birikim, "yalnızca ben" yemek kartı).
|
||||
- Denetim kaydı: hesap oluşturma/düzenleme/arşiv, kategori taşıma/arşiv.
|
||||
- Entegrasyon testleri: kategori ve hesap izolasyonu, roller, gizli bakiye, kart kuralları, ağaç kuralları.
|
||||
|
||||
## [0.5.0] - 2026-11-05
|
||||
|
||||
Faz 5: Haneler, hane içi roller ve veri izolasyonu.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Hane içi roller (`viewer < member < owner`) ve kuralları: son sahip düşürülemez veya çıkarılamaz.
|
||||
- DAL: `getHouseholdContext` (istek başına tek okuma), `requireActiveHousehold(minRole)`, `getHouseholdActionContext(minRole)`, `requireHouseholdMember(householdId, minRole)`. Sistem admini için hane verisinde ayrıcalık yok.
|
||||
- Hane servisi (`features/households/service.ts`, framework'ten bağımsız): her hane kapsamlı fonksiyon üyeliği kendisi de doğrular; hane oluşturma + ilk sahip + denetim kaydı tek MongoDB transaction'ında.
|
||||
- Aktif hane seçici (üst bar): seçim sunucuda doğrulanır, `butcem.hh` cookie'sine yazılır; geçersiz/uydurma cookie yok sayılır. Tercih yoksa en yüksek rolün olduğu hane açılır.
|
||||
- `/ayarlar`: hane adı, bütçe ayı başlangıç günü, taksit modu, günlük plan dağıtımı, "yalnızca ben" hesaplarının toplamlara dahil edilmesi, ek mağaza tasarruf eşiği; üye rolleri ve üye çıkarma (yalnızca sahip; diğer roller salt okunur).
|
||||
- `/admin/haneler`: hane oluşturma (ilk sahip seçerek), kullanıcıyı haneye ekleme, hane içi rol değiştirme, üye çıkarma.
|
||||
- `/hane-yok`: hiçbir haneye üye olmayan kullanıcılar için yönlendirme sayfası.
|
||||
- Denetim kaydı (`auditLogs`): hane ayarları, üye ekleme/rol/çıkarma, hane oluşturma ve tüm admin kullanıcı işlemleri (oluşturma, düzenleme, rol, şifre sıfırlama, askı, silme).
|
||||
- Migration mekanizması: `npm run migrate`, `scripts/migrations/NNN-*.ts`, uygulananlar `migrations` koleksiyonunda; `001-initial-indexes`. Seed önce migration'ları çalıştırır.
|
||||
- Veri izolasyonu entegrasyon testleri (başka hanenin üyeliği/ayrıntısı/ayarları, rol yetersizliği, uydurma ve enjeksiyon denemesi kimlikler, hanesi olmayan admin, çapraz hane üyelik değişikliği, son sahip, transaction).
|
||||
|
||||
### Değişti
|
||||
|
||||
- İzleyici (viewer) rolünde Hızlı ekle gizlenir/pasifleşir.
|
||||
- Kullanıcı silindiğinde tüm hane üyelikleri de temizlenir.
|
||||
|
||||
### Kaldırıldı
|
||||
|
||||
- Hane mock verisi.
|
||||
|
||||
## [0.4.0] - 2026-11-05
|
||||
|
||||
Faz 4: Örnek test verisi.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Seed (`npm run seed`): admin + 7 örnek kullanıcı (biri askıda, biri hanesi olmayan ikinci admin, ikisi e-postasız ve kullanıcı adıyla giriş yapan), 2 hane ("Çakır Ailesi", "Annemler") ve hane içi roller (owner / member / viewer; bir kullanıcı iki hanede farklı rollerle).
|
||||
- Seed idempotenttir (kullanıcı adı, hane adı ve üyelik çiftine göre); kullanıcılar Better Auth API'siyle oluşturulur. Geliştirme hesapları konsolda tablo olarak gösterilir.
|
||||
- `npm run seed:reset`: kimlik doğrulama ve hane koleksiyonlarını temizleyip yeniden doldurur; production'da reddedilir. Production'da seed yalnızca admini oluşturur.
|
||||
- Mongoose modelleri: `households` (ayarlar: bütçe ayı başlangıcı, taksit ve günlük plan tercihleri, özel hesaplar, ek mağaza eşiği) ve `householdMembers` (benzersiz `{householdId, userId}` index'i).
|
||||
- `SEED_USER_PASSWORD` ortam değişkeni (örnek kullanıcıların ortak geliştirme şifresi).
|
||||
- Seed entegrasyon testleri (idempotentlik, roller, askı, e-postasız giriş, reset).
|
||||
|
||||
## [0.3.0] - 2026-11-05
|
||||
|
||||
Faz 3: Backend — kimlik doğrulama, yetki katmanı ve güvenlik.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Better Auth (MongoDB adaptörü, replica set transaction): e-posta veya **kullanıcı adı** ile giriş, kayıt (sign-up) kapalı, admin plugin, 30 günlük oturum, `HttpOnly`/`Secure`/`SameSite=Lax` cookie.
|
||||
- Rate limit (veritabanında): genel 100 istek/dk, giriş denemeleri 5/dk.
|
||||
- Data Access Layer (`lib/dal.ts`): `getCurrentUser` (istek başına tek okuma), `requireSession`, `requireRole`, `getActionUser`. Askıdaki kullanıcının oturumu reddedilir.
|
||||
- `proxy.ts`: iyimser oturum cookie kontrolü (oturumsuz → `/login`); asıl kontrol DAL'da.
|
||||
- Admin kullanıcı yönetimi Server Action'ları: oluşturma, düzenleme, rol, şifre sıfırlama (diğer oturumları kapatır), askıya alma/kaldırma, silme. Kendini silememe/askıya alamama ve son admini düşürememe sunucuda da uygulanır; Better Auth hataları Türkçe alan hatalarına çevrilir.
|
||||
- E-postası olmayan kullanıcılar için `.invalid` yer tutucu adres (arayüzde gizlenir).
|
||||
- MongoDB (`lib/db/mongo.ts`) ve Mongoose (`lib/db/mongoose.ts`) bağlantıları (HMR güvenli tekil örnek, tembel oluşturma).
|
||||
- Güvenlik başlıkları: CSP (nonce'suz, Cache Components uyumlu), `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`; production'da HSTS.
|
||||
- `GET /api/health` veritabanı ping'i; erişilemezse 503.
|
||||
- `npm run seed`: ilk admini (`SEED_ADMIN_*`, kullanıcı adı `admin`) idempotent olarak oluşturur.
|
||||
- `npm run test:integration`: gerçek MongoDB'de (`butcem_test`) kimlik doğrulama sözleşmesi testleri (kayıt kapalı, giriş, askı, admin yetkisi, rate limit).
|
||||
- BÜTÇEM logosu ve ikon seti (`logo.svg`, `logo-dark.svg`, `icon.svg`, `icon-maskable.svg`); `npm run icons` SVG kaynaktan üretir.
|
||||
|
||||
### Değişti
|
||||
|
||||
- Giriş, çıkış, dashboard ve admin sayfaları mock yerine gerçek oturum ve verilerle çalışır; oturuma bağlı kabuk Suspense içinde akış olarak gelir.
|
||||
|
||||
### Düzeltildi
|
||||
|
||||
- Kullanıcı adı normalizasyonu: `tr-TR` küçük harf dönüşümü "ADMIN"i "admın" yapıyordu; artık İ/I/ı → i ve ASCII küçük harf.
|
||||
|
||||
### Kaldırıldı
|
||||
|
||||
- Oturum ve admin kullanıcı listesi mock verileri (hane mock'u Faz 5'e kadar duruyor).
|
||||
|
||||
## [0.2.0] - 2026-11-05
|
||||
|
||||
Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock).
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Marka paleti: mavi/beyaz oklch tema (açık), lacivert/açık mavi (koyu); anlamsal `--income`, `--expense`, `--budget`, `--warning` değişkenleri ve grafik renkleri.
|
||||
- Açık / Koyu / Sistem tema (next-themes) ve her ekranda tema seçici.
|
||||
- Giriş ekranı (`/login`): kullanıcı adı veya e-posta + şifre, zod doğrulama, şifre göster/gizle, yükleniyor durumu; kayıt bağlantısı yok. `/` her zaman `/login`'e yönlenir.
|
||||
- Uygulama kabuğu: masaüstünde daraltılabilir yan menü, mobilde alt gezinme çubuğu (ortada Hızlı ekle, "Daha fazla" ile menü), üst bar (hane seçici, Hızlı ekle, tema, kullanıcı menüsü: ad, rol, sürüm, Çıkış yap). Henüz gelmeyen sayfalar "Yakında" olarak pasif.
|
||||
- Hızlı ekle (gider / gelir / transfer) seçimi; mobilde alttan açılan Drawer, masaüstünde Dialog (`ResponsiveDialog`).
|
||||
- Dashboard: karşılama ve özet kartları (Suspense + Skeleton).
|
||||
- Admin kullanıcı yönetimi (`/admin/kullanicilar`): TanStack Table v9 ile Türkçe duyarlı arama, sıralama, sayfalama; oluşturma, düzenleme, rol değiştirme, şifre sıfırlama, askıya alma/kaldırma, silme (onaylı). Kendini silememe ve son admini düşürememe kuralları (`checkAdminAction`).
|
||||
- Paylaşılan zod şemaları (`src/shared/schemas`: giriş, kullanıcı, şifre, askı), Türkçe arama/sıralama (`foldTr`, `compareTr`), TL ve İstanbul saatli tarih biçimleri (`formatMoney`, `formatDate`).
|
||||
- PWA temeli: `manifest.webmanifest`, tema rengi, Apple web app meta, ikonlar.
|
||||
- `npm run icons`: `public/brand/icon.png`'den favicon.ico, uygulama, Apple ve maskable PWA ikonları üretir (sharp).
|
||||
- Yer tutucu marka dosyaları (`public/brand/logo.svg`, `logo-dark.svg`, `icon.png`).
|
||||
- 404, hata (`retry`) ve global hata sayfaları; yükleniyor ekranları.
|
||||
- `ActionResult` sonuç tipi (Faz 3 Server Action sözleşmesi).
|
||||
|
||||
### Değişti
|
||||
|
||||
- `use-mobile` hook'u `useSyncExternalStore` ile yeniden yazıldı (effect içinde setState yok, hydration güvenli).
|
||||
- ESLint: `_` önekli bilinçli kullanılmayan değişkenlere izin.
|
||||
|
||||
## [0.1.0] - 2026-11-05
|
||||
|
||||
Faz 1: Framework, klasör yapısı ve altyapı.
|
||||
@@ -25,5 +490,16 @@ Faz 1: Framework, klasör yapısı ve altyapı.
|
||||
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
||||
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
||||
|
||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.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.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.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
|
||||
[0.6.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı).
|
||||
# Build: docker build --build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||
# Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem
|
||||
# Başlarken: replica set kontrolü, bekleyen migration'lar, ilk admin (docker-entrypoint.sh).
|
||||
|
||||
ARG NODE_VERSION=26
|
||||
|
||||
@@ -10,6 +11,8 @@ FROM node:${NODE_VERSION}-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json package-lock.json ./
|
||||
# SheetJS resmi dağıtımdan vendor/ altında (npm uzak tarball indirmez).
|
||||
COPY vendor ./vendor
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# 2) Build
|
||||
@@ -22,7 +25,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
APP_COMMIT=${APP_COMMIT}
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# Uygulama + başlatma betiği (migration ve ilk admin; tek dosya, tsx gerekmez).
|
||||
RUN npm run build && npm run build:scripts
|
||||
|
||||
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
|
||||
FROM node:${NODE_VERSION}-alpine AS runner
|
||||
@@ -43,12 +47,15 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dist/bootstrap.mjs ./scripts/bootstrap.mjs
|
||||
COPY --chown=nextjs:nodejs --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
VOLUME ["/app/uploads"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
# start-period: ilk deploy'da migration'lar için pay.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -26,22 +26,56 @@ cp .env.example .env.local
|
||||
# 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler
|
||||
npm run db:up
|
||||
|
||||
# 4) Uygulama
|
||||
npm run dev # http://localhost:3000
|
||||
# 4) Örnek veri: admin + aile kullanıcıları + 2 hane (tekrar çalıştırmak güvenli)
|
||||
npm run seed
|
||||
|
||||
# 5) Uygulama
|
||||
npm run dev # http://localhost:3000 → /login
|
||||
```
|
||||
|
||||
### Geliştirme hesapları (`npm run seed`)
|
||||
|
||||
| Kullanıcı adı | Rol | Hane(ler) | Not |
|
||||
| ------------- | ----- | --------------------------------------- | ------------------------------- |
|
||||
| `admin` | admin | Çakır Ailesi (owner) | şifre `SEED_ADMIN_PASSWORD` |
|
||||
| `ayse` | user | Çakır Ailesi (owner), Annemler (viewer) | |
|
||||
| `emre` | user | Çakır Ailesi (member) | e-postasız |
|
||||
| `zeynep` | user | Çakır Ailesi (viewer) | e-postasız |
|
||||
| `fatma` | user | Annemler (owner) | |
|
||||
| `mehmet` | user | Annemler (member) | |
|
||||
| `burak` | admin | — | hanesi yok → hane verisi görmez |
|
||||
| `ozan` | user | Çakır Ailesi (member) | askıda, giriş yapamaz |
|
||||
|
||||
Admin dışındaki hesapların şifresi `SEED_USER_PASSWORD` (`.env.local`). Production'da yalnızca admin oluşturulur.
|
||||
|
||||
Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kullanıcıları admin `/admin/kullanicilar` sayfasından oluşturur.
|
||||
|
||||
## Scriptler
|
||||
|
||||
| Komut | Açıklama |
|
||||
| ----------------------------- | -------------------------------- |
|
||||
| `npm run dev` | Geliştirme sunucusu |
|
||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest birim testleri |
|
||||
| `npm run format` | Prettier |
|
||||
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
||||
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
||||
| Komut | Açıklama |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `npm run dev` | Geliştirme sunucusu |
|
||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest birim testleri |
|
||||
| `npm run format` | Prettier |
|
||||
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
||||
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
||||
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
||||
| `npm run migrate` | Bekleyen migration'lar |
|
||||
| `npm run bootstrap` | Canlı başlatma adımı (replica set kontrolü + migration; production'da ilk admin) |
|
||||
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||
|
||||
## Haneler ve yetki
|
||||
|
||||
- Tüm alan verisi bir **haneye** (`householdId`) aittir; kullanıcı yalnızca üyesi olduğu hanelerin verisini görür. Sistem admini de bu kurala tabidir.
|
||||
- Hane içi roller: **sahip** (ayarlar, üyeler), **üye** (veri girer), **izleyici** (yalnızca görüntüler).
|
||||
- Her sayfa, Server Action ve API, hane yetkisini `src/lib/dal.ts` üzerinden sunucuda doğrular; servisler de üyeliği ayrıca kontrol eder.
|
||||
|
||||
## Marka dosyaları
|
||||
|
||||
`public/brand/`: `logo.svg` (açık tema), `logo-dark.svg` (koyu tema), `icon.svg` (uygulama ikonu), `icon-maskable.svg` (Android/iOS, köşesiz). Yazı Noto Sans Bold (OFL) harf yollarıdır. İkonları değiştirdikten sonra `npm run icons` çalıştırın; favicon ve PWA ikonları bu kaynaklardan üretilir.
|
||||
|
||||
## Docker imajı
|
||||
|
||||
@@ -50,4 +84,13 @@ docker build --build-arg APP_VERSION=$(node -p "require('./package.json').versio
|
||||
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||
```
|
||||
|
||||
Canlı ortam (Dokploy, `butcem.ta1sph.website`) kurulumu Faz 15'te `docs/DEPLOY.md` içinde anlatılacak.
|
||||
Konteyner başlarken `docker-entrypoint.sh` önce başlatma adımını (`scripts/bootstrap.mjs`: replica
|
||||
set kontrolü, bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin) sonra uygulamayı çalıştırır.
|
||||
|
||||
## Canlı ortam
|
||||
|
||||
- Kurulum, ortam değişkenleri, domain, volume, otomatik deploy, yedekleme ve geri yükleme:
|
||||
[`docs/DEPLOY.md`](docs/DEPLOY.md)
|
||||
- MongoDB replica set ve uygulama kullanıcısı: [`docs/MONGODB-KURULUM.md`](docs/MONGODB-KURULUM.md)
|
||||
- Duman testi: `scripts/smoke-test.sh https://butcem.ta1sph.website`
|
||||
- Yedek / geri yükleme betikleri: `scripts/backup/`
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# BÜTÇEM konteyner girişi: önce başlatma (replica set kontrolü, migration'lar, ilk admin),
|
||||
# başarılıysa uygulama. Başlatma başarısız olursa konteyner durur; Dokploy günlüğünde sebep görünür.
|
||||
# Acil durumda atlamak için: SKIP_BOOTSTRAP=1
|
||||
set -e
|
||||
if [ "${SKIP_BOOTSTRAP:-0}" != "1" ]; then
|
||||
node /app/scripts/bootstrap.mjs
|
||||
fi
|
||||
exec node /app/server.js
|
||||
@@ -0,0 +1,254 @@
|
||||
# Canlıya alma (Dokploy + Gitea)
|
||||
|
||||
BÜTÇEM'i Atlas sunucusunda Dokploy ile `https://butcem.ta1sph.website` adresinde yayınlamak,
|
||||
güncellemek, yedeklemek ve geri yüklemek için adım adım kılavuz.
|
||||
|
||||
**Mimari:** Gitea (`main` dalı) → webhook → Dokploy, Dockerfile ile imaj build eder → konteyner
|
||||
başlarken veritabanını hazırlar (replica set kontrolü, migration'lar, ilk admin) → Next.js
|
||||
uygulaması 3000 portunda → Traefik HTTPS ile dışarı açar. MongoDB aynı Dokploy ağında, dışa portsuz.
|
||||
Yüklenen dosyalar `/app/uploads` volume'unda.
|
||||
|
||||
> Sunucu komutları Atlas'ta çalıştırılır. Her komutun altında ne yaptığı yazıyor. `<…>` ile
|
||||
> gösterilen yerleri kendi değerlerinizle değiştirin.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ön koşullar
|
||||
|
||||
- [ ] MongoDB servisi **replica set** olarak çalışıyor ve `butcem_app` kullanıcısı hazır →
|
||||
[`MONGODB-KURULUM.md`](MONGODB-KURULUM.md) (1–5. adımlar).
|
||||
- [ ] Gitea'da BÜTÇEM reposu (`main` dalı güncel; `git push origin main`).
|
||||
- [ ] DNS: `butcem.ta1sph.website` için Atlas'ın genel IP'sine **A kaydı** (ve gerekiyorsa
|
||||
modemde 80/443 yönlendirmesi Traefik'e).
|
||||
|
||||
```bash
|
||||
dig +short butcem.ta1sph.website
|
||||
```
|
||||
|
||||
Alan adının çözüldüğü IP'yi gösterir; Atlas'ın genel IP'si olmalı.
|
||||
|
||||
## 2. Dokploy'da uygulama
|
||||
|
||||
Dokploy › proje › **Create Service › Application**, ad: `butcem`.
|
||||
|
||||
### 2.1 Kaynak (General)
|
||||
|
||||
- **Provider:** Gitea (Dokploy › Settings › Git'te Gitea bağlantısı tanımlıysa) ya da **Git**
|
||||
(repo URL'si + Dokploy'un SSH anahtarı Gitea'da deploy key olarak ekli).
|
||||
- **Repository / Branch:** BÜTÇEM reposu, `main`.
|
||||
- **Build Type:** `Dockerfile` (yol: `Dockerfile`, context: `.`).
|
||||
- **Build Args** (isteğe bağlı): `APP_COMMIT=<kısa-commit>` — sağlık kontrolünde görünür; boşsa
|
||||
`unknown` yazar. Sürüm `package.json`'dan otomatik gelir.
|
||||
|
||||
### 2.2 Ortam değişkenleri (Environment)
|
||||
|
||||
| Değişken | Değer | Not |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `MONGODB_URI` | `mongodb://butcem_app:<parola>@<iç-host>:27017/butcem?authSource=<admin\|butcem>&replicaSet=rs0&directConnection=true` | Ayrıntı: MONGODB-KURULUM.md › 5 |
|
||||
| `BETTER_AUTH_SECRET` | 32+ karakter rastgele | Aşağıdaki komutla üretin; **değiştirirseniz tüm oturumlar kapanır** |
|
||||
| `BETTER_AUTH_URL` | `https://butcem.ta1sph.website` | Sonunda `/` yok |
|
||||
| `SEED_ADMIN_EMAIL` | yönetici e-postası | İlk açılışta admin oluşturulur |
|
||||
| `SEED_ADMIN_PASSWORD` | güçlü parola (8+) | İlk girişten sonra parolayı değiştirip bu değişkeni silebilirsiniz |
|
||||
| `MAX_UPLOAD_MB` | `10` | İsteğe bağlı |
|
||||
|
||||
`NODE_ENV=production`, `PORT=3000` ve `UPLOAD_DIR=/app/uploads` imajda tanımlıdır; eklemeyin.
|
||||
`SEED_USER_PASSWORD` canlıda kullanılmaz (örnek kullanıcı oluşturulmaz).
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
`BETTER_AUTH_SECRET` için 32 baytlık rastgele bir değer üretir.
|
||||
|
||||
### 2.3 Volume (Advanced › Volumes / Mounts)
|
||||
|
||||
- **Volume Mount** · ad: `butcem-uploads` · bağlama yolu: **`/app/uploads`**.
|
||||
Fiş, ürün görseli ve ek dosyalar burada tutulur; imaj yeniden oluşturulduğunda kaybolmaz.
|
||||
(Bind mount yerine adlandırılmış volume kullanın; imajdaki dizin izinleri — uid 1001 — volume'a
|
||||
ilk oluşturulurken aktarılır.)
|
||||
|
||||
### 2.4 Domain (Domains)
|
||||
|
||||
- **Host:** `butcem.ta1sph.website` · **Path:** `/` · **Container Port:** `3000`
|
||||
- **HTTPS:** açık · **Certificate:** Let's Encrypt
|
||||
|
||||
### 2.5 İlk deploy
|
||||
|
||||
**Deploy**'a basın ve **Logs**'ta şu satırları bekleyin:
|
||||
|
||||
```
|
||||
• MongoDB rs0 PRIMARY
|
||||
✓ Migration: 001-initial-indexes — … (ilk kurulumda 15 migration)
|
||||
✓ Admin oluşturuldu: <e-posta> (kullanıcı adı: admin)
|
||||
✓ Ready in …
|
||||
```
|
||||
|
||||
"MongoDB replica set olarak çalışmıyor" hatası görürseniz MONGODB-KURULUM.md › 3'ü uygulayın.
|
||||
Konteynerin Docker sağlık durumu birkaç saniye içinde **healthy** olur (`/api/health`).
|
||||
|
||||
### 2.6 İlk giriş
|
||||
|
||||
`https://butcem.ta1sph.website` › kullanıcı adı **`admin`** ve `SEED_ADMIN_PASSWORD`.
|
||||
|
||||
1. **Yönetim › Kullanıcılar**'dan aile üyelerini oluşturun.
|
||||
2. **Yönetim › Haneler**'den haneyi açıp üyeleri ve rollerini ekleyin.
|
||||
3. (Önerilir) Admin parolasını **Yönetim › Kullanıcılar › kendi satırınız › Şifre sıfırla** ile
|
||||
değiştirip Dokploy'da `SEED_ADMIN_PASSWORD`'u silin ve yeniden deploy edin; başlatma artık
|
||||
yalnızca migration'ları çalıştırır. (Admin hesabı varken bu değişken parolayı değiştirmez.)
|
||||
|
||||
## 3. Duman testi (smoke test)
|
||||
|
||||
Kendi bilgisayarınızda, repo klasöründe:
|
||||
|
||||
```bash
|
||||
scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
```
|
||||
|
||||
Sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS yönlendirmesi ve
|
||||
oturumsuz erişimin engellendiğini kontrol eder; **veri değiştirmez**. Tümü ✓ olmalı.
|
||||
|
||||
```bash
|
||||
SMOKE_USER=admin SMOKE_PASSWORD='<parola>' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
```
|
||||
|
||||
Ayrıca giriş yapıp ana sayfanın açıldığını dener (parola komut geçmişine düşer; sonra
|
||||
`history -d` ile silebilir ya da satırı boşlukla başlatabilirsiniz).
|
||||
|
||||
Elle kontrol listesi (bir kez, tarayıcıdan):
|
||||
|
||||
- [ ] Giriş / çıkış, telefondan PWA olarak ana ekrana ekleme.
|
||||
- [ ] Bir hesap ve bir gider işlemi oluşturma (transaction'lı yazma).
|
||||
- [ ] İşleme fiş fotoğrafı ekleyip görüntüleme (uploads volume).
|
||||
- [ ] Bir transfer (iki hesap birden; transaction).
|
||||
- [ ] Dokploy'da uygulamayı **Restart** edip fişin hâlâ açıldığını görme (volume kalıcılığı).
|
||||
|
||||
## 4. Otomatik deploy (Gitea webhook)
|
||||
|
||||
Dokploy › `butcem` › **Deployments** › **Webhook URL**'yi kopyalayın (Auto Deploy açık olmalı).
|
||||
Gitea › repo › **Settings › Webhooks › Add Webhook › Gitea**:
|
||||
|
||||
- **Target URL:** kopyaladığınız Dokploy URL'si · **HTTP Method:** POST · **Content type:** JSON
|
||||
- **Trigger On:** Push Events · **Branch filter:** `main`
|
||||
- Kaydedip **Test Delivery** ile deneyin; Dokploy'da yeni bir deployment başlamalı.
|
||||
|
||||
Bundan sonra `git push origin main` → otomatik build ve yayın. Her deploy'da bekleyen
|
||||
migration'lar uygulama açılmadan önce çalışır.
|
||||
|
||||
## 5. Güncelleme ve geri alma
|
||||
|
||||
- **Normal güncelleme:** `main`'e push (webhook) ya da Dokploy'da **Deploy**.
|
||||
- **Büyük güncellemeden önce** elle yedek alın: `sudo /opt/butcem/butcem-backup.sh` (bkz. 6).
|
||||
- **Geri alma:** Dokploy › Deployments › önceki başarılı deployment › **Rollback** (ya da
|
||||
Gitea'da önceki commit'e dönüp push). Migration'lar yalnızca ileri yönlüdür; yeni sürümün
|
||||
eklediği index/alanlar eski sürümü bozmaz. Veri bozulduysa 7'deki geri yüklemeyi kullanın.
|
||||
- **Acil durum:** başlatma adımı (migration) hata verip uygulama açılmıyorsa geçici olarak
|
||||
`SKIP_BOOTSTRAP=1` ortam değişkeniyle açabilirsiniz; sorunu giderince kaldırın.
|
||||
|
||||
## 6. Yedekleme
|
||||
|
||||
Günlük: `butcem` veritabanı (`mongodump`) + uploads volume arşivi + SHA-256 özetleri,
|
||||
**7 gün saklama**, `/var/backups/butcem` altında. Betik: `scripts/backup/butcem-backup.sh`.
|
||||
|
||||
> ⚠️ Şimdilik yedekler **yalnızca sunucuda**. Disk arızasında yedekler de gider; bir sonraki adım
|
||||
> olarak sunucu dışına kopya (NAS'a `rsync` ya da S3 uyumlu depolama) eklenmeli.
|
||||
|
||||
### 6.1 Kurulum (bir kez)
|
||||
|
||||
```bash
|
||||
sudo install -d -m 755 /opt/butcem
|
||||
```
|
||||
|
||||
Betikler için `/opt/butcem` klasörünü oluşturur.
|
||||
|
||||
```bash
|
||||
sudo install -m 750 scripts/backup/butcem-backup.sh scripts/backup/butcem-restore.sh /opt/butcem/
|
||||
```
|
||||
|
||||
Yedek ve geri yükleme betiklerini (repo klasöründen; sunucuya `scp` ile de kopyalayabilirsiniz)
|
||||
`/opt/butcem`'e yalnızca root'un çalıştırabileceği izinle kopyalar.
|
||||
|
||||
```bash
|
||||
sudo install -m 600 scripts/backup/butcem-backup.env.example /etc/butcem-backup.env
|
||||
sudo nano /etc/butcem-backup.env
|
||||
```
|
||||
|
||||
Ayar dosyasını yalnızca root'un okuyabileceği izinle oluşturur ve düzenlemek için açar. Doldurun:
|
||||
|
||||
- `MONGO_CONTAINER`: MongoDB konteyner adının ayırt edici kısmı (`docker ps --format '{{.Names}}'`).
|
||||
- `MONGO_ROOT_USER` / `MONGO_ROOT_PASSWORD`: `butcem_root` bilgileri.
|
||||
- `UPLOADS_VOLUME`: uygulamanın volume adı.
|
||||
```bash
|
||||
docker volume ls | grep -i uploads
|
||||
```
|
||||
Volume adlarını listeler (Dokploy'da verdiğiniz `butcem-uploads` adını doğrulayın).
|
||||
|
||||
### 6.2 İlk çalıştırma
|
||||
|
||||
```bash
|
||||
sudo /opt/butcem/butcem-backup.sh
|
||||
```
|
||||
|
||||
Yedeği hemen alır; sonunda `Tamam: … veritabanı, … dosya` yazmalı.
|
||||
|
||||
```bash
|
||||
sudo ls -lh /var/backups/butcem && sudo sh -c 'cd /var/backups/butcem && sha256sum -c SHA256SUMS-*'
|
||||
```
|
||||
|
||||
Yedek dosyalarını listeler ve bütünlüklerini doğrular (`OK`).
|
||||
|
||||
### 6.3 Günlük zamanlama
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
root'un zamanlanmış görevlerini açar. Şu satırı ekleyin (her gece 03:30):
|
||||
|
||||
```
|
||||
30 3 * * * /opt/butcem/butcem-backup.sh >> /var/log/butcem-backup.log 2>&1
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo tail -n 20 /var/log/butcem-backup.log
|
||||
```
|
||||
|
||||
Ertesi gün son yedeğin günlüğünü gösterir.
|
||||
|
||||
## 7. Geri yükleme
|
||||
|
||||
```bash
|
||||
sudo ls -1 /var/backups/butcem
|
||||
```
|
||||
|
||||
Mevcut yedekleri listeler; dönülecek tarih damgasını seçin.
|
||||
|
||||
1. Dokploy › `butcem` › **Stop** (yazma olmasın).
|
||||
2. Geri yükleyin:
|
||||
```bash
|
||||
sudo /opt/butcem/butcem-restore.sh /var/backups/butcem/mongo-<damga>.archive.gz /var/backups/butcem/uploads-<damga>.tar.gz
|
||||
```
|
||||
Bütünlüğü kontrol eder, onay ister (`evet`), `butcem` veritabanını yedektekiyle **değiştirir**
|
||||
ve uploads volume'unu arşivden yeniden yazar. Yalnızca veritabanı için ikinci dosyayı vermeyin.
|
||||
3. Dokploy › **Start**, ardından duman testi (3).
|
||||
|
||||
**Tatbikat:** Ayda bir, en son yedeği bu adımlarla deneme amaçlı bir Dokploy kopyasına ya da
|
||||
lokale (`docker compose` mongo'su) yükleyip açılıp açılmadığını kontrol edin. Yedeği hiç denenmemiş
|
||||
bir yedek, yedek değildir.
|
||||
|
||||
## 8. Sorun giderme
|
||||
|
||||
| Belirti | Neden / çözüm |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Logs: `MongoDB replica set olarak çalışmıyor` | MONGODB-KURULUM.md › 3 |
|
||||
| Logs: `Authentication failed` | `MONGODB_URI` kullanıcı/parola/`authSource`; parola URL kodlu mu? |
|
||||
| Logs: `Geçersiz ortam değişkenleri` | Listelenen değişkeni düzeltin (ör. `BETTER_AUTH_SECRET` < 32 karakter) |
|
||||
| Konteyner `unhealthy` | `curl -s https://butcem.ta1sph.website/api/health` → `db` alanı; MongoDB servisi çalışıyor mu? |
|
||||
| Giriş sonrası tekrar giriş sayfası | `BETTER_AUTH_URL` alan adıyla birebir aynı mı (https, sonda `/` yok)? |
|
||||
| Yüklenen dosya açılmıyor / yükleme hatası | Volume `/app/uploads`'a bağlı mı? `docker exec <app> ls -ln /app/uploads` sahibi 1001 olmalı |
|
||||
| Sertifika alınamadı | DNS A kaydı ve 80 portu Traefik'e ulaşıyor mu? |
|
||||
|
||||
```bash
|
||||
docker logs --tail 100 $(docker ps -q --filter name=<butcem-uygulama-adı> | head -n1)
|
||||
```
|
||||
|
||||
Uygulama konteynerinin son 100 log satırını gösterir (Dokploy › Logs ile aynı).
|
||||
@@ -0,0 +1,144 @@
|
||||
# MongoDB kurulumu (canlı, Dokploy)
|
||||
|
||||
BÜTÇEM çok adımlı yazmaları (transfer, "Alışverişi tamamla", kart ödemesi…) **MongoDB transaction**
|
||||
ile yapar. Transaction yalnızca **replica set** üzerinde çalışır; tek sunucu için **tek düğümlü
|
||||
replica set** (`rs0`) yeterlidir. Uygulama başlarken bunu kontrol eder; replica set yoksa açık bir
|
||||
hata mesajıyla durur.
|
||||
|
||||
Bu belge mevcut kurulumu esas alır: Dokploy'da **`mongo:7`** veritabanı servisi (mongo:8 sunucu
|
||||
çekirdeğiyle uyumsuz), kimlik doğrulama açık, `butcem_root` (yönetici) ve `butcem_app` (uygulama)
|
||||
kullanıcıları oluşturulmuş. Uygulama mongo:7.0 ile test edilmiştir (tüm entegrasyon testleri).
|
||||
|
||||
> Komutlar Atlas sunucusunda çalıştırılır. `<…>` ile gösterilen yerleri kendi değerlerinizle
|
||||
> değiştirin. Parolaları komut geçmişine yazmamak için `mongosh` parola sorduğunda girin.
|
||||
|
||||
---
|
||||
|
||||
## 1. MongoDB konteynerini bul
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}' | grep -i mongo
|
||||
```
|
||||
|
||||
Çalışan konteynerlerin adlarından MongoDB'ninkini listeler. Dokploy ada rastgele bir son ek ekler
|
||||
(ör. `butcem-mongo-a1b2c3.1.xyz`). Aşağıda bu adı `<MONGO>` olarak kullanın; adın ayırt edici
|
||||
başlangıcı (ör. `butcem-mongo-a1b2c3`) da yeterlidir.
|
||||
|
||||
```bash
|
||||
MONGO=$(docker ps -q --filter name=<mongo-servis-adı> | head -n1); echo "$MONGO"
|
||||
```
|
||||
|
||||
Konteyner kimliğini bir değişkene alır; sonraki komutlar `$MONGO` ile kısalır.
|
||||
|
||||
## 2. Replica set açık mı?
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'try { const s = rs.status(); print(s.set, s.members[0].stateStr) } catch (e) { print("REPLICA SET YOK: " + e.codeName) }'
|
||||
```
|
||||
|
||||
Yönetici olarak bağlanıp replica set durumunu yazdırır (parolayı sorar).
|
||||
|
||||
- Çıktı **`rs0 PRIMARY`** ise replica set hazır; **4. adıma** geçin.
|
||||
- Çıktı **`REPLICA SET YOK: NoReplicationEnabled`** ise **3. adımı** uygulayın.
|
||||
|
||||
## 3. Tek düğümlü replica set'i aç
|
||||
|
||||
Dokploy'un MongoDB servisinde bir **"Replica Set"** seçeneği varsa (sürüme göre değişir) onu açıp
|
||||
servisi yeniden deploy etmeniz ve 3.4'e geçmeniz yeterlidir. Yoksa aşağıdaki elle yöntemi uygulayın.
|
||||
Kimlik doğrulama açıkken replica set bir **anahtar dosyası (keyfile)** ister.
|
||||
|
||||
### 3.1 Yedek alın
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongodump -u butcem_root --authenticationDatabase admin --gzip --archive=/data/db/oncesi.archive.gz
|
||||
docker cp "$MONGO":/data/db/oncesi.archive.gz ~/mongo-oncesi.archive.gz && docker exec "$MONGO" rm /data/db/oncesi.archive.gz
|
||||
```
|
||||
|
||||
Değişiklikten önce tüm veritabanlarının yedeğini konteyner içinde bir dosyaya alır (parolayı sorar),
|
||||
sonra ev dizinine kopyalayıp konteynerdeki kopyayı siler. Yeni kurulumda veri yoksa atlanabilir.
|
||||
|
||||
### 3.2 Anahtar dosyasını oluştur
|
||||
|
||||
```bash
|
||||
docker exec "$MONGO" sh -c 'head -c 756 /dev/urandom | base64 > /data/db/rs-keyfile && chmod 400 /data/db/rs-keyfile && chown 999:999 /data/db/rs-keyfile && ls -l /data/db/rs-keyfile'
|
||||
```
|
||||
|
||||
Kalıcı veri volume'u (`/data/db`) içinde rastgele bir anahtar dosyası üretir; yalnızca `mongodb`
|
||||
kullanıcısı (uid 999) okuyabilir. Çıktıda `-r-------- … mongodb mongodb` görmelisiniz. Dosya
|
||||
volume'da olduğu için yeniden başlatmalarda korunur.
|
||||
|
||||
### 3.3 Başlatma komutunu değiştir ve yeniden deploy et
|
||||
|
||||
Dokploy › MongoDB servisi › **Advanced** sekmesi › **Command** alanına:
|
||||
|
||||
```
|
||||
mongod --replSet rs0 --bind_ip_all --keyFile /data/db/rs-keyfile
|
||||
```
|
||||
|
||||
yazıp kaydedin ve servisi **Redeploy** edin. (`MONGO_INITDB_ROOT_*` ortam değişkenleri yerinde
|
||||
kaldığı için kimlik doğrulama açık kalır.) Konteyner yeniden oluşacağından 1. adımdaki `MONGO`
|
||||
değişkenini yeniden alın.
|
||||
|
||||
### 3.4 Replica set'i başlat (tek seferlik)
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'
|
||||
```
|
||||
|
||||
`rs0` adlı tek üyeli replica set'i başlatır; çıktıda `{ ok: 1 }` görmelisiniz. Uygulama
|
||||
`directConnection=true` ile bağlandığı için üye adresinin `localhost` olması sorun değildir.
|
||||
|
||||
Birkaç saniye sonra 2. adımdaki kontrolü tekrarlayın: **`rs0 PRIMARY`**.
|
||||
|
||||
## 4. Uygulama kullanıcısını doğrula
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin --eval '
|
||||
for (const d of ["admin", "butcem"]) {
|
||||
const u = db.getSiblingDB(d).getUser("butcem_app");
|
||||
if (u) print("tanımlı veritabanı:", d, "| roller:", JSON.stringify(u.roles));
|
||||
}'
|
||||
```
|
||||
|
||||
`butcem_app` kullanıcısının hangi veritabanında tanımlı olduğunu (bağlantı dizesindeki
|
||||
`authSource`) ve rollerini yazdırır. Beklenen rol: `[{"role":"readWrite","db":"butcem"}]`.
|
||||
|
||||
Rol farklıysa düzeltin:
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'db.getSiblingDB("<authSource>").updateUser("butcem_app", { roles: [{ role: "readWrite", db: "butcem" }] })'
|
||||
```
|
||||
|
||||
Uygulama kullanıcısına yalnızca `butcem` veritabanında okuma-yazma yetkisi verir (migration'lar
|
||||
index oluşturmak için bu yetkiyle yetinir).
|
||||
|
||||
## 5. Bağlantı dizesi (Dokploy uygulama ortam değişkeni)
|
||||
|
||||
```
|
||||
MONGODB_URI=mongodb://butcem_app:<parola-url-kodlu>@<iç-host>:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true
|
||||
```
|
||||
|
||||
- `<iç-host>`: Dokploy › MongoDB servisi › **Internal Host** (ör. `butcem-mongo-a1b2c3`). Uygulama
|
||||
ve veritabanı aynı Dokploy ağında olduğundan **dışa port açmaya gerek yoktur**; açmayın.
|
||||
- `<authSource>`: 4. adımın çıktısındaki veritabanı (`admin` veya `butcem`).
|
||||
- Parolada `@ : / ? # %` gibi karakterler varsa URL kodlayın:
|
||||
```bash
|
||||
python3 -c 'import urllib.parse,getpass; print(urllib.parse.quote(getpass.getpass(), safe=""))'
|
||||
```
|
||||
Parolayı sorar ve bağlantı dizesine yazılacak kodlu hâlini basar (geçmişe düşmez).
|
||||
|
||||
## 6. Transaction denemesi (isteğe bağlı)
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh "mongodb://butcem_app@localhost:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true" --eval '
|
||||
const s = db.getMongo().startSession(); const c = s.getDatabase("butcem").getCollection("tx_probe");
|
||||
s.startTransaction(); c.insertOne({ a: 1 }); s.commitTransaction();
|
||||
print("transaction tamam:", db.getSiblingDB("butcem").getCollection("tx_probe").countDocuments({}));
|
||||
db.getSiblingDB("butcem").getCollection("tx_probe").drop();'
|
||||
```
|
||||
|
||||
Uygulama kullanıcısıyla bağlanıp küçük bir transaction yazar, sonucu basar ve deneme
|
||||
koleksiyonunu siler. `transaction tamam: 1` görmelisiniz.
|
||||
@@ -295,8 +295,13 @@ Skill fazları (0–4) uygulanır, ardından alan fazları gelir. Her faz kendi
|
||||
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
||||
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
||||
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
||||
| 16 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.1.0 |
|
||||
| 17 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.2.0 |
|
||||
| 16 | **Kakeibo K1:** dört grup (Zorunlu/İstek/Kültür/Beklenmeyen), banka maliyeti ayrımı, kayıt bazında grup, Kakeibo raporu | 1.1.0 |
|
||||
| 17 | **Kakeibo K2:** ekstre içe aktarma (Yapı Kredi kart/hesap, Kakeibo çalışma kitabı; önizleme, tekrar engeli, ekstre toplamı kontrolü, geri alma) | 1.2.0 |
|
||||
| 18 | **Kakeibo K3:** ay planı ve dört soru (ay başı sihirbazı, harcanabilir, grup bütçeleri, haftalık nakit limiti), birikim hedefleri | 1.3.0 |
|
||||
| 19 | **Kakeibo K4:** haftalık kontrol, ay sonu değerlendirmesi (pişmanlık, iyileştirme hedefleri), Kakeibo panosu (KMH faizi/gün, taksit yükü, kayıtsız nakit) | 1.4.0 |
|
||||
| 20 | **Kakeibo K5:** satın almadan önce kontrol ve 24 saat bekleme listesi | 1.5.0 |
|
||||
| 21 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.6.0 |
|
||||
| 22 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.7.0 |
|
||||
|
||||
---
|
||||
|
||||
@@ -317,6 +322,18 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
||||
- **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır.
|
||||
- **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce.
|
||||
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
|
||||
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
|
||||
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
||||
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
||||
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
|
||||
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
|
||||
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
|
||||
- **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.
|
||||
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
||||
- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen, mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte).
|
||||
- **Faz 15 kararları (2026-11-07):** Canlı MongoDB Dokploy servisi **mongo:7** (mongo:8 sunucu çekirdeğiyle uyumsuz); uygulama mongo:7.0'a karşı tüm entegrasyon testleriyle doğrulandı. Replica set kimlik doğrulamalı tek düğüm (`rs0`, keyfile), uygulama `directConnection=true` ile bağlanır (`docs/MONGODB-KURULUM.md`). Migration'lar ve ilk admin konteyner başlarken otomatik (`scripts/bootstrap.mjs`; replica set yoksa uygulama açılmaz). Gitea `main` push → Dokploy webhook ile otomatik deploy. Yedek: günlük `mongodump` + uploads arşivi, 7 gün, şimdilik yalnızca sunucuda (sunucu dışı kopya sonraki adım).
|
||||
- **Kakeibo kararları (2026-11-07):** Uygulama `.claude/skills/kakeibo` becerisine göre Kakeibo yöntemine uyarlanır (Faz 16–20); mobil API ve fiyat sağlayıcıları öncelik değil, 21–22'ye kaydı. KMH faizi, KKDF/BSMV ve banka ücretleri "banka maliyeti"dir: dört gruba ve Kakeibo harcama toplamına girmez, ayrı gösterilir. Günlük plan korunur, Kakeibo haftalık kontrolü yanına eklenir. Kişilere giden havale/EFT gider sayılır; kampanya iadeleri "İade" geliri. Ekstre dosyaları `import/` (git ve Docker dışı); gerçek veri önce dev veritabanında denenir, canlıya aktarmadan önce sorulur.
|
||||
- **Faz 16 kararları (2026-11-07):** Kakeibo grubu gider kategorisinde tutulur ve alt kategoriye devredilir; işlem ya da bölünmüş kalem bazında değiştirilebilir ("kategoriyi ürün değil amaç belirler"). Banka maliyeti işareti kategori düzeyinde (varsayılan "Banka ve kart giderleri" ağacı); grup değiştirilse bile banka maliyeti kalır. Varsayılan kategoriler beceri eşlemesine göre doldurulur, kullanıcı kategorileri grupsuz kalır ve uyarı gösterilir. Migration'lar artık geri alınabilir (`npm run migrate -- --down <ad>`).
|
||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||
|
||||
---
|
||||
|
||||
@@ -5,12 +5,23 @@ import nextTs from "eslint-config-next/typescript";
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
// "_" ile başlayan adlar bilinçli olarak kullanılmayan değişkenlerdir.
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
// Derlenmiş başlatma betiği (esbuild paketi).
|
||||
"dist/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -1,9 +1,65 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
/**
|
||||
* Content-Security-Policy (nonce'suz): Cache Components'in statik kabuğu ile uyumlu.
|
||||
* 'unsafe-inline' script: Next.js'in satır içi hydration verisi ve next-themes'in tema betiği için.
|
||||
* Grafikler (Recharts, SVG) ve carousel (Embla) ek kaynak gerektirmez; ek dosyalar
|
||||
* kendi origin'imizden (/api/files/...) ve blob: önizlemelerle servis edilir.
|
||||
*/
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' blob: data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"media-src 'self' blob:",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
...(isDev ? [] : ["upgrade-insecure-requests"]),
|
||||
].join("; ");
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "Content-Security-Policy", value: csp },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
|
||||
// Kamera: Faz 6'da fiş fotoğrafı için yalnızca kendi origin'imiz.
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(self), microphone=(), geolocation=(), payment=(), usb=()",
|
||||
},
|
||||
...(isDev
|
||||
? []
|
||||
: // includeSubDomains/preload bilinçli olarak yok: ta1sph.website'in diğer alt alanlarını bağlamasın.
|
||||
[{ key: "Strict-Transport-Security", value: "max-age=31536000" }]),
|
||||
];
|
||||
|
||||
/**
|
||||
* /api/files: kullanıcı dosyaları (WebP görsel, PDF). Sayfa CSP'si yerine daha sıkı bir politika:
|
||||
* betik yok; PDF'in tarayıcı görüntüleyicisinde açılabilmesi için object-src 'self'.
|
||||
*/
|
||||
const fileCsp = [
|
||||
"default-src 'none'",
|
||||
"img-src 'self'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"object-src 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
].join("; ");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala.
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
// HEIC çözücü (libheif WASM) bundle'a girmesin; Node require ile yüklensin.
|
||||
serverExternalPackages: ["heic-decode", "libheif-js"],
|
||||
experimental: {
|
||||
agentFeedback: true,
|
||||
},
|
||||
@@ -17,6 +73,18 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{ source: "/:path((?!api/files/).*)", headers: securityHeaders },
|
||||
{
|
||||
source: "/api/files/:path*",
|
||||
headers: [
|
||||
...securityHeaders.filter((h) => h.key !== "Content-Security-Policy"),
|
||||
{ key: "Content-Security-Policy", value: fileCsp },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "butcem",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
"better-auth": "^1.7.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.5.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"heic-decode": "^2.1.0",
|
||||
"lucide-react": "^1.68.0",
|
||||
"mongodb": "~7.7",
|
||||
"mongoose": "^9.11.4",
|
||||
@@ -22,10 +24,14 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"recharts": "^3.8.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -42,6 +48,9 @@
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5",
|
||||
"vitest": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=26 <27"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -1650,7 +1659,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -4045,6 +4053,46 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz",
|
||||
"integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.2.9",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.2.9",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
|
||||
"integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
||||
@@ -4958,6 +5006,69 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -5016,6 +5127,12 @@
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -6567,6 +6684,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz",
|
||||
"integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
|
||||
"integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz",
|
||||
"integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
|
||||
"integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz",
|
||||
"integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz",
|
||||
"integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz",
|
||||
"integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz",
|
||||
"integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -6660,6 +6898,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -6776,7 +7020,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6858,6 +7101,34 @@
|
||||
"integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/embla-carousel": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
|
||||
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"embla-carousel": "8.6.0",
|
||||
"embla-carousel-reactive-utils": "8.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/embla-carousel-reactive-utils": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
|
||||
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"embla-carousel": "8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@@ -7107,6 +7378,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.53.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.1.tgz",
|
||||
"integrity": "sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types",
|
||||
"tests/browser-compat"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -7625,6 +7908,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
@@ -8202,6 +8491,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-decode": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libheif-js": "^1.19.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@@ -8237,6 +8538,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -8279,6 +8590,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.6",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
|
||||
@@ -9073,6 +9393,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libheif-js": {
|
||||
"version": "1.23.5",
|
||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz",
|
||||
"integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==",
|
||||
"license": "LGPL-3.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
@@ -10661,9 +10990,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz",
|
||||
"integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -10749,6 +11100,51 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -10802,6 +11198,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
@@ -11199,7 +11601,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -11249,7 +11650,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
|
||||
"integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -12340,6 +12740,41 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.4.1",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz",
|
||||
"integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==",
|
||||
"license": "MIT AND ISC AND BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
@@ -12724,6 +13159,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
@@ -11,15 +11,21 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"db:up": "docker compose up -d --wait",
|
||||
"db:down": "docker compose down",
|
||||
"seed": "tsx --env-file=.env.local scripts/seed.ts",
|
||||
"seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset"
|
||||
"seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts",
|
||||
"seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset",
|
||||
"icons": "tsx scripts/generate-icons.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
|
||||
"bootstrap": "tsx --conditions=react-server --env-file=.env.local scripts/bootstrap.ts",
|
||||
"build:scripts": "esbuild scripts/bootstrap.ts --bundle --platform=node --format=esm --target=node22 --conditions=react-server --outfile=dist/bootstrap.mjs --external:sharp --external:kerberos --external:snappy --external:socks --external:aws4 --external:@mongodb-js/zstd --external:mongodb-client-encryption --external:gcp-metadata --external:@aws-sdk/credential-providers --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --log-level=warning",
|
||||
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
@@ -27,6 +33,8 @@
|
||||
"better-auth": "^1.7.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.5.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"heic-decode": "^2.1.0",
|
||||
"lucide-react": "^1.68.0",
|
||||
"mongodb": "~7.7",
|
||||
"mongoose": "^9.11.4",
|
||||
@@ -36,10 +44,14 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"recharts": "^3.8.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM maskable/Apple ikonu (köşesiz, tam zemin). Kaynak: npm run icons -->
|
||||
<defs><linearGradient id="mg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" fill="url(#mg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM uygulama ikonu: hane (çatı) + yükselen bütçe/birikim sütunları. Kaynak: npm run icons -->
|
||||
<defs><linearGradient id="ig" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#ig)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 820 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM logosu (koyu tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
|
||||
<g transform="scale(0.125)">
|
||||
<defs><linearGradient id="dg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#dg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</g>
|
||||
<path fill="#edf2f8" d="M88.744 19.863999999999997Q93.742 19.863999999999997 96.02000000000001 21.342999999999996Q98.298 22.822 98.298 25.916Q98.298 28.194 97.09100000000001 29.537Q95.884 30.88 94.184 31.253999999999998V31.424Q95.374 31.662 96.46199999999999 32.274Q97.55 32.885999999999996 98.22999999999999 34.042Q98.91 35.198 98.91 37.102000000000004Q98.91 40.434 96.513 42.287Q94.116 44.14 89.968 44.14H80.89V19.863999999999997ZM89.016 29.588Q91.226 29.588 92.093 28.772Q92.96000000000001 27.956 92.96000000000001 26.63Q92.96000000000001 25.236 91.94 24.573Q90.92 23.91 88.778 23.91H86.092V29.588ZM86.092 33.498V40.026H89.39Q91.702 40.026 92.60300000000001 39.091Q93.504 38.156 93.504 36.694Q93.504 35.368 92.58600000000001 34.433Q91.668 33.498 89.22 33.498Z M124.10400000000001 35.572Q124.10400000000001 38.088 122.99900000000001 40.111000000000004Q121.894 42.134 119.667 43.307Q117.44000000000001 44.480000000000004 114.04 44.480000000000004Q109.212 44.480000000000004 106.696 42.015Q104.18 39.55 104.18 35.504V19.863999999999997H109.31400000000001V34.722Q109.31400000000001 37.714 110.53800000000001 38.938Q111.762 40.162 114.176 40.162Q116.69200000000001 40.162 117.831 38.836Q118.97 37.51 118.97 34.688V19.863999999999997H124.10400000000001ZM108.05600000000001 15.103999999999996Q108.05600000000001 13.913999999999994 108.71900000000001 13.369999999999996Q109.382 12.825999999999997 110.40200000000002 12.825999999999997Q111.32000000000001 12.825999999999997 112.03400000000002 13.369999999999996Q112.74800000000002 13.913999999999994 112.74800000000002 15.103999999999996Q112.74800000000002 16.225999999999996 112.03400000000002 16.769999999999996Q111.32000000000001 17.313999999999997 110.40200000000002 17.313999999999997Q109.382 17.313999999999997 108.71900000000001 16.769999999999996Q108.05600000000001 16.225999999999996 108.05600000000001 15.103999999999996ZM115.4 15.103999999999996Q115.4 13.913999999999994 116.08000000000001 13.369999999999996Q116.76000000000002 12.825999999999997 117.74600000000001 12.825999999999997Q118.69800000000001 12.825999999999997 119.39500000000001 13.369999999999996Q120.09200000000001 13.913999999999994 120.09200000000001 15.103999999999996Q120.09200000000001 16.225999999999996 119.39500000000001 16.769999999999996Q118.69800000000001 17.313999999999997 117.74600000000001 17.313999999999997Q116.76000000000002 17.313999999999997 116.08000000000001 16.769999999999996Q115.4 16.225999999999996 115.4 15.103999999999996Z M140.084 44.14H134.882V24.182H128.32V19.863999999999997H146.64600000000002V24.182H140.084Z M161.50400000000002 23.842Q158.58 23.842 156.897 26.035Q155.214 28.228 155.214 32.07Q155.214 35.912 156.829 38.037Q158.44400000000002 40.162 161.708 40.162Q163.306 40.162 164.78500000000003 39.788Q166.264 39.414 167.76000000000002 38.836V43.256Q164.87 44.480000000000004 161.13 44.480000000000004Q157.25400000000002 44.480000000000004 154.755 42.93300000000001Q152.256 41.386 151.049 38.581Q149.842 35.775999999999996 149.842 32.036Q149.842 28.363999999999997 151.202 25.541999999999998Q152.562 22.72 155.18 21.122Q157.798 19.523999999999997 161.572 19.523999999999997Q163.37400000000002 19.523999999999997 165.22700000000003 19.915Q167.08 20.305999999999997 168.746 21.122L167.04600000000002 25.27Q165.822 24.692 164.445 24.267Q163.068 23.842 161.50400000000002 23.842ZM164.496 48.526Q164.496 50.26 163.238 51.28Q161.98000000000002 52.3 159.73600000000002 52.3Q158.818 52.3 158.07000000000002 52.181Q157.32200000000003 52.062 156.846 51.892V49.206Q157.83200000000002 49.512 158.88600000000002 49.512Q160.68800000000002 49.512 160.68800000000002 48.322Q160.68800000000002 47.642 160.00800000000004 47.268Q159.32800000000003 46.894 158.03600000000003 46.69L159.22600000000003 44.14H161.94600000000003L161.436 45.466Q162.15 45.636 162.86400000000003 45.976Q163.57800000000003 46.316 164.03700000000003 46.928Q164.496 47.54 164.496 48.526Z"/>
|
||||
<path fill="#6bb9f8" d="M187.514 44.14H173.54000000000002V19.863999999999997H187.514V24.08H178.674V29.418H186.90200000000002V33.634H178.674V39.89H187.514Z M203.52800000000002 44.14 197.68 25.099999999999998H197.544Q197.57800000000003 25.779999999999998 197.64600000000002 27.14Q197.71400000000003 28.5 197.78200000000004 30.047Q197.85000000000002 31.594 197.85000000000002 32.852000000000004V44.14H193.26000000000002V19.863999999999997H200.264L206.01000000000002 38.428H206.11200000000002L212.198 19.863999999999997H219.20200000000003V44.14H214.40800000000002V32.647999999999996Q214.40800000000002 31.491999999999997 214.459 29.996Q214.51000000000002 28.5 214.56100000000004 27.157Q214.61200000000002 25.814 214.64600000000002 25.134H214.51000000000002L208.25400000000002 44.14Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM logosu (açık tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
|
||||
<g transform="scale(0.125)">
|
||||
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#lg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</g>
|
||||
<path fill="#101a2d" d="M88.744 19.863999999999997Q93.742 19.863999999999997 96.02000000000001 21.342999999999996Q98.298 22.822 98.298 25.916Q98.298 28.194 97.09100000000001 29.537Q95.884 30.88 94.184 31.253999999999998V31.424Q95.374 31.662 96.46199999999999 32.274Q97.55 32.885999999999996 98.22999999999999 34.042Q98.91 35.198 98.91 37.102000000000004Q98.91 40.434 96.513 42.287Q94.116 44.14 89.968 44.14H80.89V19.863999999999997ZM89.016 29.588Q91.226 29.588 92.093 28.772Q92.96000000000001 27.956 92.96000000000001 26.63Q92.96000000000001 25.236 91.94 24.573Q90.92 23.91 88.778 23.91H86.092V29.588ZM86.092 33.498V40.026H89.39Q91.702 40.026 92.60300000000001 39.091Q93.504 38.156 93.504 36.694Q93.504 35.368 92.58600000000001 34.433Q91.668 33.498 89.22 33.498Z M124.10400000000001 35.572Q124.10400000000001 38.088 122.99900000000001 40.111000000000004Q121.894 42.134 119.667 43.307Q117.44000000000001 44.480000000000004 114.04 44.480000000000004Q109.212 44.480000000000004 106.696 42.015Q104.18 39.55 104.18 35.504V19.863999999999997H109.31400000000001V34.722Q109.31400000000001 37.714 110.53800000000001 38.938Q111.762 40.162 114.176 40.162Q116.69200000000001 40.162 117.831 38.836Q118.97 37.51 118.97 34.688V19.863999999999997H124.10400000000001ZM108.05600000000001 15.103999999999996Q108.05600000000001 13.913999999999994 108.71900000000001 13.369999999999996Q109.382 12.825999999999997 110.40200000000002 12.825999999999997Q111.32000000000001 12.825999999999997 112.03400000000002 13.369999999999996Q112.74800000000002 13.913999999999994 112.74800000000002 15.103999999999996Q112.74800000000002 16.225999999999996 112.03400000000002 16.769999999999996Q111.32000000000001 17.313999999999997 110.40200000000002 17.313999999999997Q109.382 17.313999999999997 108.71900000000001 16.769999999999996Q108.05600000000001 16.225999999999996 108.05600000000001 15.103999999999996ZM115.4 15.103999999999996Q115.4 13.913999999999994 116.08000000000001 13.369999999999996Q116.76000000000002 12.825999999999997 117.74600000000001 12.825999999999997Q118.69800000000001 12.825999999999997 119.39500000000001 13.369999999999996Q120.09200000000001 13.913999999999994 120.09200000000001 15.103999999999996Q120.09200000000001 16.225999999999996 119.39500000000001 16.769999999999996Q118.69800000000001 17.313999999999997 117.74600000000001 17.313999999999997Q116.76000000000002 17.313999999999997 116.08000000000001 16.769999999999996Q115.4 16.225999999999996 115.4 15.103999999999996Z M140.084 44.14H134.882V24.182H128.32V19.863999999999997H146.64600000000002V24.182H140.084Z M161.50400000000002 23.842Q158.58 23.842 156.897 26.035Q155.214 28.228 155.214 32.07Q155.214 35.912 156.829 38.037Q158.44400000000002 40.162 161.708 40.162Q163.306 40.162 164.78500000000003 39.788Q166.264 39.414 167.76000000000002 38.836V43.256Q164.87 44.480000000000004 161.13 44.480000000000004Q157.25400000000002 44.480000000000004 154.755 42.93300000000001Q152.256 41.386 151.049 38.581Q149.842 35.775999999999996 149.842 32.036Q149.842 28.363999999999997 151.202 25.541999999999998Q152.562 22.72 155.18 21.122Q157.798 19.523999999999997 161.572 19.523999999999997Q163.37400000000002 19.523999999999997 165.22700000000003 19.915Q167.08 20.305999999999997 168.746 21.122L167.04600000000002 25.27Q165.822 24.692 164.445 24.267Q163.068 23.842 161.50400000000002 23.842ZM164.496 48.526Q164.496 50.26 163.238 51.28Q161.98000000000002 52.3 159.73600000000002 52.3Q158.818 52.3 158.07000000000002 52.181Q157.32200000000003 52.062 156.846 51.892V49.206Q157.83200000000002 49.512 158.88600000000002 49.512Q160.68800000000002 49.512 160.68800000000002 48.322Q160.68800000000002 47.642 160.00800000000004 47.268Q159.32800000000003 46.894 158.03600000000003 46.69L159.22600000000003 44.14H161.94600000000003L161.436 45.466Q162.15 45.636 162.86400000000003 45.976Q163.57800000000003 46.316 164.03700000000003 46.928Q164.496 47.54 164.496 48.526Z"/>
|
||||
<path fill="#105bbe" d="M187.514 44.14H173.54000000000002V19.863999999999997H187.514V24.08H178.674V29.418H186.90200000000002V33.634H178.674V39.89H187.514Z M203.52800000000002 44.14 197.68 25.099999999999998H197.544Q197.57800000000003 25.779999999999998 197.64600000000002 27.14Q197.71400000000003 28.5 197.78200000000004 30.047Q197.85000000000002 31.594 197.85000000000002 32.852000000000004V44.14H193.26000000000002V19.863999999999997H200.264L206.01000000000002 38.428H206.11200000000002L212.198 19.863999999999997H219.20200000000003V44.14H214.40800000000002V32.647999999999996Q214.40800000000002 31.491999999999997 214.459 29.996Q214.51000000000002 28.5 214.56100000000004 27.157Q214.61200000000002 25.814 214.64600000000002 25.134H214.51000000000002L208.25400000000002 44.14Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,13 @@
|
||||
# /etc/butcem-backup.env — butcem-backup.sh ayarları. İzin: sudo chmod 600 /etc/butcem-backup.env
|
||||
# Değerler için docs/DEPLOY.md › Yedekleme.
|
||||
|
||||
# MongoDB konteyner adı veya adının ayırt edici bir parçası (docker ps --format '{{.Names}}')
|
||||
MONGO_CONTAINER="butcem-mongo"
|
||||
MONGO_ROOT_USER="butcem_root"
|
||||
MONGO_ROOT_PASSWORD=""
|
||||
|
||||
# Uygulamanın /app/uploads volume adı (docker volume ls)
|
||||
UPLOADS_VOLUME="butcem-uploads"
|
||||
|
||||
BACKUP_DIR="/var/backups/butcem"
|
||||
RETENTION_DAYS="7"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM günlük yedek — sunucuda (Atlas) root olarak, cron ile çalışır. Ayrıntı: docs/DEPLOY.md › Yedekleme
|
||||
#
|
||||
# Üretir (BACKUP_DIR altında, tarih damgalı):
|
||||
# mongo-YYYYmmdd-HHMMSS.archive.gz → butcem veritabanı (mongodump --archive --gzip)
|
||||
# uploads-YYYYmmdd-HHMMSS.tar.gz → yüklenen dosyalar (uploads volume)
|
||||
# SHA256SUMS-YYYYmmdd-HHMMSS → bütünlük özetleri
|
||||
# RETENTION_DAYS günden eski yedekleri siler.
|
||||
#
|
||||
# Ayarlar /etc/butcem-backup.env dosyasından okunur (yalnızca root okuyabilmeli: chmod 600).
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||
|
||||
: "${MONGO_CONTAINER:?MONGO_CONTAINER tanımlı değil (MongoDB konteyner adı veya adının bir parçası)}"
|
||||
: "${MONGO_ROOT_USER:?MONGO_ROOT_USER tanımlı değil}"
|
||||
: "${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD tanımlı değil}"
|
||||
: "${UPLOADS_VOLUME:?UPLOADS_VOLUME tanımlı değil (uygulamanın /app/uploads volume adı)}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/var/backups/butcem}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||
DB_NAME="${DB_NAME:-butcem}"
|
||||
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
log() { echo "[$(date '+%F %T')] $*"; }
|
||||
|
||||
# Dokploy konteyner adlarına rastgele son ek ekler; adın bir parçasıyla eşleşen çalışan konteyner.
|
||||
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||
[ -n "$CID" ] || { log "HATA: '${MONGO_CONTAINER}' adlı çalışan MongoDB konteyneri yok."; exit 1; }
|
||||
docker volume inspect "$UPLOADS_VOLUME" >/dev/null 2>&1 || { log "HATA: '${UPLOADS_VOLUME}' volume'u yok."; exit 1; }
|
||||
|
||||
install -d -m 700 "$BACKUP_DIR"
|
||||
MONGO_FILE="$BACKUP_DIR/mongo-$TS.archive.gz"
|
||||
UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tar.gz"
|
||||
|
||||
# 1) MongoDB: parola komut satırında görünmesin diye geçici yapılandırma dosyasıyla.
|
||||
log "MongoDB yedeği alınıyor ($DB_NAME)…"
|
||||
CONF="/tmp/butcem-dump-$TS.yaml"
|
||||
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||
if ! docker exec "$CID" mongodump --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||
--authenticationDatabase admin --db "$DB_NAME" --archive --gzip >"$MONGO_FILE"; then
|
||||
docker exec "$CID" rm -f "$CONF"
|
||||
rm -f "$MONGO_FILE"
|
||||
log "HATA: mongodump başarısız."
|
||||
exit 1
|
||||
fi
|
||||
docker exec "$CID" rm -f "$CONF"
|
||||
[ -s "$MONGO_FILE" ] || { log "HATA: MongoDB yedeği boş."; exit 1; }
|
||||
|
||||
# 2) Yüklenen dosyalar: volume salt okunur bağlanır.
|
||||
log "Uploads yedeği alınıyor ($UPLOADS_VOLUME)…"
|
||||
docker run --rm -v "$UPLOADS_VOLUME":/data:ro -v "$BACKUP_DIR":/backup "$HELPER_IMAGE" \
|
||||
tar czf "/backup/$(basename "$UPLOADS_FILE")" -C /data .
|
||||
[ -s "$UPLOADS_FILE" ] || { log "HATA: uploads yedeği boş."; exit 1; }
|
||||
|
||||
# 3) Bütünlük özeti ve izinler
|
||||
( cd "$BACKUP_DIR" && sha256sum "$(basename "$MONGO_FILE")" "$(basename "$UPLOADS_FILE")" >"SHA256SUMS-$TS" )
|
||||
chmod 600 "$MONGO_FILE" "$UPLOADS_FILE" "$BACKUP_DIR/SHA256SUMS-$TS"
|
||||
|
||||
# 4) Saklama süresi
|
||||
find "$BACKUP_DIR" -maxdepth 1 -type f \( -name 'mongo-*.archive.gz' -o -name 'uploads-*.tar.gz' -o -name 'SHA256SUMS-*' \) \
|
||||
-mtime +"$RETENTION_DAYS" -print -delete | sed 's/^/ silindi: /'
|
||||
|
||||
log "Tamam: $(du -h "$MONGO_FILE" | cut -f1) veritabanı, $(du -h "$UPLOADS_FILE" | cut -f1) dosya → $BACKUP_DIR"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM geri yükleme — sunucuda root olarak, ELLE çalıştırılır. Ayrıntı: docs/DEPLOY.md › Geri yükleme
|
||||
#
|
||||
# sudo scripts/backup/butcem-restore.sh /var/backups/butcem/mongo-….archive.gz [/var/backups/butcem/uploads-….tar.gz]
|
||||
#
|
||||
# DİKKAT: butcem veritabanındaki koleksiyonlar yedektekilerle DEĞİŞTİRİLİR (--drop); uploads arşivi
|
||||
# verilirse volume içeriği silinip arşivden yazılır. Önce uygulamayı durdurun (Dokploy › Stop).
|
||||
# Ayarlar butcem-backup.sh ile aynı dosyadan: /etc/butcem-backup.env
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||
: "${MONGO_CONTAINER:?}" "${MONGO_ROOT_USER:?}" "${MONGO_ROOT_PASSWORD:?}" "${UPLOADS_VOLUME:?}"
|
||||
DB_NAME="${DB_NAME:-butcem}"
|
||||
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||
|
||||
MONGO_FILE="${1:?Kullanım: butcem-restore.sh <mongo-arşivi> [uploads-arşivi]}"
|
||||
UPLOADS_FILE="${2:-}"
|
||||
[ -s "$MONGO_FILE" ] || { echo "Arşiv yok veya boş: $MONGO_FILE"; exit 1; }
|
||||
[ -z "$UPLOADS_FILE" ] || [ -s "$UPLOADS_FILE" ] || { echo "Arşiv yok veya boş: $UPLOADS_FILE"; exit 1; }
|
||||
|
||||
# Bütünlük kontrolü (aynı klasörde SHA256SUMS varsa)
|
||||
DIR="$(cd "$(dirname "$MONGO_FILE")" && pwd)"
|
||||
STAMP="$(basename "$MONGO_FILE" | sed -E 's/^mongo-(.*)\.archive\.gz$/\1/')"
|
||||
if [ -f "$DIR/SHA256SUMS-$STAMP" ]; then
|
||||
(cd "$DIR" && sha256sum -c "SHA256SUMS-$STAMP") || { echo "Bütünlük kontrolü başarısız."; exit 1; }
|
||||
fi
|
||||
|
||||
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||
[ -n "$CID" ] || { echo "Çalışan MongoDB konteyneri yok: $MONGO_CONTAINER"; exit 1; }
|
||||
|
||||
echo "Geri yüklenecek: $MONGO_FILE${UPLOADS_FILE:+ ve $UPLOADS_FILE}"
|
||||
echo "'$DB_NAME' veritabanındaki mevcut veri yedektekiyle DEĞİŞTİRİLECEK."
|
||||
if [ "${ASSUME_YES:-0}" != "1" ]; then
|
||||
read -r -p "Devam etmek için 'evet' yazın: " ANSWER
|
||||
[ "$ANSWER" = "evet" ] || { echo "Vazgeçildi."; exit 1; }
|
||||
fi
|
||||
|
||||
CONF="/tmp/butcem-restore-$$.yaml"
|
||||
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||
trap 'docker exec "$CID" rm -f "$CONF" >/dev/null 2>&1 || true' EXIT
|
||||
docker exec -i "$CID" mongorestore --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||
--authenticationDatabase admin --archive --gzip --drop --nsInclude "${DB_NAME}.*" <"$MONGO_FILE"
|
||||
echo "✓ Veritabanı geri yüklendi."
|
||||
|
||||
if [ -n "$UPLOADS_FILE" ]; then
|
||||
docker run --rm -v "$UPLOADS_VOLUME":/data -v "$(cd "$(dirname "$UPLOADS_FILE")" && pwd)":/backup:ro "$HELPER_IMAGE" \
|
||||
sh -c "find /data -mindepth 1 -delete && tar xzf /backup/$(basename "$UPLOADS_FILE") -C /data && chown -R 1001:1001 /data"
|
||||
echo "✓ Yüklenen dosyalar geri yüklendi."
|
||||
fi
|
||||
echo "Uygulamayı yeniden başlatın (Dokploy › Start) ve duman testini çalıştırın."
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Canlı başlatma (Docker entrypoint, uygulamadan önce): replica set kontrolü, bekleyen migration'lar
|
||||
* ve SEED_ADMIN_* tanımlıysa ilk admin hesabı (varsa dokunulmaz). İdempotent; her deploy'da çalışır.
|
||||
* İmajda esbuild ile tek dosyaya derlenir (scripts/bootstrap.mjs). Lokal: npm run bootstrap
|
||||
*/
|
||||
import mongoose from "mongoose";
|
||||
import { getDb, getMongoClient } from "@/lib/db/mongo";
|
||||
import { env } from "@/lib/env";
|
||||
import { runMigrations } from "./migrations";
|
||||
import { closeSeedConnections, runSeed } from "./seed/run";
|
||||
|
||||
async function main() {
|
||||
const e = env();
|
||||
const hello = await getDb().admin().command({ hello: 1 });
|
||||
if (!hello.setName) {
|
||||
throw new Error(
|
||||
"MongoDB replica set olarak çalışmıyor; transaction'lı yazmalar başarısız olur. " +
|
||||
"docs/DEPLOY.md › 'MongoDB replica set' adımlarını uygulayın.",
|
||||
);
|
||||
}
|
||||
if (!hello.isWritablePrimary) throw new Error("MongoDB düğümü PRIMARY değil (yazılamaz).");
|
||||
console.log(`• MongoDB ${hello.setName} PRIMARY`);
|
||||
|
||||
if (e.NODE_ENV === "production" && e.SEED_ADMIN_EMAIL && e.SEED_ADMIN_PASSWORD) {
|
||||
// Production'da seed yalnızca migration + admin yapar (örnek veri oluşturulmaz).
|
||||
await runSeed();
|
||||
} else {
|
||||
await runMigrations();
|
||||
if (e.NODE_ENV === "production")
|
||||
console.log("• SEED_ADMIN_* tanımlı değil; admin kontrolü atlandı.");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error(`✗ Başlatma hatası: ${err instanceof Error ? err.message : err}`);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeSeedConnections();
|
||||
await mongoose.disconnect();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Ek dosya temizliği: npm run cleanup (canlıda günlük zamanlanmış görev; bkz. Faz 15).
|
||||
* 24 saatten eski bağlanmamış ekler ve 30 günden önce silinmiş kayıtların ekleri fiziksel olarak silinir.
|
||||
*/
|
||||
import mongoose from "mongoose";
|
||||
import { purgeAttachments } from "@/features/attachments/service";
|
||||
import { getMongoClient } from "@/lib/db/mongo";
|
||||
|
||||
purgeAttachments()
|
||||
.then(({ removed }) => console.log(`✓ Temizlenen ek dosya: ${removed}`))
|
||||
.catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await mongoose.disconnect();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Uygulama ikonlarını logodan üretir: npm run icons
|
||||
*
|
||||
* Kaynak: public/brand/icon.svg (varsa; önce 512×512 icon.png olarak render edilir)
|
||||
* veya doğrudan public/brand/icon.png (kare, en az 512×512).
|
||||
* Çıktılar (commit edilir):
|
||||
* public/brand/icon.png 512 px (icon.svg varsa ondan üretilir)
|
||||
* src/app/favicon.ico 16/32/48 px (PNG gömülü ICO)
|
||||
* src/app/icon.png 192 px → <link rel="icon">
|
||||
* src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran
|
||||
* public/icons/icon-192.png PWA manifest
|
||||
* public/icons/icon-512.png PWA manifest
|
||||
* public/icons/maskable-512.png PWA manifest (purpose: maskable)
|
||||
*
|
||||
* public/brand/icon-maskable.svg (köşesiz, tam zemin) varsa maskable ve Apple ikonları ondan
|
||||
* üretilir; yoksa icon.png marka mavisi zemine %80 güvenli alanla yerleştirilir.
|
||||
*
|
||||
* Logoyu değiştirdikten sonra bu betiği yeniden çalıştırın.
|
||||
*/
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SOURCE = path.join(ROOT, "public/brand/icon.png");
|
||||
const SOURCE_SVG = path.join(ROOT, "public/brand/icon.svg");
|
||||
const MASKABLE_SVG = path.join(ROOT, "public/brand/icon-maskable.svg");
|
||||
/** Saydamlık kabul etmeyen ikonlarda arka plan: marka mavisi (--primary, açık tema). */
|
||||
const BRAND_BG = "#105bbe";
|
||||
|
||||
async function png(size: number, opts: { flatten?: boolean; padding?: number } = {}) {
|
||||
const inner = Math.round(size * (1 - (opts.padding ?? 0) * 2));
|
||||
const resized = await sharp(SOURCE).resize(inner, inner, { fit: "contain" }).png().toBuffer();
|
||||
let img = sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: opts.flatten || opts.padding ? BRAND_BG : { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
}).composite([{ input: resized, gravity: "center" }]);
|
||||
if (opts.flatten) img = img.flatten({ background: BRAND_BG });
|
||||
return img.png({ compressionLevel: 9 }).toBuffer();
|
||||
}
|
||||
|
||||
/** PNG görüntülerinden ICO dosyası oluşturur (Vista+ tüm tarayıcılar PNG gömülü ICO destekler). */
|
||||
function toIco(images: { size: number; data: Buffer }[]): Buffer {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0); // reserved
|
||||
header.writeUInt16LE(1, 2); // type: icon
|
||||
header.writeUInt16LE(images.length, 4);
|
||||
const entries: Buffer[] = [];
|
||||
let offset = 6 + images.length * 16;
|
||||
for (const { size, data } of images) {
|
||||
const e = Buffer.alloc(16);
|
||||
e.writeUInt8(size >= 256 ? 0 : size, 0);
|
||||
e.writeUInt8(size >= 256 ? 0 : size, 1);
|
||||
e.writeUInt8(0, 2); // palette
|
||||
e.writeUInt8(0, 3); // reserved
|
||||
e.writeUInt16LE(1, 4); // color planes
|
||||
e.writeUInt16LE(32, 6); // bpp
|
||||
e.writeUInt32LE(data.length, 8);
|
||||
e.writeUInt32LE(offset, 12);
|
||||
offset += data.length;
|
||||
entries.push(e);
|
||||
}
|
||||
return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]);
|
||||
}
|
||||
|
||||
async function exists(file: string) {
|
||||
return access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (await exists(SOURCE_SVG)) {
|
||||
// Vektörden yüksek yoğunlukta render: küçük boyutlarda da keskin kenarlar.
|
||||
const png512 = await sharp(SOURCE_SVG, { density: 288 }).resize(512, 512).png().toBuffer();
|
||||
await writeFile(SOURCE, png512);
|
||||
console.log(`✓ public/brand/icon.png ← icon.svg (${png512.length} bayt)`);
|
||||
}
|
||||
const meta = await sharp(SOURCE).metadata();
|
||||
if (!meta.width || !meta.height || meta.width !== meta.height || meta.width < 512) {
|
||||
throw new Error(`${SOURCE} kare ve en az 512×512 olmalı (şu an ${meta.width}×${meta.height}).`);
|
||||
}
|
||||
await mkdir(path.join(ROOT, "public/icons"), { recursive: true });
|
||||
|
||||
const fullBleed = (await exists(MASKABLE_SVG))
|
||||
? (size: number) =>
|
||||
sharp(MASKABLE_SVG, { density: 288 })
|
||||
.resize(size, size)
|
||||
.flatten({ background: BRAND_BG })
|
||||
.png()
|
||||
.toBuffer()
|
||||
: null;
|
||||
|
||||
const out: [string, Buffer][] = [
|
||||
["src/app/icon.png", await png(192)],
|
||||
[
|
||||
"src/app/apple-icon.png",
|
||||
fullBleed ? await fullBleed(180) : await png(180, { flatten: true }),
|
||||
],
|
||||
["public/icons/icon-192.png", await png(192)],
|
||||
["public/icons/icon-512.png", await png(512)],
|
||||
[
|
||||
"public/icons/maskable-512.png",
|
||||
fullBleed ? await fullBleed(512) : await png(512, { padding: 0.1 }),
|
||||
],
|
||||
[
|
||||
"src/app/favicon.ico",
|
||||
toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))),
|
||||
],
|
||||
];
|
||||
for (const [file, data] of out) {
|
||||
await writeFile(path.join(ROOT, file), data);
|
||||
console.log(`✓ ${file} (${data.length} bayt)`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Migration'ları uygular: npm run migrate (canlıda deploy sonrası; bkz. docs/DEPLOY.md, Faz 15).
|
||||
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
|
||||
*/
|
||||
import mongoose from "mongoose";
|
||||
import { getMongoClient } from "@/lib/db/mongo";
|
||||
import { revertMigration, runMigrations } from "./migrations";
|
||||
|
||||
// npm run migrate → bekleyen migration'lar
|
||||
// npm run migrate -- --down <ad> → bir migration'ı geri al
|
||||
const downIndex = process.argv.indexOf("--down");
|
||||
const task =
|
||||
downIndex >= 0
|
||||
? revertMigration(process.argv[downIndex + 1] ?? "")
|
||||
: runMigrations().then(() => {});
|
||||
|
||||
task
|
||||
.catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await mongoose.disconnect();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Hane, üyelik ve denetim kaydı index'leri (Mongoose şemalarındaki tanımlarla). */
|
||||
export const migration: Migration = {
|
||||
name: "001-initial-indexes",
|
||||
description: "households, householdMembers ve auditLogs index'leri",
|
||||
async up() {
|
||||
await HouseholdModel.createIndexes();
|
||||
await HouseholdMemberModel.createIndexes();
|
||||
await AuditLogModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Types } from "mongoose";
|
||||
import { installDefaultCategories } from "@/features/categories/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Hesap ve kategori index'leri + mevcut hanelere varsayılan kategori seti (idempotent). */
|
||||
export const migration: Migration = {
|
||||
name: "002-accounts-categories",
|
||||
description: "accounts/categories index'leri ve mevcut hanelere varsayılan kategoriler",
|
||||
async up() {
|
||||
await AccountModel.createIndexes();
|
||||
await CategoryModel.createIndexes();
|
||||
for (const h of await HouseholdModel.find().select({ _id: 1, createdBy: 1 }).lean()) {
|
||||
await installDefaultCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PayeeModel } from "@/models/payee";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "003-transactions",
|
||||
description: "transactions ve payees index'leri",
|
||||
async up() {
|
||||
await TransactionModel.createIndexes();
|
||||
await PayeeModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AttachmentModel } from "@/models/attachment";
|
||||
import { ReconciliationModel } from "@/models/reconciliation";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "004-attachments-reconciliation",
|
||||
description: "attachments ve reconciliations index'leri; işlemlerde mutabakat alanları",
|
||||
async up(db) {
|
||||
await AttachmentModel.createIndexes();
|
||||
await ReconciliationModel.createIndexes();
|
||||
const tx = db.collection("transactions");
|
||||
// reconciledAt (hiç kullanılmadı) → hesap bazında reconciledAccountIds.
|
||||
await tx.updateMany({ reconciledAt: { $exists: true } }, { $unset: { reconciledAt: "" } });
|
||||
await tx.updateMany(
|
||||
{ reconciledAccountIds: { $exists: false } },
|
||||
{ $set: { reconciledAccountIds: [] } },
|
||||
);
|
||||
await tx.updateMany({ isAdjustment: { $exists: false } }, { $set: { isAdjustment: false } });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { RecurringRuleModel } from "@/models/recurring-rule";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "005-recurring",
|
||||
description: "recurringRules index'leri; işlemlerde tekrar alanları ve benzersiz tekrar index'i",
|
||||
async up(db) {
|
||||
const tx = db.collection("transactions");
|
||||
await tx.updateMany(
|
||||
{ occurrenceIndex: { $exists: false } },
|
||||
{ $set: { occurrenceIndex: null, detached: false, isEstimate: false } },
|
||||
);
|
||||
await RecurringRuleModel.createIndexes();
|
||||
await TransactionModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CardStatementModel } from "@/models/card-statement";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "006-card-statements",
|
||||
description: "statements (kart/KMH dönemleri) index'leri; taksit planı index'i",
|
||||
async up() {
|
||||
await CardStatementModel.createIndexes();
|
||||
await TransactionModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "007-contacts",
|
||||
description: "contacts index'leri; işlemlerde cari alanları ve cari index'i",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany({ contactKind: { $exists: false } }, { $set: { contactKind: null } });
|
||||
await ContactModel.createIndexes();
|
||||
await TransactionModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "008-in-kind",
|
||||
description: "assetPrices ve inKindDebts index'leri; işlemlerde aynî borç alanları",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany(
|
||||
{ inKindDebtId: { $exists: false } },
|
||||
{ $set: { inKindDebtId: null, inKindRole: null } },
|
||||
);
|
||||
await AssetPriceModel.createIndexes();
|
||||
await InKindDebtModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "009-assets",
|
||||
description: "assets ve assetMovements index'leri; işlemlerde varlık alanları",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany({ assetId: { $exists: false } }, { $set: { assetId: null } });
|
||||
await AssetModel.createIndexes();
|
||||
await AssetMovementModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BudgetModel } from "@/models/budget";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "010-budgets",
|
||||
description: "budgets index'i (hane × ay benzersiz)",
|
||||
async up() {
|
||||
await BudgetModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StockCategoryModel } from "@/models/stock-category";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "011-stock",
|
||||
description: "stok koleksiyonları index'leri; mevcut hanelere varsayılan stok kategorileri",
|
||||
async up() {
|
||||
await StockCategoryModel.createIndexes();
|
||||
await ProductTypeModel.createIndexes();
|
||||
await StockMovementModel.createIndexes();
|
||||
const households = await HouseholdModel.find({}).select({ createdBy: 1 }).lean();
|
||||
for (const h of households)
|
||||
await installDefaultStockCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId);
|
||||
},
|
||||
};
|
||||
@@ -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 } },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "015-shopping-lists",
|
||||
description: "alışveriş listeleri (shoppingLists) index'leri",
|
||||
async up() {
|
||||
await ShoppingListModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defaultKakeibo } from "@/features/categories/service";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/**
|
||||
* Kakeibo dört grup (Faz 16): gider kategorilerine `kakeiboGroup` ve `bankCost`. Varsayılan
|
||||
* kategoriler beceri eşlemesine göre doldurulur; kullanıcının kategorileri grupsuz kalır (arayüz
|
||||
* "grup seç" uyarısı gösterir). Alanı zaten olan kategoriye dokunulmaz. Hiçbir kategori silinmez.
|
||||
* Geri alma: alanlar kaldırılır (npm run migrate -- --down 016-kakeibo-groups).
|
||||
*/
|
||||
export const migration: Migration = {
|
||||
name: "016-kakeibo-groups",
|
||||
description: "gider kategorilerine Kakeibo grubu ve banka maliyeti; varsayılanlar dolduruldu",
|
||||
async up() {
|
||||
const defaults = await CategoryModel.find({
|
||||
kind: "expense",
|
||||
defaultKey: { $type: "string" },
|
||||
kakeiboGroup: { $exists: false },
|
||||
})
|
||||
.select({ defaultKey: 1 })
|
||||
.lean<{ _id: unknown; defaultKey: string }[]>();
|
||||
const ops = defaults.map((c) => ({
|
||||
updateOne: { filter: { _id: c._id }, update: { $set: defaultKakeibo(c.defaultKey) } },
|
||||
}));
|
||||
if (ops.length) await CategoryModel.bulkWrite(ops);
|
||||
await CategoryModel.updateMany(
|
||||
{ kakeiboGroup: { $exists: false } },
|
||||
{ $set: { kakeiboGroup: null, bankCost: false } },
|
||||
);
|
||||
},
|
||||
async down() {
|
||||
await CategoryModel.updateMany({}, { $unset: { kakeiboGroup: "", bankCost: "" } });
|
||||
await TransactionModel.updateMany(
|
||||
{ kakeiboGroup: { $exists: true } },
|
||||
{ $unset: { kakeiboGroup: "" } },
|
||||
);
|
||||
await TransactionModel.updateMany(
|
||||
{ "splits.kakeiboGroup": { $exists: true } },
|
||||
{ $unset: { "splits.$[].kakeiboGroup": "" } },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ImportBatchModel } from "@/models/import-batch";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Ekstre içe aktarma (Faz 17): importBatches index'i ve işlemlerde tekrar engeli index'i. */
|
||||
export const migration: Migration = {
|
||||
name: "017-imports",
|
||||
description: "içe aktarma partileri ve işlemlerde tekrar engeli (importKey) index'leri",
|
||||
async up() {
|
||||
await ImportBatchModel.createIndexes();
|
||||
await TransactionModel.createIndexes();
|
||||
},
|
||||
async down(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.dropIndex("householdId_1_importKey_1")
|
||||
.catch(() => {});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { getDb } from "@/lib/db/mongo";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { migration as m001 } from "./001-initial-indexes";
|
||||
import { migration as m002 } from "./002-accounts-categories";
|
||||
import { migration as m003 } from "./003-transactions";
|
||||
import { migration as m004 } from "./004-attachments-reconciliation";
|
||||
import { migration as m005 } from "./005-recurring";
|
||||
import { migration as m006 } from "./006-card-statements";
|
||||
import { migration as m007 } from "./007-contacts";
|
||||
import { migration as m008 } from "./008-in-kind";
|
||||
import { migration as m009 } from "./009-assets";
|
||||
import { migration as m010 } from "./010-budgets";
|
||||
import { migration as m011 } from "./011-stock";
|
||||
import { migration as m012 } from "./012-stock-counts";
|
||||
import { migration as m013 } from "./013-stores-products";
|
||||
import { migration as m014 } from "./014-product-prices";
|
||||
import { migration as m015 } from "./015-shopping-lists";
|
||||
import { migration as m016 } from "./016-kakeibo-groups";
|
||||
import { migration as m017 } from "./017-imports";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
export const MIGRATIONS: Migration[] = [
|
||||
m001,
|
||||
m002,
|
||||
m003,
|
||||
m004,
|
||||
m005,
|
||||
m006,
|
||||
m007,
|
||||
m008,
|
||||
m009,
|
||||
m010,
|
||||
m011,
|
||||
m012,
|
||||
m013,
|
||||
m014,
|
||||
m015,
|
||||
m016,
|
||||
m017,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
/**
|
||||
* Uygulanmamış migration'ları sırayla çalıştırır; her biri bir kez (`migrations` koleksiyonu).
|
||||
* Aynı anda iki çalıştırma olursa benzersiz `name` index'i ikinci kaydı reddeder.
|
||||
*/
|
||||
export async function runMigrations(log: (line: string) => void = console.log): Promise<string[]> {
|
||||
const db = getDb();
|
||||
await connectMongoose();
|
||||
const col = db.collection<{ name: string; appliedAt: Date; durationMs: number }>(COLLECTION);
|
||||
await col.createIndex({ name: 1 }, { unique: true });
|
||||
const applied = new Set(
|
||||
(await col.find({}, { projection: { name: 1 } }).toArray()).map((d) => d.name),
|
||||
);
|
||||
|
||||
const ran: string[] = [];
|
||||
for (const m of MIGRATIONS) {
|
||||
if (applied.has(m.name)) continue;
|
||||
const started = Date.now();
|
||||
await m.up(db);
|
||||
await col.insertOne({ name: m.name, appliedAt: new Date(), durationMs: Date.now() - started });
|
||||
ran.push(m.name);
|
||||
log(`✓ Migration: ${m.name} — ${m.description}`);
|
||||
}
|
||||
if (!ran.length) log("• Bekleyen migration yok.");
|
||||
return ran;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bir migration'ı geri alır (yalnızca `down` tanımlıysa) ve kaydını siler; bir sonraki
|
||||
* `runMigrations` onu yeniden uygular. Kullanım: npm run migrate -- --down 016-kakeibo-groups
|
||||
*/
|
||||
export async function revertMigration(
|
||||
name: string,
|
||||
log: (line: string) => void = console.log,
|
||||
): Promise<void> {
|
||||
const m = MIGRATIONS.find((x) => x.name === name);
|
||||
if (!m) throw new Error(`Migration bulunamadı: ${name}`);
|
||||
if (!m.down) throw new Error(`${name} geri alınamaz (down tanımlı değil).`);
|
||||
const db = getDb();
|
||||
await connectMongoose();
|
||||
const col = db.collection<{ name: string }>(COLLECTION);
|
||||
if (!(await col.findOne({ name }))) throw new Error(`${name} uygulanmamış.`);
|
||||
await m.down(db);
|
||||
await col.deleteOne({ name });
|
||||
log(`↩ Geri alındı: ${name}`);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Db } from "mongodb";
|
||||
|
||||
/**
|
||||
* Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). İleri yönlü; Faz 16'dan
|
||||
* itibaren veri değiştirenler `down` ile geri alınabilir (npm run migrate -- --down <ad>).
|
||||
*/
|
||||
export type Migration = {
|
||||
name: string;
|
||||
description: string;
|
||||
up: (db: Db) => Promise<void>;
|
||||
down?: (db: Db) => Promise<void>;
|
||||
};
|
||||
@@ -1,10 +1,36 @@
|
||||
/**
|
||||
* Seed betiği — Faz 4'te doldurulacak (admin, örnek hane, aile üyeleri ve alan verisi).
|
||||
* Çalıştırma: npm run seed | Sıfırlayıp doldurma: npm run seed:reset
|
||||
* Seed: npm run seed | Sıfırlayıp doldurma: npm run seed:reset (production'da reddedilir)
|
||||
*
|
||||
* Oluşturulanlar: admin (SEED_ADMIN_*), 7 örnek kullanıcı (biri askıda, biri ikinci admin),
|
||||
* 2 hane ve hane içi rolleri. Ayrıntı: scripts/seed/fixtures.ts
|
||||
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
|
||||
*/
|
||||
if (process.env.NODE_ENV === "production" && process.argv.includes("--reset")) {
|
||||
console.error("seed:reset production ortamında çalıştırılamaz.");
|
||||
process.exit(1);
|
||||
import { getMongoClient } from "@/lib/db/mongo";
|
||||
import { env } from "@/lib/env";
|
||||
import { closeSeedConnections, runSeed } from "./seed/run";
|
||||
|
||||
async function main() {
|
||||
const summary = await runSeed({ reset: process.argv.includes("--reset") });
|
||||
const { users, households, memberships, categories, accounts, transactions } = summary.created;
|
||||
console.log(
|
||||
`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik, ${categories} kategori, ${accounts} hesap, ${transactions} işlem.`,
|
||||
);
|
||||
|
||||
// Hesap tablosu yalnızca geliştirme ortamında gösterilir.
|
||||
if (env().NODE_ENV !== "production" && summary.accounts.length) {
|
||||
console.log(
|
||||
"\nGeliştirme hesapları (şifreler: admin → SEED_ADMIN_PASSWORD, diğerleri → SEED_USER_PASSWORD):",
|
||||
);
|
||||
console.table(summary.accounts);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Seed henüz uygulanmadı (Faz 4).");
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeSeedConnections();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { addMovement, createAsset } from "@/features/assets/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import type { MovementKind } from "@/shared/portfolio";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||
|
||||
/**
|
||||
* Örnek varlıklar (Faz 9a). Tutarı boş alımlar fiyat tablosundaki örnek fiyatlardan (seedInKind
|
||||
* önce çalışır) hesaplanır. İdempotent: varlık `seedRef` ile bir kez oluşturulur.
|
||||
*/
|
||||
|
||||
type SeedMovement = {
|
||||
kind: MovementKind;
|
||||
daysAgo: number;
|
||||
quantity: string;
|
||||
amount?: string;
|
||||
account?: string;
|
||||
storage?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type SeedAsset = {
|
||||
ref: string;
|
||||
household: string;
|
||||
by: string;
|
||||
assetKey: AssetKey;
|
||||
name: string;
|
||||
isPrivate?: boolean;
|
||||
note?: string;
|
||||
movements: SeedMovement[];
|
||||
};
|
||||
|
||||
const monthly = (from: number, to: number): SeedMovement[] =>
|
||||
Array.from({ length: to - from + 1 }, (_, i) => ({
|
||||
kind: "buy" as const,
|
||||
daysAgo: (to - i) * 30 + 2,
|
||||
quantity: "1",
|
||||
account: "Ziraat Maaş",
|
||||
storage: "Ev kasası",
|
||||
}));
|
||||
|
||||
const ITEMS: SeedAsset[] = [
|
||||
{
|
||||
ref: "gram-altin",
|
||||
household: "Çakır Ailesi",
|
||||
by: "ayse",
|
||||
assetKey: "gram_gold",
|
||||
name: "Gram altın birikimi",
|
||||
note: "Her ay maaştan 1 gr",
|
||||
movements: [
|
||||
{
|
||||
kind: "buy",
|
||||
daysAgo: 178,
|
||||
quantity: "4",
|
||||
account: "Birikim (Vadeli)",
|
||||
storage: "Ev kasası",
|
||||
note: "İlk alım",
|
||||
},
|
||||
...monthly(1, 5),
|
||||
{ kind: "sell", daysAgo: 12, quantity: "2", account: "Birikim (Vadeli)", note: "Tatil için" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "ziynet",
|
||||
household: "Çakır Ailesi",
|
||||
by: "admin",
|
||||
assetKey: "quarter_gold",
|
||||
name: "Düğün ziynetleri",
|
||||
movements: [
|
||||
{
|
||||
kind: "gift_in",
|
||||
daysAgo: 175,
|
||||
quantity: "6",
|
||||
storage: "Banka kiralık kasa",
|
||||
note: "Düğün takıları",
|
||||
},
|
||||
{ kind: "gift_out", daysAgo: 40, quantity: "1", note: "Yeğenin düğünü" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "emre-dolar",
|
||||
household: "Çakır Ailesi",
|
||||
by: "emre",
|
||||
assetKey: "usd",
|
||||
name: "Dolar",
|
||||
isPrivate: true,
|
||||
movements: [
|
||||
{ kind: "buy", daysAgo: 120, quantity: "200", account: "Ziraat Maaş", storage: "Cüzdan" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "bilezik",
|
||||
household: "Annemler",
|
||||
by: "fatma",
|
||||
assetKey: "bracelet_22k",
|
||||
name: "Bilezikler",
|
||||
note: "Fiyat tablosunda bilezik fiyatı yok (örnek: fiyatsız varlık)",
|
||||
movements: [{ kind: "gift_in", daysAgo: 160, quantity: "40", amount: "120.000" }],
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedAssets(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const x of ITEMS) {
|
||||
const householdId = householdIds.get(x.household)!;
|
||||
const seedRef = `asset:${x.ref}`;
|
||||
if (await AssetModel.exists({ householdId, seedRef })) continue;
|
||||
const by = userIds.get(x.by)!;
|
||||
const hh = String(householdId);
|
||||
const r = await createAsset(
|
||||
by,
|
||||
hh,
|
||||
assetFormSchema.parse({
|
||||
assetKey: x.assetKey,
|
||||
name: x.name,
|
||||
note: x.note ?? "",
|
||||
isPrivate: x.isPrivate ?? false,
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed varlığı oluşturulamadı (${x.ref}): ${r.error}`);
|
||||
await AssetModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const [i, m] of x.movements.entries()) {
|
||||
const account = m.account
|
||||
? String((await AccountModel.findOne({ householdId, name: m.account }).lean())!._id)
|
||||
: "";
|
||||
const res = await addMovement(
|
||||
by,
|
||||
hh,
|
||||
r.data.id,
|
||||
movementFormSchema.parse({
|
||||
kind: m.kind,
|
||||
date: addDays(today, -m.daysAgo),
|
||||
quantity: m.quantity,
|
||||
amount: m.amount ?? "",
|
||||
accountId: account,
|
||||
storage: m.storage ?? "",
|
||||
note: m.note ?? "",
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(`Seed varlık hareketi eklenemedi (${x.ref}): ${res.error}`);
|
||||
const ref = `${seedRef}:${i}`;
|
||||
const mv = await AssetMovementModel.findOneAndUpdate(
|
||||
{ _id: res.data.id },
|
||||
{ $set: { seedRef: ref } },
|
||||
).lean();
|
||||
if (mv?.transactionId)
|
||||
await TransactionModel.updateOne({ _id: mv.transactionId }, { $set: { seedRef: ref } });
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { budgetFromActuals, saveBudget } from "@/features/budgets/service";
|
||||
import { BudgetModel } from "@/models/budget";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { periodOfDate, shiftMonth } from "@/shared/budget";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { budgetLimitsSchema } from "@/shared/schemas/budget";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
|
||||
/**
|
||||
* Örnek bütçeler (Faz 10a): Çakır Ailesi'nde önceki ve bu ay, önceki ayların harcamalarından
|
||||
* oluşturulur. Bu ay "Market" limiti biraz düşürülür ve "Elektrik" alt limiti eklenir; böylece
|
||||
* uyarı ve alt limit örnekleri görünür. İdempotent: bütçesi olan aya dokunulmaz.
|
||||
*/
|
||||
export async function seedBudgets(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
const by = userIds.get("ayse")!;
|
||||
const hh = String(householdId);
|
||||
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
|
||||
const current = periodOfDate(todayIstanbul(), h?.settings?.budgetMonthStartDay ?? 1);
|
||||
let created = 0;
|
||||
for (const month of [shiftMonth(current, -1), current]) {
|
||||
if (await BudgetModel.exists({ householdId, month })) continue;
|
||||
const r = await budgetFromActuals(by, hh, month);
|
||||
if (!r.ok) continue;
|
||||
created++;
|
||||
}
|
||||
if (created && (await BudgetModel.exists({ householdId, month: current }))) {
|
||||
const doc = (await BudgetModel.findOne({ householdId, month: current }).lean())!;
|
||||
const cats = await CategoryModel.find({
|
||||
householdId,
|
||||
defaultKey: { $in: ["market", "faturalar.elektrik"] },
|
||||
}).lean();
|
||||
const id = (key: string) => String(cats.find((c) => c.defaultKey === key)?._id ?? "");
|
||||
const limits = doc.limits.map((l) => ({
|
||||
categoryId: String(l.categoryId),
|
||||
amount: formatMoneyInput(
|
||||
String(l.categoryId) === id("market") ? Math.round(l.amountMinor * 0.7) : l.amountMinor,
|
||||
),
|
||||
}));
|
||||
if (id("faturalar.elektrik"))
|
||||
limits.push({ categoryId: id("faturalar.elektrik"), amount: "900" });
|
||||
await saveBudget(by, hh, budgetLimitsSchema.parse({ month: current, limits }));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { createContact, createContactEntry } from "@/features/contacts/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import type { ContactEntryKind, ContactRelation } from "@/shared/contacts";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
|
||||
|
||||
/**
|
||||
* Örnek cariler (Faz 8a): vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş küçük alacak
|
||||
* ve "yalnızca ben" cari. İdempotent: cari `seedRef` ile; hareketler yalnızca cari ilk
|
||||
* oluşturulduğunda eklenir.
|
||||
*/
|
||||
type SeedContact = {
|
||||
ref: string;
|
||||
household: string;
|
||||
by: string;
|
||||
name: string;
|
||||
relation: ContactRelation;
|
||||
phone?: string;
|
||||
isPrivate?: boolean;
|
||||
dueInDays?: number;
|
||||
reminderDays?: number;
|
||||
entries: {
|
||||
kind: ContactEntryKind;
|
||||
account: string;
|
||||
amountMinor: number;
|
||||
daysAgo: number;
|
||||
description?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const CONTACTS: SeedContact[] = [
|
||||
{
|
||||
ref: "ahmet",
|
||||
household: "Çakır Ailesi",
|
||||
by: "admin",
|
||||
name: "Ahmet Yılmaz",
|
||||
relation: "friend",
|
||||
phone: "0532 000 00 01",
|
||||
dueInDays: 5,
|
||||
reminderDays: 7,
|
||||
entries: [
|
||||
{
|
||||
kind: "lend",
|
||||
account: "Ziraat Maaş",
|
||||
amountMinor: 5_000_00,
|
||||
daysAgo: 70,
|
||||
description: "Araba tamiri için",
|
||||
},
|
||||
{ kind: "collect", account: "Cüzdan", amountMinor: 2_000_00, daysAgo: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "hasan",
|
||||
household: "Çakır Ailesi",
|
||||
by: "admin",
|
||||
name: "Hasan Çakır",
|
||||
relation: "family",
|
||||
dueInDays: 60,
|
||||
entries: [
|
||||
{
|
||||
kind: "borrow",
|
||||
account: "Ziraat Maaş",
|
||||
amountMinor: 20_000_00,
|
||||
daysAgo: 120,
|
||||
description: "Ev eşyası için",
|
||||
},
|
||||
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 60 },
|
||||
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "elif",
|
||||
household: "Çakır Ailesi",
|
||||
by: "ayse",
|
||||
name: "Elif (komşu)",
|
||||
relation: "neighbor",
|
||||
dueInDays: -2,
|
||||
entries: [
|
||||
{
|
||||
kind: "lend",
|
||||
account: "Cüzdan",
|
||||
amountMinor: 300_00,
|
||||
daysAgo: 12,
|
||||
description: "Pazar alışverişi",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "selin",
|
||||
household: "Çakır Ailesi",
|
||||
by: "ayse",
|
||||
name: "Selin (iş arkadaşı)",
|
||||
relation: "work",
|
||||
isPrivate: true,
|
||||
entries: [{ kind: "lend", account: "Cüzdan", amountMinor: 1_000_00, daysAgo: 8 }],
|
||||
},
|
||||
{
|
||||
ref: "ali",
|
||||
household: "Annemler",
|
||||
by: "fatma",
|
||||
name: "Ali Demir",
|
||||
relation: "family",
|
||||
dueInDays: 45,
|
||||
entries: [{ kind: "borrow", account: "Vakıfbank Emekli", amountMinor: 3_000_00, daysAgo: 25 }],
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedContacts(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const c of CONTACTS) {
|
||||
const householdId = householdIds.get(c.household)!;
|
||||
const seedRef = `contact:${c.ref}`;
|
||||
if (await ContactModel.exists({ householdId, seedRef })) continue;
|
||||
const by = userIds.get(c.by)!;
|
||||
const oldest = Math.max(...c.entries.map((e) => e.daysAgo));
|
||||
const r = await createContact(
|
||||
by,
|
||||
String(householdId),
|
||||
contactFormSchema.parse({
|
||||
name: c.name,
|
||||
relation: c.relation,
|
||||
phone: c.phone ?? "",
|
||||
openingDate: addDays(today, -oldest - 1),
|
||||
isPrivate: c.isPrivate ?? false,
|
||||
dueDate: c.dueInDays !== undefined ? addDays(today, c.dueInDays) : null,
|
||||
reminderDays: c.reminderDays ?? 3,
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed carisi oluşturulamadı (${c.name}): ${r.error}`);
|
||||
await ContactModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const e of c.entries) {
|
||||
const account = await AccountModel.findOne({ householdId, name: e.account }).lean();
|
||||
const x = await createContactEntry(
|
||||
by,
|
||||
String(householdId),
|
||||
r.data.id,
|
||||
contactEntrySchema.parse({
|
||||
kind: e.kind,
|
||||
accountId: String(account!._id),
|
||||
amount: formatMoneyInput(e.amountMinor),
|
||||
date: addDays(today, -e.daysAgo),
|
||||
description: e.description ?? "",
|
||||
}),
|
||||
);
|
||||
if (!x.ok) throw new Error(`Seed cari hareketi oluşturulamadı (${c.name}): ${x.error}`);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { HouseholdRole } from "@/features/households/types";
|
||||
import type { AccountType, MealCardProvider } from "@/shared/accounts";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import type { SystemRole } from "@/shared/schemas/common";
|
||||
|
||||
/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */
|
||||
export type SeedUser = {
|
||||
username: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
role: SystemRole;
|
||||
banReason?: string;
|
||||
};
|
||||
|
||||
export const SEED_USERS: SeedUser[] = [
|
||||
{ username: "ayse", name: "Ayşe Çakır", email: "ayse@butcem.local", role: "user" },
|
||||
{ username: "emre", name: "Emre Çakır", role: "user" },
|
||||
{ username: "zeynep", name: "Zeynep Çakır", role: "user" },
|
||||
{ username: "fatma", name: "Fatma Demir", email: "fatma@butcem.local", role: "user" },
|
||||
{ username: "mehmet", name: "Mehmet Demir", email: "mehmet@butcem.local", role: "user" },
|
||||
// İkinci admin: hiçbir haneye üye değil → hane verisini göremez (Faz 0 kararı).
|
||||
{ username: "burak", name: "Burak Öztürk", email: "burak@butcem.local", role: "admin" },
|
||||
{
|
||||
username: "ozan",
|
||||
name: "Ozan Kaya",
|
||||
email: "ozan@butcem.local",
|
||||
role: "user",
|
||||
banReason: "Ev arkadaşlığı sona erdi; erişim kaldırıldı.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Seed admin'in kullanıcı adı (SEED_ADMIN_EMAIL ile oluşturulur). */
|
||||
export const ADMIN_USERNAME = "admin";
|
||||
|
||||
export type SeedHousehold = {
|
||||
name: string;
|
||||
members: { username: string; role: HouseholdRole }[];
|
||||
};
|
||||
|
||||
export const SEED_HOUSEHOLDS: SeedHousehold[] = [
|
||||
{
|
||||
name: "Çakır Ailesi",
|
||||
members: [
|
||||
{ username: ADMIN_USERNAME, role: "owner" },
|
||||
{ username: "ayse", role: "owner" },
|
||||
{ username: "emre", role: "member" },
|
||||
{ username: "zeynep", role: "viewer" },
|
||||
{ username: "ozan", role: "member" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Annemler",
|
||||
members: [
|
||||
{ username: "fatma", role: "owner" },
|
||||
{ username: "mehmet", role: "member" },
|
||||
// Bir kullanıcı birden çok haneye üye olabilir.
|
||||
{ username: "ayse", role: "viewer" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Örnek hesaplar (tutarlar kuruş; kart borcu negatif). Açılış: 6 ay önce. */
|
||||
export type SeedAccount = {
|
||||
household: string;
|
||||
name: string;
|
||||
type: AccountType;
|
||||
institution?: string;
|
||||
openingBalanceMinor: number;
|
||||
color: Swatch;
|
||||
icon: IconKey;
|
||||
owner?: string;
|
||||
isPrivate?: boolean;
|
||||
mealCardProvider?: MealCardProvider;
|
||||
overdraft?: { limitMinor: number; monthlyRateBp: number; statementDay: number };
|
||||
creditCard?: {
|
||||
limitMinor: number;
|
||||
statementDay: number;
|
||||
dueDays: number;
|
||||
paymentAccount: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const SEED_ACCOUNTS: SeedAccount[] = [
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Cüzdan",
|
||||
type: "cash",
|
||||
openingBalanceMinor: 2_350_00,
|
||||
color: "green",
|
||||
icon: "wallet",
|
||||
},
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Ziraat Maaş",
|
||||
type: "bank",
|
||||
institution: "Ziraat Bankası",
|
||||
openingBalanceMinor: 18_420_50,
|
||||
color: "red",
|
||||
icon: "landmark",
|
||||
overdraft: { limitMinor: 10_000_00, monthlyRateBp: 425, statementDay: 1 },
|
||||
},
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Garanti Bonus",
|
||||
type: "credit_card",
|
||||
institution: "Garanti BBVA",
|
||||
openingBalanceMinor: -6_480_75,
|
||||
color: "teal",
|
||||
icon: "credit-card",
|
||||
creditCard: {
|
||||
limitMinor: 60_000_00,
|
||||
statementDay: 29,
|
||||
dueDays: 10,
|
||||
paymentAccount: "Ziraat Maaş",
|
||||
},
|
||||
},
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Birikim (Vadeli)",
|
||||
type: "savings",
|
||||
institution: "Ziraat Bankası",
|
||||
openingBalanceMinor: 45_000_00,
|
||||
color: "indigo",
|
||||
icon: "piggy-bank",
|
||||
},
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Ayşe — Pluxee",
|
||||
type: "meal_card",
|
||||
mealCardProvider: "pluxee",
|
||||
openingBalanceMinor: 3_120_00,
|
||||
color: "lime",
|
||||
icon: "utensils",
|
||||
owner: "ayse",
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
household: "Annemler",
|
||||
name: "Nakit",
|
||||
type: "cash",
|
||||
openingBalanceMinor: 1_500_00,
|
||||
color: "green",
|
||||
icon: "wallet",
|
||||
},
|
||||
{
|
||||
household: "Annemler",
|
||||
name: "Vakıfbank Emekli",
|
||||
type: "bank",
|
||||
institution: "VakıfBank",
|
||||
openingBalanceMinor: 27_800_00,
|
||||
color: "amber",
|
||||
icon: "landmark",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { addContribution, createInKind, savePrice } from "@/features/in-kind/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import type { AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
|
||||
/**
|
||||
* Örnek fiyat tablosu ve aynî kayıtlar (Faz 8b). Fiyatlar örnek amaçlıdır (gerçek piyasa verisi
|
||||
* değildir): son 6 ay, ayda bir kayıt, doğrusal artış. İdempotent.
|
||||
*/
|
||||
|
||||
/** [6 ay önceki alış, bugünkü alış, satış farkı] — birim başına kuruş. */
|
||||
const PRICE_PATH: Partial<Record<AssetKey, [number, number, number]>> = {
|
||||
gram_gold: [4_900_00, 5_600_00, 100_00],
|
||||
quarter_gold: [8_050_00, 9_200_00, 250_00],
|
||||
usd: [40_20, 42_80, 30],
|
||||
eur: [46_90, 49_50, 40],
|
||||
};
|
||||
|
||||
async function seedPrices(householdId: Types.ObjectId, actorId: string, today: string) {
|
||||
for (const [k, [from, to, spread]] of Object.entries(PRICE_PATH)) {
|
||||
const key = k as AssetKey;
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = i === 0 ? today : addMonths(today, -i);
|
||||
if (await AssetPriceModel.exists({ householdId, assetKey: key, date })) continue;
|
||||
const buy = Math.round(to - ((to - from) * i) / 6);
|
||||
await savePrice(
|
||||
actorId,
|
||||
String(householdId),
|
||||
assetPriceFormSchema.parse({
|
||||
assetKey: key,
|
||||
date,
|
||||
buy: formatMoneyInput(buy),
|
||||
sell: formatMoneyInput(buy + spread),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SeedInKind = {
|
||||
ref: string;
|
||||
household: string;
|
||||
contact: string;
|
||||
by: string;
|
||||
direction: "debt" | "receivable";
|
||||
assetKey: AssetKey;
|
||||
quantity: string;
|
||||
daysAgo: number;
|
||||
note?: string;
|
||||
savings?: string;
|
||||
monthlyPlanMinor?: number;
|
||||
contributions?: { from: string; amountMinor: number; daysAgo: number }[];
|
||||
};
|
||||
|
||||
const ITEMS: SeedInKind[] = [
|
||||
{
|
||||
ref: "hasan-altin",
|
||||
household: "Çakır Ailesi",
|
||||
contact: "contact:hasan",
|
||||
by: "admin",
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "5",
|
||||
daysAgo: 95,
|
||||
note: "Düğün için ödünç alınan altın",
|
||||
savings: "Birikim (Vadeli)",
|
||||
monthlyPlanMinor: 4_000_00,
|
||||
contributions: [
|
||||
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 60 },
|
||||
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "elif-usd",
|
||||
household: "Çakır Ailesi",
|
||||
contact: "contact:elif",
|
||||
by: "ayse",
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
daysAgo: 40,
|
||||
},
|
||||
{
|
||||
ref: "ali-ceyrek",
|
||||
household: "Annemler",
|
||||
contact: "contact:ali",
|
||||
by: "fatma",
|
||||
direction: "receivable",
|
||||
assetKey: "quarter_gold",
|
||||
quantity: "2",
|
||||
daysAgo: 150,
|
||||
note: "Torun doğumunda verilen çeyrekler",
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedInKind(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
await seedPrices(householdIds.get("Çakır Ailesi")!, userIds.get("admin")!, today);
|
||||
await seedPrices(householdIds.get("Annemler")!, userIds.get("fatma")!, today);
|
||||
let created = 0;
|
||||
for (const x of ITEMS) {
|
||||
const householdId = householdIds.get(x.household)!;
|
||||
const seedRef = `inkind:${x.ref}`;
|
||||
if (await InKindDebtModel.exists({ householdId, seedRef })) continue;
|
||||
const contact = await ContactModel.findOne({ householdId, seedRef: x.contact }).lean();
|
||||
if (!contact) continue;
|
||||
const account = async (name: string) =>
|
||||
String((await AccountModel.findOne({ householdId, name }).lean())!._id);
|
||||
const by = userIds.get(x.by)!;
|
||||
const r = await createInKind(
|
||||
by,
|
||||
String(householdId),
|
||||
String(contact._id),
|
||||
inKindFormSchema.parse({
|
||||
direction: x.direction,
|
||||
assetKey: x.assetKey,
|
||||
quantity: x.quantity,
|
||||
date: addDays(today, -x.daysAgo),
|
||||
note: x.note ?? "",
|
||||
savingsAccountId: x.savings ? await account(x.savings) : "",
|
||||
monthlyPlan: x.monthlyPlanMinor ? formatMoneyInput(x.monthlyPlanMinor) : "",
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed aynî kaydı oluşturulamadı (${x.ref}): ${r.error}`);
|
||||
await InKindDebtModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const c of x.contributions ?? []) {
|
||||
const res = await addContribution(
|
||||
by,
|
||||
String(householdId),
|
||||
r.data.id,
|
||||
contributionSchema.parse({
|
||||
fromAccountId: await account(c.from),
|
||||
amount: formatMoneyInput(c.amountMinor),
|
||||
date: addDays(today, -c.daysAgo),
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(`Seed birikimi oluşturulamadı (${x.ref}): ${res.error}`);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { createTransaction } from "@/features/transactions/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
|
||||
/**
|
||||
* Örnek taksitli kart harcamaları (Faz 7b): bir kısmı gerçekleşmiş, kalanı planlı taksitler.
|
||||
* İdempotent: aynı satıcıyla taksit planı varsa atlanır.
|
||||
*/
|
||||
const PURCHASES = [
|
||||
{
|
||||
payee: "Hepsiburada",
|
||||
description: "Çamaşır makinesi",
|
||||
amountMinor: 18_000_00,
|
||||
count: 9,
|
||||
daysAgo: 75,
|
||||
category: "ev",
|
||||
by: "admin",
|
||||
},
|
||||
{
|
||||
payee: "Teknosa",
|
||||
description: "Telefon",
|
||||
amountMinor: 12_000_00,
|
||||
count: 6,
|
||||
daysAgo: 20,
|
||||
category: "diger-gider",
|
||||
by: "emre",
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedInstallments(
|
||||
householdId: Types.ObjectId,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
const card = await AccountModel.findOne({ householdId, name: "Garanti Bonus" }).lean();
|
||||
if (!card) return 0;
|
||||
let created = 0;
|
||||
for (const p of PURCHASES) {
|
||||
const exists = await TransactionModel.exists({
|
||||
householdId,
|
||||
payeeName: p.payee,
|
||||
"installment.planId": { $type: "objectId" },
|
||||
});
|
||||
if (exists) continue;
|
||||
const category = await CategoryModel.findOne({ householdId, defaultKey: p.category }).lean();
|
||||
const r = await createTransaction(userIds.get(p.by)!, String(householdId), {
|
||||
type: "expense",
|
||||
amount: p.amountMinor,
|
||||
date: addDays(today, -p.daysAgo),
|
||||
accountId: String(card._id),
|
||||
toAccountId: null,
|
||||
categoryId: String(category!._id),
|
||||
splits: [],
|
||||
payee: p.payee,
|
||||
description: p.description,
|
||||
tags: [],
|
||||
installments: p.count,
|
||||
attachmentIds: [],
|
||||
});
|
||||
if (!r.ok) throw new Error(`Seed taksiti oluşturulamadı (${p.payee}): ${r.error}`);
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { createRule } from "@/features/recurring/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { RecurringRuleModel } from "@/models/recurring-rule";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
|
||||
/**
|
||||
* Örnek düzenli işlemler (Faz 7a). Seed işlemleri bugüne kadarki günleri doldurur; kurallar bir
|
||||
* sonraki gerçekleşme gününden başlar (çakışma yok). "Spor salonu" kasıtlı olarak 3 gün önce
|
||||
* başlar: vadesi geçmiş planlı kayıt örneği. İdempotent (`seedRef`).
|
||||
*/
|
||||
type SeedRule = {
|
||||
ref: string;
|
||||
by: string;
|
||||
type: "income" | "expense" | "transfer";
|
||||
amountMinor: number;
|
||||
account: string;
|
||||
category?: string;
|
||||
payee: string;
|
||||
/** Ayın günü (bir sonraki gerçekleşme) veya bugüne göre gün farkı. */
|
||||
day?: number;
|
||||
offsetDays?: number;
|
||||
isEstimate?: boolean;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
const RULES: Record<string, SeedRule[]> = {
|
||||
"Çakır Ailesi": [
|
||||
{
|
||||
ref: "maas",
|
||||
by: "admin",
|
||||
type: "income",
|
||||
amountMinor: 52_000_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "maas",
|
||||
payee: "Anadolu Yazılım A.Ş.",
|
||||
day: 15,
|
||||
},
|
||||
{
|
||||
ref: "kira",
|
||||
by: "admin",
|
||||
type: "expense",
|
||||
amountMinor: 17_500_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "kira",
|
||||
payee: "Ev sahibi",
|
||||
day: 3,
|
||||
},
|
||||
{
|
||||
ref: "aidat",
|
||||
by: "admin",
|
||||
type: "expense",
|
||||
amountMinor: 1_250_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "aidat",
|
||||
payee: "Site yönetimi",
|
||||
day: 5,
|
||||
},
|
||||
{
|
||||
ref: "elektrik",
|
||||
by: "ayse",
|
||||
type: "expense",
|
||||
amountMinor: 1_200_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.elektrik",
|
||||
payee: "Enerjisa",
|
||||
day: 10,
|
||||
isEstimate: true,
|
||||
},
|
||||
{
|
||||
ref: "su",
|
||||
by: "ayse",
|
||||
type: "expense",
|
||||
amountMinor: 450_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.su",
|
||||
payee: "İSKİ",
|
||||
day: 12,
|
||||
isEstimate: true,
|
||||
},
|
||||
{
|
||||
ref: "dogalgaz",
|
||||
by: "ayse",
|
||||
type: "expense",
|
||||
amountMinor: 2_500_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.dogalgaz",
|
||||
payee: "İGDAŞ",
|
||||
day: 14,
|
||||
isEstimate: true,
|
||||
},
|
||||
{
|
||||
ref: "internet",
|
||||
by: "admin",
|
||||
type: "expense",
|
||||
amountMinor: 499_90,
|
||||
account: "Garanti Bonus",
|
||||
category: "faturalar.internet",
|
||||
payee: "Türk Telekom",
|
||||
day: 20,
|
||||
},
|
||||
{
|
||||
ref: "netflix",
|
||||
by: "admin",
|
||||
type: "expense",
|
||||
amountMinor: 229_99,
|
||||
account: "Garanti Bonus",
|
||||
category: "abonelikler",
|
||||
payee: "Netflix",
|
||||
day: 8,
|
||||
},
|
||||
{
|
||||
ref: "spor",
|
||||
by: "emre",
|
||||
type: "expense",
|
||||
amountMinor: 1_250_00,
|
||||
account: "Garanti Bonus",
|
||||
category: "eglence",
|
||||
payee: "Spor salonu",
|
||||
offsetDays: -3,
|
||||
count: 12,
|
||||
},
|
||||
],
|
||||
Annemler: [
|
||||
{
|
||||
ref: "emekli",
|
||||
by: "fatma",
|
||||
type: "income",
|
||||
amountMinor: 24_350_00,
|
||||
account: "Vakıfbank Emekli",
|
||||
category: "maas",
|
||||
payee: "SGK",
|
||||
day: 17,
|
||||
},
|
||||
{
|
||||
ref: "elektrik",
|
||||
by: "fatma",
|
||||
type: "expense",
|
||||
amountMinor: 700_00,
|
||||
account: "Vakıfbank Emekli",
|
||||
category: "faturalar.elektrik",
|
||||
payee: "Enerjisa",
|
||||
day: 11,
|
||||
isEstimate: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Bugünden sonraki ilk "ayın N. günü". */
|
||||
function nextMonthDay(today: string, day: number): string {
|
||||
const thisMonth = addMonths(today, 0, day);
|
||||
return thisMonth > today ? thisMonth : addMonths(today, 1, day);
|
||||
}
|
||||
|
||||
export async function seedRecurring(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const [household, rules] of Object.entries(RULES)) {
|
||||
const householdId = householdIds.get(household)!;
|
||||
const [accounts, categories] = await Promise.all([
|
||||
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
|
||||
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
|
||||
.select({ defaultKey: 1 })
|
||||
.lean(),
|
||||
]);
|
||||
for (const r of rules) {
|
||||
const seedRef = `rule:${r.ref}`;
|
||||
if (await RecurringRuleModel.exists({ householdId, seedRef })) continue;
|
||||
const input: RecurringRuleFormInput = {
|
||||
type: r.type,
|
||||
amount: formatMoneyInput(r.amountMinor),
|
||||
isEstimate: r.isEstimate ?? false,
|
||||
accountId: String(accounts.find((a) => a.name === r.account)!._id),
|
||||
categoryId: r.category
|
||||
? String(categories.find((c) => c.defaultKey === r.category)!._id)
|
||||
: null,
|
||||
payee: r.payee,
|
||||
startDate: r.day ? nextMonthDay(today, r.day) : addDays(today, r.offsetDays ?? 0),
|
||||
frequency: "monthly",
|
||||
endKind: r.count ? "count" : "never",
|
||||
count: r.count ?? null,
|
||||
};
|
||||
const result = await createRule(
|
||||
userIds.get(r.by)!,
|
||||
String(householdId),
|
||||
recurringRuleFormSchema.parse(input),
|
||||
);
|
||||
if (!result.ok) throw new Error(`Seed kuralı oluşturulamadı (${seedRef}): ${result.error}`);
|
||||
await RecurringRuleModel.updateOne({ _id: result.data.id }, { $set: { seedRef } });
|
||||
created++;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { authErrorCode } from "@/lib/auth-errors";
|
||||
import { getDb, getMongoClient } from "@/lib/db/mongo";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import { closeSeedConnections, runSeed } from "./run";
|
||||
import { SEED_HOUSEHOLDS, SEED_USERS } from "./fixtures";
|
||||
|
||||
const silent = () => {};
|
||||
|
||||
beforeAll(async () => {
|
||||
expect(getDb().databaseName).toBe("butcem_test");
|
||||
await getDb().dropDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await getDb().dropDatabase();
|
||||
await closeSeedConnections();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
|
||||
describe("seed", () => {
|
||||
it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => {
|
||||
const summary = await runSeed({ log: silent });
|
||||
const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0);
|
||||
expect(summary.created).toMatchObject({
|
||||
users: SEED_USERS.length + 1,
|
||||
households: SEED_HOUSEHOLDS.length,
|
||||
memberships: memberCount,
|
||||
});
|
||||
expect(summary.created.categories).toBeGreaterThan(40);
|
||||
expect(summary.created.accounts).toBe(7);
|
||||
expect(summary.created.transactions).toBeGreaterThan(150);
|
||||
expect(summary.created.assets).toBe(4);
|
||||
expect(summary.created.budgets).toBe(2);
|
||||
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 HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||
});
|
||||
|
||||
it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => {
|
||||
const summary = await runSeed({ log: silent });
|
||||
expect(summary.created).toEqual({
|
||||
users: 0,
|
||||
households: 0,
|
||||
memberships: 0,
|
||||
categories: 0,
|
||||
accounts: 0,
|
||||
transactions: 0,
|
||||
recurringRules: 0,
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
stores: 0,
|
||||
products: 0,
|
||||
prices: 0,
|
||||
shoppingLists: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
});
|
||||
|
||||
it("aynı aydaki transferlerin hepsi oluşur (seedRef çakışması yok)", async () => {
|
||||
const tx = getDb().collection("transactions");
|
||||
const months = new Set(
|
||||
(await tx.find({ description: "ATM" }).toArray()).map((t) => t.seedRef.split(":")[1]),
|
||||
);
|
||||
expect(months.size).toBeGreaterThanOrEqual(5);
|
||||
// Her ay ATM'nin yanında kart ödemesi ve birikim de var (ayın 7'si geçmişse).
|
||||
expect(await tx.countDocuments({ description: "Kart borcu ödemesi" })).toBeGreaterThanOrEqual(
|
||||
months.size - 1,
|
||||
);
|
||||
expect(await tx.countDocuments({ description: "Aylık birikim" })).toBeGreaterThanOrEqual(
|
||||
months.size - 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("roller ve askı durumu doğru", async () => {
|
||||
const users = await getDb()
|
||||
.collection("user")
|
||||
.find({}, { projection: { username: 1, role: 1, banned: 1 } })
|
||||
.toArray();
|
||||
const by = new Map(users.map((u) => [u.username as string, u]));
|
||||
expect(by.get("admin")?.role).toBe("admin");
|
||||
expect(by.get("burak")?.role).toBe("admin");
|
||||
expect(by.get("ozan")?.banned).toBe(true);
|
||||
expect(users.filter((u) => u.banned)).toHaveLength(1);
|
||||
|
||||
// Bir kullanıcı birden çok hanede, farklı rollerle.
|
||||
const ayse = by.get("ayse")!;
|
||||
const roles = await HouseholdMemberModel.find({ userId: ayse._id }).lean();
|
||||
expect(roles.map((m) => m.role).sort()).toEqual(["owner", "viewer"]);
|
||||
|
||||
// Hanesi olmayan admin.
|
||||
expect(await HouseholdMemberModel.countDocuments({ userId: by.get("burak")!._id })).toBe(0);
|
||||
});
|
||||
|
||||
it("e-postası olmayan kullanıcı kullanıcı adıyla girer; askıdaki kullanıcı giremez", async () => {
|
||||
const api = getAuth().api;
|
||||
const emre = await api.signInUsername({
|
||||
body: { username: "emre", password: "Test-User-12345" },
|
||||
});
|
||||
expect(emre.user.name).toBe("Emre Çakır");
|
||||
|
||||
const error = await api
|
||||
.signInUsername({ body: { username: "ozan", password: "Test-User-12345" } })
|
||||
.then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(authErrorCode(error)).toBe("BANNED_USER");
|
||||
});
|
||||
|
||||
it("reset koleksiyonları temizleyip yeniden doldurur", async () => {
|
||||
const summary = await runSeed({ reset: true, log: silent });
|
||||
expect(summary.created.users).toBe(SEED_USERS.length + 1);
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import mongoose, { Types } from "mongoose";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { getDb } from "@/lib/db/mongo";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { env } from "@/lib/env";
|
||||
import { getStorage } from "@/lib/storage";
|
||||
import { installDefaultCategories } from "@/features/categories/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import { storedEmail } from "@/shared/account-email";
|
||||
import { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedAssets } from "./assets";
|
||||
import { seedBudgets } from "./budgets";
|
||||
import { seedProductPrices, seedShopping, seedShoppingLists } from "./shopping";
|
||||
import { seedStock } from "./stock";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
import { seedRecurring } from "./recurring";
|
||||
import { seedTransactions } from "./transactions";
|
||||
import {
|
||||
ADMIN_USERNAME,
|
||||
SEED_ACCOUNTS,
|
||||
SEED_HOUSEHOLDS,
|
||||
SEED_USERS,
|
||||
type SeedUser,
|
||||
} from "./fixtures";
|
||||
|
||||
/**
|
||||
* İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir);
|
||||
* tekrar çalıştırınca kopya oluşturmaz (kullanıcı adı / hane adı / üyelik çiftine göre).
|
||||
* Production'da yalnızca admin oluşturulur; örnek kullanıcı ve hane oluşturulmaz.
|
||||
*/
|
||||
|
||||
export type SeedOptions = { reset?: boolean; log?: (line: string) => void };
|
||||
|
||||
export type SeedAccountRow = {
|
||||
rol: string;
|
||||
kullaniciAdi: string;
|
||||
eposta: string;
|
||||
durum: string;
|
||||
haneler: string;
|
||||
};
|
||||
|
||||
export type SeedSummary = {
|
||||
created: {
|
||||
users: number;
|
||||
households: number;
|
||||
memberships: number;
|
||||
categories: number;
|
||||
accounts: number;
|
||||
transactions: number;
|
||||
recurringRules: number;
|
||||
installmentPlans: number;
|
||||
contacts: number;
|
||||
inKind: number;
|
||||
assets: number;
|
||||
budgets: number;
|
||||
stock: number;
|
||||
stores: number;
|
||||
products: number;
|
||||
prices: number;
|
||||
shoppingLists: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
|
||||
const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"];
|
||||
const DOMAIN_COLLECTIONS = [
|
||||
"households",
|
||||
"householdMembers",
|
||||
"auditLogs",
|
||||
"categories",
|
||||
"accounts",
|
||||
"transactions",
|
||||
"payees",
|
||||
"attachments",
|
||||
"reconciliations",
|
||||
"recurringRules",
|
||||
"statements",
|
||||
"contacts",
|
||||
"assetPrices",
|
||||
"inKindDebts",
|
||||
"assets",
|
||||
"assetMovements",
|
||||
"budgets",
|
||||
"stockCategories",
|
||||
"productTypes",
|
||||
"stockMovements",
|
||||
"stockCounts",
|
||||
"stores",
|
||||
"products",
|
||||
"productPrices",
|
||||
"shoppingLists",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
|
||||
async function findUserIdByUsername(username: string): Promise<string | null> {
|
||||
const doc = await getDb()
|
||||
.collection("user")
|
||||
.findOne({ username }, { projection: { _id: 1 } });
|
||||
return doc ? String(doc._id) : null;
|
||||
}
|
||||
|
||||
/** Admin olarak giriş yapıp oturum başlıklarını döner (ban gibi oturum isteyen admin API'leri için). */
|
||||
async function adminHeaders(api: AuthApi, password: string): Promise<Headers> {
|
||||
const { headers } = await api.signInUsername({
|
||||
body: { username: ADMIN_USERNAME, password },
|
||||
returnHeaders: true,
|
||||
});
|
||||
const cookie = headers
|
||||
.getSetCookie()
|
||||
.map((c) => c.split(";")[0])
|
||||
.join("; ");
|
||||
return new Headers({ cookie });
|
||||
}
|
||||
|
||||
export async function runSeed({
|
||||
reset = false,
|
||||
log = console.log,
|
||||
}: SeedOptions = {}): Promise<SeedSummary> {
|
||||
const e = env();
|
||||
const isProd = e.NODE_ENV === "production";
|
||||
if (reset && isProd) throw new Error("seed:reset production ortamında çalıştırılamaz.");
|
||||
if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) {
|
||||
throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı.");
|
||||
}
|
||||
if (!isProd && !e.SEED_USER_PASSWORD) {
|
||||
throw new Error("SEED_USER_PASSWORD tanımlı olmalı (örnek kullanıcıların ortak şifresi).");
|
||||
}
|
||||
|
||||
const api = getAuth().api;
|
||||
const db = getDb();
|
||||
await connectMongoose();
|
||||
const summary: SeedSummary = {
|
||||
created: {
|
||||
users: 0,
|
||||
households: 0,
|
||||
memberships: 0,
|
||||
categories: 0,
|
||||
accounts: 0,
|
||||
transactions: 0,
|
||||
recurringRules: 0,
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
stores: 0,
|
||||
products: 0,
|
||||
prices: 0,
|
||||
shoppingLists: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
if (reset) {
|
||||
// Ek dosyaların diskteki kopyaları da silinsin (yetim dosya kalmasın).
|
||||
const storage = getStorage();
|
||||
for await (const a of db
|
||||
.collection("attachments")
|
||||
.find({}, { projection: { storageKey: 1, thumbKey: 1 } })) {
|
||||
await storage.delete(String(a.storageKey));
|
||||
if (a.thumbKey) await storage.delete(String(a.thumbKey));
|
||||
}
|
||||
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
|
||||
await db.collection(name).deleteMany({});
|
||||
}
|
||||
log("• Kimlik doğrulama ve hane koleksiyonları temizlendi.");
|
||||
}
|
||||
|
||||
// Index'ler ve şema değişiklikleri (production'da Mongoose autoIndex kapalı).
|
||||
await runMigrations(log);
|
||||
|
||||
// 1) Admin
|
||||
const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase();
|
||||
let adminId = await findUserIdByUsername(ADMIN_USERNAME);
|
||||
if (!adminId) {
|
||||
const res = await api.createUser({
|
||||
body: {
|
||||
email: adminEmail,
|
||||
password: e.SEED_ADMIN_PASSWORD,
|
||||
name: "Yönetici",
|
||||
role: "admin",
|
||||
data: { username: ADMIN_USERNAME },
|
||||
},
|
||||
});
|
||||
adminId = res.user.id;
|
||||
summary.created.users++;
|
||||
log(`✓ Admin oluşturuldu: ${adminEmail} (kullanıcı adı: ${ADMIN_USERNAME})`);
|
||||
} else {
|
||||
log(`• Admin zaten var: ${ADMIN_USERNAME}`);
|
||||
}
|
||||
|
||||
if (isProd) {
|
||||
log("• Production: örnek kullanıcı ve hane oluşturulmadı.");
|
||||
return summary;
|
||||
}
|
||||
|
||||
// 2) Örnek kullanıcılar
|
||||
const ids = new Map<string, string>([[ADMIN_USERNAME, adminId]]);
|
||||
const toBan: SeedUser[] = [];
|
||||
for (const u of SEED_USERS) {
|
||||
let id = await findUserIdByUsername(u.username);
|
||||
if (!id) {
|
||||
const res = await api.createUser({
|
||||
body: {
|
||||
email: storedEmail(u.username, u.email),
|
||||
password: e.SEED_USER_PASSWORD!,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
data: { username: u.username },
|
||||
},
|
||||
});
|
||||
id = res.user.id;
|
||||
summary.created.users++;
|
||||
log(`✓ Kullanıcı: ${u.name} (@${u.username})`);
|
||||
}
|
||||
ids.set(u.username, id);
|
||||
if (u.banReason) toBan.push(u);
|
||||
}
|
||||
|
||||
// 3) Askıdaki kullanıcı(lar) — admin API'si oturum ister.
|
||||
const pendingBans: { user: SeedUser; id: string }[] = [];
|
||||
for (const u of toBan) {
|
||||
const id = ids.get(u.username)!;
|
||||
const doc = await db
|
||||
.collection("user")
|
||||
.findOne({ _id: new Types.ObjectId(id) }, { projection: { banned: 1 } });
|
||||
if (!doc?.banned) pendingBans.push({ user: u, id });
|
||||
}
|
||||
if (pendingBans.length) {
|
||||
const headers = await adminHeaders(api, e.SEED_ADMIN_PASSWORD);
|
||||
for (const { user, id } of pendingBans) {
|
||||
await api.banUser({ headers, body: { userId: id, banReason: user.banReason } });
|
||||
log(`✓ Askıya alındı: @${user.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Haneler ve üyelikler
|
||||
const memberships = new Map<string, string[]>();
|
||||
const actor = new Types.ObjectId(adminId);
|
||||
for (const h of SEED_HOUSEHOLDS) {
|
||||
let household = await HouseholdModel.findOne({ name: h.name });
|
||||
if (!household) {
|
||||
household = await HouseholdModel.create({ name: h.name, createdBy: actor, updatedBy: actor });
|
||||
summary.created.households++;
|
||||
log(`✓ Hane: ${h.name}`);
|
||||
}
|
||||
for (const m of h.members) {
|
||||
const userId = new Types.ObjectId(ids.get(m.username)!);
|
||||
const result = await HouseholdMemberModel.updateOne(
|
||||
{ householdId: household._id, userId },
|
||||
{
|
||||
$setOnInsert: { householdId: household._id, userId, createdBy: actor },
|
||||
$set: { role: m.role, updatedBy: actor },
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
if (result.upsertedCount) summary.created.memberships++;
|
||||
memberships.set(m.username, [
|
||||
...(memberships.get(m.username) ?? []),
|
||||
`${h.name} (${m.role})`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Varsayılan kategoriler ve örnek hesaplar
|
||||
const householdIds = new Map<string, Types.ObjectId>();
|
||||
for (const h of SEED_HOUSEHOLDS) {
|
||||
const doc = await HouseholdModel.findOne({ name: h.name }).select({ _id: 1 }).lean();
|
||||
householdIds.set(h.name, doc!._id);
|
||||
summary.created.categories += await installDefaultCategories(doc!._id, actor);
|
||||
}
|
||||
const openingDate = new Date(Date.now() - 183 * 24 * 60 * 60 * 1000);
|
||||
for (const [order, a] of SEED_ACCOUNTS.entries()) {
|
||||
const householdId = householdIds.get(a.household)!;
|
||||
if (await AccountModel.exists({ householdId, name: a.name })) continue;
|
||||
const payer = a.creditCard
|
||||
? await AccountModel.findOne({ householdId, name: a.creditCard.paymentAccount })
|
||||
.select({ _id: 1 })
|
||||
.lean()
|
||||
: null;
|
||||
await AccountModel.create({
|
||||
householdId,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
institution: a.institution ?? "",
|
||||
mealCardProvider: a.mealCardProvider ?? null,
|
||||
openingBalanceMinor: a.openingBalanceMinor,
|
||||
openingDate,
|
||||
color: a.color,
|
||||
icon: a.icon,
|
||||
order,
|
||||
ownerUserId: a.owner ? new Types.ObjectId(ids.get(a.owner)!) : null,
|
||||
isPrivate: a.isPrivate ?? false,
|
||||
overdraft: a.overdraft ? { enabled: true, ...a.overdraft } : null,
|
||||
creditCard: a.creditCard
|
||||
? {
|
||||
limitMinor: a.creditCard.limitMinor,
|
||||
statementDay: a.creditCard.statementDay,
|
||||
dueRule: { kind: "daysAfter", value: a.creditCard.dueDays },
|
||||
paymentAccountId: payer?._id ?? null,
|
||||
}
|
||||
: null,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
summary.created.accounts++;
|
||||
}
|
||||
if (summary.created.categories) log(`✓ Varsayılan kategoriler: ${summary.created.categories}`);
|
||||
if (summary.created.accounts) log(`✓ Örnek hesaplar: ${summary.created.accounts}`);
|
||||
|
||||
// 6) Son 6 ayın örnek işlemleri (faker.seed(42), idempotent)
|
||||
const userIdMap = new Map([...ids.entries()]);
|
||||
summary.created.transactions = await seedTransactions(householdIds, userIdMap);
|
||||
if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`);
|
||||
summary.created.recurringRules = await seedRecurring(householdIds, userIdMap);
|
||||
if (summary.created.recurringRules)
|
||||
log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`);
|
||||
summary.created.installmentPlans = await seedInstallments(
|
||||
householdIds.get("Çakır Ailesi")!,
|
||||
userIdMap,
|
||||
);
|
||||
if (summary.created.installmentPlans)
|
||||
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
|
||||
summary.created.contacts = await seedContacts(householdIds, userIdMap);
|
||||
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
||||
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
||||
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
||||
summary.created.assets = await seedAssets(householdIds, userIdMap);
|
||||
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
|
||||
summary.created.budgets = await seedBudgets(householdIds, userIdMap);
|
||||
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
|
||||
summary.created.stock = await seedStock(householdIds, userIdMap);
|
||||
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}`);
|
||||
summary.created.shoppingLists = await seedShoppingLists(householdIds, userIdMap);
|
||||
if (summary.created.shoppingLists)
|
||||
log(`✓ Örnek alışveriş listeleri: ${summary.created.shoppingLists}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
{ username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const },
|
||||
...SEED_USERS,
|
||||
];
|
||||
summary.accounts = all.map((u) => ({
|
||||
rol: u.role,
|
||||
kullaniciAdi: u.username,
|
||||
eposta: u.email ?? "—",
|
||||
durum: "banReason" in u && u.banReason ? "askıda" : "aktif",
|
||||
haneler: (memberships.get(u.username) ?? ["—"]).join(", "),
|
||||
}));
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Seed sonrası bağlantıları kapatır (CLI ve testler). */
|
||||
export async function closeSeedConnections() {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import { Types } from "mongoose";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductPriceModel } from "@/models/product-price";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
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;
|
||||
}
|
||||
|
||||
type SeedListItem =
|
||||
| { type: string; qty?: number }
|
||||
| { product: string; qty?: number }
|
||||
| { text: string; qty?: number; unit?: string; note?: string };
|
||||
|
||||
/**
|
||||
* Örnek alışveriş listeleri (Faz 13). Eşiğin altındaki ürünler (mercimek, tablet deterjan, diş
|
||||
* macunu) listelere eklenmez; liste sayfasında öneri olarak görünür. Domatesin fiyatı yok:
|
||||
* akıllı öneride "fiyatı bilinmeyen" örneği.
|
||||
*/
|
||||
const SHOPPING_LISTS: { ref: string; name: string; note?: string; items: SeedListItem[] }[] = [
|
||||
{
|
||||
ref: "haftalik",
|
||||
name: "Haftalık market",
|
||||
items: [
|
||||
{ type: "sut", qty: 4 * K },
|
||||
{ type: "yumurta", qty: 30 * K },
|
||||
{ type: "pirinc", qty: 2_500 },
|
||||
{ type: "beyaz-peynir", qty: 500 },
|
||||
{ type: "domates", qty: 2 * K },
|
||||
{ type: "toz-deterjan", qty: 40 * K },
|
||||
{ type: "tuvalet-kagidi", qty: 32 * K },
|
||||
{ product: "yumos-1440", qty: 1 * K },
|
||||
{ text: "Doğum günü mumu", qty: 1 * K, unit: "paket", note: "Rakam mum: 7" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "ay-sonu",
|
||||
name: "Ay sonu toptan",
|
||||
note: "Metro'ya arabayla.",
|
||||
items: [{ type: "camasir-suyu", qty: 4 * K }],
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedShoppingLists(
|
||||
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 [types, products] = await Promise.all([
|
||||
ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1 })
|
||||
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||
ProductModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1 })
|
||||
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||
]);
|
||||
const typeByRef = new Map(types.map((x) => [x.seedRef, x._id]));
|
||||
const productByRef = new Map(products.map((x) => [x.seedRef, x._id]));
|
||||
let created = 0;
|
||||
for (const l of SHOPPING_LISTS) {
|
||||
if (await ShoppingListModel.exists({ householdId, seedRef: l.ref })) continue;
|
||||
const items = l.items.flatMap((i): Record<string, unknown>[] => {
|
||||
const common = {
|
||||
_id: new Types.ObjectId(),
|
||||
quantity: i.qty ?? null,
|
||||
source: "manual",
|
||||
addedBy: actor,
|
||||
addedAt: new Date(),
|
||||
};
|
||||
if ("type" in i) {
|
||||
const id = typeByRef.get(i.type);
|
||||
return id ? [{ ...common, kind: "type", productTypeId: id }] : [];
|
||||
}
|
||||
if ("product" in i) {
|
||||
const id = productByRef.get(i.product);
|
||||
return id ? [{ ...common, kind: "product", productId: id }] : [];
|
||||
}
|
||||
return [{ ...common, kind: "text", text: i.text, unit: i.unit ?? "", note: i.note ?? "" }];
|
||||
});
|
||||
await ShoppingListModel.create({
|
||||
householdId,
|
||||
name: l.name,
|
||||
note: l.note ?? "",
|
||||
items,
|
||||
seedRef: l.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { Types } from "mongoose";
|
||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StockCategoryModel } from "@/models/stock-category";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Örnek ev stoğu (Faz 11). Varsayılan stok kategorileri her haneye; Çakır Ailesi'ne ürün tipleri
|
||||
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, periyodik sayım, SKT).
|
||||
* Sayım: Mutfak 15 günde bir (son sayım 21 gün önce → gecikmiş), Genel Temizlik 30 günde bir
|
||||
* (son sayım 14 gün önce). Deterministik; idempotent: ürün tipi `seedRef` ile bir kez
|
||||
* oluşturulur, sayım oturumları `seedRef` ile paylaşılır. Miktarlar binde bir.
|
||||
*/
|
||||
|
||||
type SeedProduct = {
|
||||
ref: string;
|
||||
category: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
target: number;
|
||||
/** Alımda paket miktarı (alım = hedefe tamamlayan paket sayısı × paket). */
|
||||
pack: number;
|
||||
perDay: number;
|
||||
location: string;
|
||||
step?: number;
|
||||
threshold?: number;
|
||||
shelfDays?: number;
|
||||
/** Her n. alımdan sonra fire (oran %). */
|
||||
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
|
||||
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
||||
stopRefillDaysAgo?: number;
|
||||
/** Ağır/hacimli (akıllı öneride taşıma cezası). */
|
||||
bulky?: boolean;
|
||||
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||||
countDrift?: { everyCounts: number; pct: number };
|
||||
};
|
||||
|
||||
const K = 1000;
|
||||
|
||||
export const PRODUCTS: SeedProduct[] = [
|
||||
// Mutfak › Bakliyat
|
||||
{
|
||||
ref: "kirmizi-mercimek",
|
||||
category: "mutfak.bakliyat",
|
||||
name: "Kırmızı Mercimek",
|
||||
unit: "kg",
|
||||
target: 2 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 40,
|
||||
location: "Kiler",
|
||||
step: 100,
|
||||
stopRefillDaysAgo: 40,
|
||||
},
|
||||
{
|
||||
ref: "kuru-fasulye",
|
||||
category: "mutfak.bakliyat",
|
||||
name: "Kuru Fasulye",
|
||||
unit: "kg",
|
||||
target: 2 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 25,
|
||||
location: "Kiler",
|
||||
step: 250,
|
||||
},
|
||||
{
|
||||
ref: "nohut",
|
||||
category: "mutfak.bakliyat",
|
||||
name: "Nohut",
|
||||
unit: "kg",
|
||||
target: 2 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 20,
|
||||
location: "Kiler",
|
||||
step: 250,
|
||||
},
|
||||
{
|
||||
ref: "pirinc",
|
||||
category: "mutfak.bakliyat",
|
||||
name: "Baldo Pirinç",
|
||||
unit: "kg",
|
||||
target: 5 * K,
|
||||
pack: 2_500,
|
||||
perDay: 90,
|
||||
location: "Kiler",
|
||||
step: 500,
|
||||
},
|
||||
// Mutfak › Manav
|
||||
{
|
||||
ref: "domates",
|
||||
category: "mutfak.manav",
|
||||
name: "Domates",
|
||||
unit: "kg",
|
||||
target: 3 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 300,
|
||||
location: "Buzdolabı",
|
||||
step: 500,
|
||||
shelfDays: 8,
|
||||
waste: { every: 2, pct: 30, reason: "spoiled" },
|
||||
},
|
||||
{
|
||||
ref: "patates",
|
||||
category: "mutfak.manav",
|
||||
name: "Patates",
|
||||
unit: "kg",
|
||||
target: 5 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 250,
|
||||
location: "Kiler",
|
||||
step: 500,
|
||||
waste: { every: 4, pct: 10, reason: "spoiled" },
|
||||
},
|
||||
{
|
||||
ref: "sogan",
|
||||
category: "mutfak.manav",
|
||||
name: "Kuru Soğan",
|
||||
unit: "kg",
|
||||
target: 3 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 120,
|
||||
location: "Kiler",
|
||||
step: 500,
|
||||
},
|
||||
// Mutfak › Şarküteri
|
||||
{
|
||||
ref: "beyaz-peynir",
|
||||
category: "mutfak.sarkuteri",
|
||||
name: "Beyaz Peynir",
|
||||
unit: "kg",
|
||||
target: 1 * K,
|
||||
pack: 500,
|
||||
perDay: 30,
|
||||
location: "Buzdolabı",
|
||||
step: 100,
|
||||
shelfDays: 45,
|
||||
},
|
||||
{
|
||||
ref: "yumurta",
|
||||
category: "mutfak.sarkuteri",
|
||||
name: "Yumurta",
|
||||
unit: "adet",
|
||||
target: 30 * K,
|
||||
pack: 15 * K,
|
||||
perDay: 3 * K,
|
||||
location: "Buzdolabı",
|
||||
shelfDays: 25,
|
||||
},
|
||||
{
|
||||
ref: "sut",
|
||||
category: "mutfak.sarkuteri",
|
||||
name: "Süt",
|
||||
unit: "lt",
|
||||
target: 6 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 700,
|
||||
location: "Buzdolabı",
|
||||
shelfDays: 12,
|
||||
waste: { every: 6, pct: 15, reason: "expired" },
|
||||
},
|
||||
// Genel Temizlik
|
||||
{
|
||||
ref: "tablet-deterjan",
|
||||
category: "genel-temizlik.bulasik",
|
||||
name: "Tablet Deterjan",
|
||||
unit: "tablet",
|
||||
target: 60 * K,
|
||||
pack: 40 * K,
|
||||
perDay: 720,
|
||||
location: "Lavabo altı",
|
||||
stopRefillDaysAgo: 40,
|
||||
},
|
||||
{
|
||||
ref: "elde-yikama",
|
||||
category: "genel-temizlik.bulasik",
|
||||
name: "Elde Yıkama Deterjanı",
|
||||
unit: "lt",
|
||||
target: 1_500,
|
||||
pack: 750,
|
||||
perDay: 15,
|
||||
location: "Lavabo altı",
|
||||
step: 100,
|
||||
},
|
||||
{
|
||||
ref: "toz-deterjan",
|
||||
bulky: true,
|
||||
category: "genel-temizlik.camasir",
|
||||
name: "Toz Deterjan",
|
||||
unit: "yıkama",
|
||||
target: 80 * K,
|
||||
pack: 50 * K,
|
||||
perDay: 700,
|
||||
location: "Banyo dolabı",
|
||||
countDrift: { everyCounts: 2, pct: 4 },
|
||||
},
|
||||
{
|
||||
ref: "yumusatici",
|
||||
bulky: true,
|
||||
category: "genel-temizlik.camasir",
|
||||
name: "Yumuşatıcı",
|
||||
unit: "lt",
|
||||
target: 3 * K,
|
||||
pack: 1_440,
|
||||
perDay: 40,
|
||||
location: "Banyo dolabı",
|
||||
step: 100,
|
||||
},
|
||||
{
|
||||
ref: "camasir-suyu",
|
||||
bulky: true,
|
||||
category: "genel-temizlik.genel",
|
||||
name: "Çamaşır Suyu",
|
||||
unit: "lt",
|
||||
target: 3 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 25,
|
||||
location: "Balkon",
|
||||
step: 250,
|
||||
},
|
||||
{
|
||||
ref: "tuvalet-kagidi",
|
||||
bulky: true,
|
||||
category: "genel-temizlik.genel",
|
||||
name: "Tuvalet Kağıdı",
|
||||
unit: "rulo",
|
||||
target: 32 * K,
|
||||
pack: 16 * K,
|
||||
perDay: 600,
|
||||
location: "Banyo dolabı",
|
||||
countDrift: { everyCounts: 3, pct: 6 },
|
||||
},
|
||||
// Kişisel Temizlik
|
||||
{
|
||||
ref: "sampuan",
|
||||
category: "kisisel-temizlik.sampuan",
|
||||
name: "Şampuan",
|
||||
unit: "adet",
|
||||
target: 3 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 50,
|
||||
location: "Banyo dolabı",
|
||||
},
|
||||
{
|
||||
ref: "tiras-bicagi",
|
||||
category: "kisisel-temizlik.tiras",
|
||||
name: "Tıraş Bıçağı",
|
||||
unit: "adet",
|
||||
target: 8 * K,
|
||||
pack: 4 * K,
|
||||
perDay: 150,
|
||||
location: "Banyo dolabı",
|
||||
},
|
||||
{
|
||||
ref: "dis-macunu",
|
||||
category: "kisisel-temizlik.kisisel-bakim",
|
||||
name: "Diş Macunu",
|
||||
unit: "adet",
|
||||
target: 4 * K,
|
||||
pack: 2 * K,
|
||||
perDay: 70,
|
||||
location: "Banyo dolabı",
|
||||
stopRefillDaysAgo: 60,
|
||||
},
|
||||
];
|
||||
|
||||
const START_DAYS_AGO = 182;
|
||||
|
||||
/** Sayım sıklığı (gün) ve sayım günü kuralı (gün önce), ana stok kategorisine göre. */
|
||||
const COUNT_PLANS: Record<string, { everyDays: number; on: (daysAgo: number) => boolean }> = {
|
||||
mutfak: { everyDays: 15, on: (d) => d >= 21 && d % 14 === 7 },
|
||||
"genel-temizlik": { everyDays: 30, on: (d) => d % 28 === 14 },
|
||||
};
|
||||
|
||||
type Row = {
|
||||
kind: "purchase" | "consume" | "waste" | "count_adjust";
|
||||
daysAgo: number;
|
||||
quantity: number;
|
||||
expiryDate?: string | null;
|
||||
wasteReason?: string | null;
|
||||
note?: string;
|
||||
initial?: boolean;
|
||||
/** Bu satır hangi sayıma ait (gün önce). */
|
||||
countDaysAgo?: number;
|
||||
};
|
||||
|
||||
export type SeedCount = { daysAgo: number; expected: number; actual: number };
|
||||
|
||||
/**
|
||||
* Haftalık simülasyon: tüketim, periyodik sayım (farkı nadiren), eşikte alım ve fire. Stok asla
|
||||
* eksiye düşmez.
|
||||
*/
|
||||
export function simulate(p: SeedProduct, today: string): { rows: Row[]; counts: SeedCount[] } {
|
||||
const rows: Row[] = [];
|
||||
const counts: SeedCount[] = [];
|
||||
const plan = COUNT_PLANS[p.category.split(".")[0]!];
|
||||
let stock = p.target;
|
||||
let purchases = 0;
|
||||
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true });
|
||||
for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) {
|
||||
// Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg).
|
||||
const step = p.step ?? K;
|
||||
const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step));
|
||||
if (consume > 0) {
|
||||
rows.push({ kind: "consume", daysAgo: d, quantity: consume });
|
||||
stock -= consume;
|
||||
}
|
||||
if (plan?.on(d)) {
|
||||
const expected = stock;
|
||||
const nth = counts.length + 1;
|
||||
if (p.countDrift && nth % p.countDrift.everyCounts === 0 && stock > 0) {
|
||||
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
|
||||
rows.push({
|
||||
kind: "count_adjust",
|
||||
daysAgo: d,
|
||||
quantity: -drift,
|
||||
note: "Sayım",
|
||||
countDaysAgo: d,
|
||||
});
|
||||
stock -= drift;
|
||||
}
|
||||
counts.push({ daysAgo: d, expected, actual: stock });
|
||||
}
|
||||
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
|
||||
if (canRefill && stock < (p.target * 35) / 100) {
|
||||
const packs = Math.max(1, Math.ceil((p.target - stock) / p.pack));
|
||||
const quantity = packs * p.pack;
|
||||
purchases++;
|
||||
rows.push({
|
||||
kind: "purchase",
|
||||
daysAgo: d,
|
||||
quantity,
|
||||
expiryDate: p.shelfDays ? addDays(addDays(today, -d), p.shelfDays) : null,
|
||||
});
|
||||
stock += quantity;
|
||||
if (p.waste && purchases % p.waste.every === 0 && d >= 3) {
|
||||
const w = Math.min(stock, Math.round((quantity * p.waste.pct) / 100));
|
||||
rows.push({ kind: "waste", daysAgo: d - 3, quantity: w, wasteReason: p.waste.reason });
|
||||
stock -= w;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rows, counts };
|
||||
}
|
||||
|
||||
export async function seedStock(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
users: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
// Periyodik sayım sıklığı (kullanıcı ayarladıysa dokunulmaz).
|
||||
for (const [key, plan] of Object.entries(COUNT_PLANS))
|
||||
await StockCategoryModel.updateOne(
|
||||
{ householdId, defaultKey: key, countEveryDays: null },
|
||||
{ $set: { countEveryDays: plan.everyDays } },
|
||||
);
|
||||
const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } })
|
||||
.select({ defaultKey: 1 })
|
||||
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
|
||||
const catByKey = new Map(cats.map((c) => [c.defaultKey, c._id]));
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const p of PRODUCTS) {
|
||||
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) {
|
||||
// Faz 13 öncesi seed'lenmiş veritabanında hacimli işaretini tamamla.
|
||||
if (p.bulky)
|
||||
await ProductTypeModel.updateOne(
|
||||
{ householdId, seedRef: p.ref, bulky: { $exists: false } },
|
||||
{ $set: { bulky: true } },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const doc = await ProductTypeModel.create({
|
||||
householdId,
|
||||
categoryId: catByKey.get(p.category)!,
|
||||
name: p.name,
|
||||
nameKey: foldTr(p.name),
|
||||
unit: p.unit,
|
||||
targetQuantity: p.target,
|
||||
thresholdPct: p.threshold ?? 20,
|
||||
location: p.location,
|
||||
step: p.step ?? K,
|
||||
bulky: p.bulky ?? false,
|
||||
seedRef: p.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
const base = Date.now() - START_DAYS_AGO * 86_400_000;
|
||||
const { rows, counts } = simulate(p, today);
|
||||
// Sayım oturumu kategori başına ortak: ürün satırı oturuma eklenir.
|
||||
const root = p.category.split(".")[0]!;
|
||||
const countIds = new Map<number, Types.ObjectId>();
|
||||
for (const c of counts) {
|
||||
const session = await StockCountModel.findOneAndUpdate(
|
||||
{ householdId, seedRef: `${root}:${c.daysAgo}` },
|
||||
{
|
||||
$setOnInsert: {
|
||||
householdId,
|
||||
date: addDays(today, -c.daysAgo),
|
||||
categoryId: catByKey.get(root) ?? null,
|
||||
note: "",
|
||||
seedRef: `${root}:${c.daysAgo}`,
|
||||
createdBy: actor,
|
||||
},
|
||||
$push: {
|
||||
items: { productTypeId: doc._id, expected: c.expected, actual: c.actual, wasted: 0 },
|
||||
},
|
||||
},
|
||||
{ upsert: true, returnDocument: "after" },
|
||||
).lean<{ _id: Types.ObjectId }>();
|
||||
countIds.set(c.daysAgo, session!._id);
|
||||
}
|
||||
// Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
|
||||
await StockMovementModel.collection.insertMany(
|
||||
rows.map((r, i) => {
|
||||
const at = new Date(base + i * 1000);
|
||||
return {
|
||||
householdId,
|
||||
productTypeId: doc._id,
|
||||
kind: r.kind,
|
||||
date: addDays(today, -r.daysAgo),
|
||||
quantity: r.quantity,
|
||||
wasteReason: r.wasteReason ?? null,
|
||||
expiryDate: r.expiryDate ?? null,
|
||||
note: r.note ?? "",
|
||||
initial: r.initial ?? false,
|
||||
countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
|
||||
deletedAt: null,
|
||||
seedRef: `${p.ref}:${i}`,
|
||||
createdBy: actor,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
}),
|
||||
);
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { Types } from "mongoose";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { PayeeModel } from "@/models/payee";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import { allocate } from "@/shared/money";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import type { TransactionType } from "@/shared/transactions";
|
||||
|
||||
/**
|
||||
* Son 6 ayın gerçekçi işlemleri (faker.seed(42) ile tekrarlanabilir).
|
||||
* Her işlemin sabit `seedRef`'i vardır: tekrar çalıştırmada kopya oluşmaz.
|
||||
*/
|
||||
|
||||
type Ctx = {
|
||||
householdId: Types.ObjectId;
|
||||
account: (name: string) => Types.ObjectId;
|
||||
category: (key: string) => Types.ObjectId;
|
||||
user: (username: string) => Types.ObjectId;
|
||||
};
|
||||
|
||||
type SeedTx = {
|
||||
ref: string;
|
||||
type: TransactionType;
|
||||
ymd: string;
|
||||
amountMinor: number;
|
||||
account: string;
|
||||
toAccount?: string;
|
||||
category?: string;
|
||||
splits?: { category: string; weight: number; note?: string }[];
|
||||
payee?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
by: string;
|
||||
};
|
||||
|
||||
const tl = (min: number, max: number) => faker.number.int({ min: min * 100, max: max * 100 });
|
||||
const pick = <T>(xs: readonly T[]) => faker.helpers.arrayElement(xs);
|
||||
|
||||
/** Ayın gün sayısına göre güvenli gün (31 → ayın son günü). */
|
||||
function ymdOf(year: number, month: number, day: number) {
|
||||
const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(Math.min(day, last)).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function months(count: number): { y: number; m: number; key: string }[] {
|
||||
const [ty, tm] = todayIstanbul().split("-").map(Number) as [number, number];
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const d = new Date(Date.UTC(ty, tm - 1 - (count - 1 - i), 1));
|
||||
const y = d.getUTCFullYear();
|
||||
const m = d.getUTCMonth() + 1;
|
||||
return { y, m, key: `${y}-${String(m).padStart(2, "0")}` };
|
||||
});
|
||||
}
|
||||
|
||||
function cakirAiles(): SeedTx[] {
|
||||
const out: SeedTx[] = [];
|
||||
const marketPayees = ["Migros", "BİM", "A101", "Şok", "CarrefourSA"] as const;
|
||||
for (const { y, m, key } of months(6)) {
|
||||
const winter = [11, 12, 1, 2, 3].includes(m);
|
||||
const d = (day: number) => ymdOf(y, m, day);
|
||||
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
|
||||
out.push({ ...x, ref: `cakir:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
|
||||
|
||||
add({
|
||||
type: "income",
|
||||
ymd: d(15),
|
||||
amountMinor: 52_000_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "maas",
|
||||
payee: "Anadolu Yazılım A.Ş.",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "income",
|
||||
ymd: d(1),
|
||||
amountMinor: 4_500_00,
|
||||
account: "Ayşe — Pluxee",
|
||||
category: "yemek-karti",
|
||||
payee: "Pluxee",
|
||||
by: "ayse",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(3),
|
||||
amountMinor: 17_500_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "kira",
|
||||
payee: "Ev sahibi",
|
||||
description: "Kira",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(5),
|
||||
amountMinor: 1_250_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "aidat",
|
||||
payee: "Site yönetimi",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(10),
|
||||
amountMinor: tl(750, 1_600),
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.elektrik",
|
||||
payee: "Enerjisa",
|
||||
by: "ayse",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(12),
|
||||
amountMinor: tl(300, 620),
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.su",
|
||||
payee: "İSKİ",
|
||||
by: "ayse",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(14),
|
||||
amountMinor: winter ? tl(1_800, 3_400) : tl(350, 700),
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.dogalgaz",
|
||||
payee: "İGDAŞ",
|
||||
by: "ayse",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(20),
|
||||
amountMinor: 499_90,
|
||||
account: "Garanti Bonus",
|
||||
category: "faturalar.internet",
|
||||
payee: "Türk Telekom",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(22),
|
||||
amountMinor: 714_00,
|
||||
account: "Garanti Bonus",
|
||||
category: "faturalar.telefon",
|
||||
payee: "Turkcell",
|
||||
description: "2 hat",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(8),
|
||||
amountMinor: 229_99,
|
||||
account: "Garanti Bonus",
|
||||
category: "abonelikler",
|
||||
payee: "Netflix",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(9),
|
||||
amountMinor: 99_99,
|
||||
account: "Garanti Bonus",
|
||||
category: "abonelikler",
|
||||
payee: "Spotify",
|
||||
by: "emre",
|
||||
});
|
||||
|
||||
// Transferler: nakit çekme, kart ödemesi, birikim. Satıcı/kategori olmadığından seedRef
|
||||
// türden üretilir; çakışmasın diye sıra numarası verilir (ATM 0 — mevcut veriyle uyumlu).
|
||||
add({
|
||||
type: "transfer",
|
||||
ymd: d(2),
|
||||
amountMinor: 2_000_00,
|
||||
account: "Ziraat Maaş",
|
||||
toAccount: "Cüzdan",
|
||||
description: "ATM",
|
||||
by: "admin",
|
||||
});
|
||||
add(
|
||||
{
|
||||
type: "transfer",
|
||||
// Kesim 29, son ödeme ~8: ödeme son ödeme gününden önce.
|
||||
ymd: d(7),
|
||||
amountMinor: tl(11_000, 15_000),
|
||||
account: "Ziraat Maaş",
|
||||
toAccount: "Garanti Bonus",
|
||||
description: "Kart borcu ödemesi",
|
||||
by: "admin",
|
||||
},
|
||||
1,
|
||||
);
|
||||
add(
|
||||
{
|
||||
type: "transfer",
|
||||
ymd: d(16),
|
||||
amountMinor: 5_000_00,
|
||||
account: "Ziraat Maaş",
|
||||
toAccount: "Birikim (Vadeli)",
|
||||
description: "Aylık birikim",
|
||||
by: "ayse",
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
// Market: haftada 1–2; bir kısmı bölünmüş.
|
||||
const marketCount = faker.number.int({ min: 6, max: 8 });
|
||||
for (let i = 0; i < marketCount; i++) {
|
||||
const payee = pick(marketPayees);
|
||||
const amountMinor = tl(280, 2_600);
|
||||
const split = amountMinor > 1_200_00 && faker.datatype.boolean({ probability: 0.5 });
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor,
|
||||
account: pick(["Garanti Bonus", "Garanti Bonus", "Cüzdan"]),
|
||||
...(split
|
||||
? {
|
||||
splits: [
|
||||
{ category: "market.gida", weight: faker.number.int({ min: 55, max: 75 }) },
|
||||
{
|
||||
category: "market.temizlik",
|
||||
weight: faker.number.int({ min: 10, max: 25 }),
|
||||
note: "Deterjan",
|
||||
},
|
||||
{
|
||||
category: "market.kisisel-bakim",
|
||||
weight: faker.number.int({ min: 5, max: 15 }),
|
||||
},
|
||||
],
|
||||
}
|
||||
: { category: "market.gida" }),
|
||||
payee,
|
||||
tags: faker.datatype.boolean({ probability: 0.15 }) ? ["indirim"] : [],
|
||||
by: pick(["ayse", "ayse", "admin", "emre"]),
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < faker.number.int({ min: 2, max: 3 }); i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(1_200, 2_300),
|
||||
account: "Garanti Bonus",
|
||||
category: "ulasim.yakit",
|
||||
payee: pick(["Shell", "Opet", "BP"]),
|
||||
by: "admin",
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < faker.number.int({ min: 4, max: 6 }); i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(110, 340),
|
||||
account: "Ayşe — Pluxee",
|
||||
category: "eglence.kafe",
|
||||
payee: pick(["Starbucks", "Kahve Dünyası", "Simit Sarayı"]),
|
||||
by: "ayse",
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < faker.number.int({ min: 2, max: 4 }); i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(450, 1_600),
|
||||
account: pick(["Garanti Bonus", "Ayşe — Pluxee"]),
|
||||
category: "eglence.disarida-yemek",
|
||||
payee: pick(["Köfteci Yusuf", "Nusr-Et", "Big Chefs", "Yemeksepeti"]),
|
||||
by: pick(["ayse", "admin"]),
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(60, 220),
|
||||
account: "Cüzdan",
|
||||
category: "ulasim.toplu-tasima",
|
||||
payee: "İstanbulkart",
|
||||
by: "emre",
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
if (faker.datatype.boolean({ probability: 0.6 })) {
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(140, 650),
|
||||
account: "Cüzdan",
|
||||
category: "saglik.ilac",
|
||||
payee: "Eczane",
|
||||
by: "ayse",
|
||||
});
|
||||
}
|
||||
if (faker.datatype.boolean({ probability: 0.5 })) {
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(600, 3_200),
|
||||
account: "Garanti Bonus",
|
||||
category: "giyim",
|
||||
payee: pick(["LC Waikiki", "Trendyol", "Koton"]),
|
||||
by: "ayse",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function annemler(): SeedTx[] {
|
||||
const out: SeedTx[] = [];
|
||||
for (const { y, m, key } of months(6)) {
|
||||
const winter = [11, 12, 1, 2, 3].includes(m);
|
||||
const d = (day: number) => ymdOf(y, m, day);
|
||||
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
|
||||
out.push({ ...x, ref: `annemler:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
|
||||
add({
|
||||
type: "income",
|
||||
ymd: d(17),
|
||||
amountMinor: 24_350_00,
|
||||
account: "Vakıfbank Emekli",
|
||||
category: "maas",
|
||||
payee: "SGK",
|
||||
description: "Emekli maaşı",
|
||||
by: "fatma",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(11),
|
||||
amountMinor: tl(450, 900),
|
||||
account: "Vakıfbank Emekli",
|
||||
category: "faturalar.elektrik",
|
||||
payee: "Enerjisa",
|
||||
by: "mehmet",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(13),
|
||||
amountMinor: winter ? tl(1_400, 2_600) : tl(250, 500),
|
||||
account: "Vakıfbank Emekli",
|
||||
category: "faturalar.dogalgaz",
|
||||
payee: "İGDAŞ",
|
||||
by: "mehmet",
|
||||
});
|
||||
add({
|
||||
type: "transfer",
|
||||
ymd: d(18),
|
||||
amountMinor: 4_000_00,
|
||||
account: "Vakıfbank Emekli",
|
||||
toAccount: "Nakit",
|
||||
description: "ATM",
|
||||
by: "fatma",
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(200, 1_100),
|
||||
account: pick(["Nakit", "Vakıfbank Emekli"]),
|
||||
category: "market.gida",
|
||||
payee: pick(["BİM", "Şok", "Pazar"]),
|
||||
by: pick(["fatma", "mehmet"]),
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function seedTransactions(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
faker.seed(42);
|
||||
let inserted = 0;
|
||||
const plan: [string, SeedTx[]][] = [
|
||||
["Çakır Ailesi", cakirAiles()],
|
||||
["Annemler", annemler()],
|
||||
];
|
||||
const today = todayIstanbul();
|
||||
|
||||
for (const [householdName, txs] of plan) {
|
||||
const householdId = householdIds.get(householdName)!;
|
||||
const [accounts, categories] = await Promise.all([
|
||||
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
|
||||
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
|
||||
.select({ defaultKey: 1 })
|
||||
.lean(),
|
||||
]);
|
||||
const ctx: Ctx = {
|
||||
householdId,
|
||||
account: (name) => accounts.find((a) => a.name === name)!._id,
|
||||
category: (key) => categories.find((c) => c.defaultKey === key)!._id,
|
||||
user: (username) => new Types.ObjectId(userIds.get(username)!),
|
||||
};
|
||||
const payeeStats = new Map<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
type: TransactionType;
|
||||
account: Types.ObjectId;
|
||||
category: Types.ObjectId | null;
|
||||
count: number;
|
||||
last: Date;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const x of txs) {
|
||||
if (x.ymd > today) continue; // Gelecek tarihli seed işlemi yok.
|
||||
const date = istanbulDayStart(x.ymd)!;
|
||||
const splits = x.splits
|
||||
? allocate(
|
||||
x.amountMinor,
|
||||
x.splits.map((s) => s.weight),
|
||||
).map((amountMinor, i) => ({
|
||||
categoryId: ctx.category(x.splits![i]!.category),
|
||||
amountMinor,
|
||||
note: x.splits![i]!.note ?? "",
|
||||
}))
|
||||
: [];
|
||||
const categoryId = x.category ? ctx.category(x.category) : null;
|
||||
const accountId = ctx.account(x.account);
|
||||
const by = ctx.user(x.by);
|
||||
const doc = {
|
||||
householdId,
|
||||
type: x.type,
|
||||
status: "posted",
|
||||
date,
|
||||
accountId,
|
||||
toAccountId: x.toAccount ? ctx.account(x.toAccount) : null,
|
||||
amountMinor: x.amountMinor,
|
||||
categoryId: splits.length ? null : categoryId,
|
||||
splits,
|
||||
effectiveCategoryIds: splits.length
|
||||
? [...new Set(splits.map((s) => String(s.categoryId)))].map(
|
||||
(id) => new Types.ObjectId(id),
|
||||
)
|
||||
: categoryId
|
||||
? [categoryId]
|
||||
: [],
|
||||
payeeName: x.payee ?? "",
|
||||
description: x.description ?? "",
|
||||
searchText: foldTr(
|
||||
[x.payee ?? "", x.description ?? "", ...splits.map((s) => s.note)].join(" "),
|
||||
),
|
||||
tags: x.tags ?? [],
|
||||
seedRef: x.ref,
|
||||
createdBy: by,
|
||||
updatedBy: by,
|
||||
};
|
||||
const res = await TransactionModel.updateOne(
|
||||
{ householdId, seedRef: x.ref },
|
||||
{ $setOnInsert: doc },
|
||||
{ upsert: true },
|
||||
);
|
||||
if (res.upsertedCount) inserted++;
|
||||
if (x.payee) {
|
||||
const key = foldTr(x.payee);
|
||||
const prev = payeeStats.get(key);
|
||||
const latest = !prev || date >= prev.last;
|
||||
payeeStats.set(key, {
|
||||
name: x.payee,
|
||||
type: latest ? x.type : prev!.type,
|
||||
account: latest ? accountId : prev!.account,
|
||||
category: latest ? (splits.length ? null : categoryId) : prev!.category,
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
last: latest ? date : prev!.last,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [nameKey, p] of payeeStats) {
|
||||
await PayeeModel.updateOne(
|
||||
{ householdId, nameKey },
|
||||
{
|
||||
$setOnInsert: { householdId, nameKey, name: p.name },
|
||||
$set: {
|
||||
lastType: p.type,
|
||||
lastAccountId: p.account,
|
||||
lastCategoryId: p.category,
|
||||
useCount: p.count,
|
||||
lastUsedAt: p.last,
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM duman testi (smoke test) — canlıya alma sonrası, yalnızca OKUMA yapar; veri değiştirmez.
|
||||
#
|
||||
# Kullanım:
|
||||
# scripts/smoke-test.sh https://butcem.ta1sph.website [beklenen-sürüm]
|
||||
# SMOKE_USER=admin SMOKE_PASSWORD='…' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
#
|
||||
# Gerekenler: bash, curl. Çıkış kodu: 0 = hepsi geçti, 1 = en az bir kontrol başarısız.
|
||||
set -uo pipefail
|
||||
|
||||
BASE="${1:?Kullanım: scripts/smoke-test.sh <adres> [beklenen-sürüm]}"
|
||||
BASE="${BASE%/}"
|
||||
EXPECT_VERSION="${2:-}"
|
||||
FAIL=0
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||
bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=1; }
|
||||
check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
|
||||
|
||||
echo "BÜTÇEM duman testi: $BASE"
|
||||
|
||||
echo "1) Sağlık kontrolü"
|
||||
HEALTH_CODE=$(curl -s -o "$TMP/health" -w '%{http_code}' "$BASE/api/health")
|
||||
HEALTH=$(cat "$TMP/health")
|
||||
echo " $HEALTH"
|
||||
check "GET /api/health 200" '[ "$HEALTH_CODE" = 200 ]'
|
||||
check "veritabanı erişilebilir" 'grep -q "\"db\":\"ok\"" <<<"$HEALTH"'
|
||||
check "MongoDB replica set (transaction için)" '! grep -q "\"replicaSet\":null" <<<"$HEALTH" && grep -q "\"replicaSet\":\"" <<<"$HEALTH"'
|
||||
if [ -n "$EXPECT_VERSION" ]; then
|
||||
check "sürüm $EXPECT_VERSION" 'grep -q "\"version\":\"$EXPECT_VERSION\"" <<<"$HEALTH"'
|
||||
fi
|
||||
|
||||
echo "2) Sayfalar ve güvenlik başlıkları"
|
||||
LOGIN_CODE=$(curl -s -D "$TMP/login-headers" -o /dev/null -w '%{http_code}' "$BASE/login")
|
||||
H=$(tr -d '\r' <"$TMP/login-headers" | tr 'A-Z' 'a-z')
|
||||
check "GET /login 200" '[ "$LOGIN_CODE" = 200 ]'
|
||||
check "Content-Security-Policy var" 'grep -q "^content-security-policy:" <<<"$H"'
|
||||
check "X-Frame-Options: DENY" 'grep -q "^x-frame-options: deny" <<<"$H"'
|
||||
check "X-Content-Type-Options: nosniff" 'grep -q "^x-content-type-options: nosniff" <<<"$H"'
|
||||
if [[ "$BASE" == https://* ]]; then
|
||||
check "Strict-Transport-Security var" 'grep -q "^strict-transport-security:" <<<"$H"'
|
||||
HTTP_URL="http://${BASE#https://}/login"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HTTP_URL")
|
||||
check "HTTP → HTTPS yönlendirmesi ($HTTP_CODE)" '[[ "$HTTP_CODE" =~ ^30[1278]$ ]]'
|
||||
fi
|
||||
|
||||
echo "3) Oturumsuz erişim engelleniyor"
|
||||
DASH=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$BASE/dashboard")
|
||||
check "/dashboard → giriş sayfasına yönlendirme ($DASH)" '[[ "$DASH" =~ ^30[27]\ .*/login ]]'
|
||||
FILE_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/files/000000000000000000000000")
|
||||
check "/api/files oturumsuz 401 ($FILE_CODE)" '[ "$FILE_CODE" = 401 ]'
|
||||
CSV=$(curl -s -o "$TMP/csv" -w '%{http_code}' "$BASE/raporlar/kategoriler/csv")
|
||||
check "rapor CSV'si oturumsuz veri vermiyor ($CSV)" '! grep -q "Toplam gidere oran" "$TMP/csv"'
|
||||
|
||||
if [ -n "${SMOKE_USER:-}" ] && [ -n "${SMOKE_PASSWORD:-}" ]; then
|
||||
echo "4) Giriş ($SMOKE_USER)"
|
||||
SIGN=$(curl -s -c "$TMP/jar" -o "$TMP/signin" -w '%{http_code}' -X POST "$BASE/api/auth/sign-in/username" \
|
||||
-H 'Content-Type: application/json' -H "Origin: $BASE" \
|
||||
--data "$(printf '{"username":"%s","password":"%s"}' "$SMOKE_USER" "$SMOKE_PASSWORD")")
|
||||
check "giriş 200 ($SIGN)" '[ "$SIGN" = 200 ]'
|
||||
PAGE=$(curl -s -b "$TMP/jar" -L -o /dev/null -w '%{http_code} %{url_effective}' "$BASE/dashboard")
|
||||
check "oturumla ana sayfa açılıyor ($PAGE)" '[[ "$PAGE" =~ ^200\ ]] && [[ ! "$PAGE" =~ /login ]]'
|
||||
curl -s -b "$TMP/jar" -X POST "$BASE/api/auth/sign-out" -H "Origin: $BASE" -o /dev/null
|
||||
else
|
||||
echo "4) Giriş testi atlandı (SMOKE_USER ve SMOKE_PASSWORD tanımlı değil)"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = 0 ]; then echo "Sonuç: tüm kontroller geçti."; else echo "Sonuç: başarısız kontrol var."; fi
|
||||
exit "$FAIL"
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { HouseholdsAdmin } from "@/features/admin-households/components/households-admin";
|
||||
import { getAdminHouseholdsData } from "@/features/admin-households/queries";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.adminHouseholds.title };
|
||||
|
||||
export default async function AdminHouseholdsPage() {
|
||||
const [admin, data] = await Promise.all([requireRole("admin"), getAdminHouseholdsData()]);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.adminHouseholds.title} description={tr.adminHouseholds.description} />
|
||||
<HouseholdsAdmin households={data.households} users={data.users} currentUserId={admin.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { UsersTable } from "@/features/admin-users/components/users-table";
|
||||
import { listAdminUsers } from "@/features/admin-users/queries";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.adminUsers.title };
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const [admin, users] = await Promise.all([requireRole("admin"), listAdminUsers()]);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.adminUsers.title} description={tr.adminUsers.description} />
|
||||
<UsersTable users={users} currentUserId={admin.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
/** Yalnızca sistem admini; diğerlerine 404. Sayfalar ayrıca kendi kontrolünü yapar. */
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireRole("admin");
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ShoppingModeView } from "@/features/shopping/components/shopping-mode";
|
||||
import { getShoppingList } from "@/features/shopping/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.shopping.mode.title };
|
||||
|
||||
/** /alisveris/:id/mod — alışverişe çıkış: mağazaya göre gruplu kontrol listesi. */
|
||||
export default async function ShoppingModePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps<"/alisveris/[id]/mod">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [{ id }, query] = await Promise.all([params, searchParams]);
|
||||
const data = await getShoppingList(user.id, household.id, id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ShoppingModeView data={data} choice={query.plan === "single" ? "single" : "recommended"} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ShoppingListDetailView } from "@/features/shopping/components/list-detail";
|
||||
import { getShoppingList } from "@/features/shopping/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.shopping.title };
|
||||
|
||||
/** /alisveris/:id — liste kalemleri, stok önerileri ve akıllı mağaza önerisi. */
|
||||
export default async function ShoppingListPage({ params }: PageProps<"/alisveris/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const data = await getShoppingList(user.id, household.id, id);
|
||||
if (!data) notFound();
|
||||
return <ShoppingListDetailView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CompletionForm } from "@/features/shopping/components/completion-form";
|
||||
import { getCompletionSheet } from "@/features/shopping/completion";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.shopping.complete.title };
|
||||
|
||||
/** /alisveris/:id/tamamla — alışveriş sonrası giriş: alınanlar, ödeme, fiş, alınmayanlar. */
|
||||
export default async function CompleteShoppingPage({
|
||||
params,
|
||||
}: PageProps<"/alisveris/[id]/tamamla">) {
|
||||
const { user, household } = await requireActiveHousehold("member");
|
||||
const { id } = await params;
|
||||
const sheet = await getCompletionSheet(user.id, household.id, id);
|
||||
if (!sheet) notFound();
|
||||
return <CompletionForm sheet={sheet} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ShoppingListsView } from "@/features/shopping/components/lists-view";
|
||||
import { listShoppingLists } from "@/features/shopping/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.shopping.title };
|
||||
|
||||
/** /alisveris — aktif ve tamamlanan alışveriş listeleri. */
|
||||
export default async function ShoppingPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listShoppingLists(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.shopping.title} description={tr.shopping.description} />
|
||||
{!data.canWrite ? (
|
||||
<p className="text-sm text-muted-foreground">{tr.shopping.readOnly}</p>
|
||||
) : null}
|
||||
<ShoppingListsView
|
||||
active={data.active}
|
||||
completed={data.completed}
|
||||
suggestions={data.suggestions}
|
||||
canWrite={data.canWrite}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { HouseholdSettingsForm } from "@/features/households/components/household-settings-form";
|
||||
import { SettingsMembers } from "@/features/households/components/settings-members";
|
||||
import { getHouseholdDetail } from "@/features/households/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.settings.title };
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const detail = await getHouseholdDetail(user.id, household.id);
|
||||
if (!detail) notFound();
|
||||
const isOwner = household.role === "owner";
|
||||
const t = tr.settings;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.title} description={t.description} />
|
||||
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.household}</CardTitle>
|
||||
<CardDescription>{isOwner ? t.householdDescription : t.ownerOnly}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<HouseholdSettingsForm settings={detail.settings} canEdit={isOwner} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.members}</CardTitle>
|
||||
<CardDescription>{t.membersDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SettingsMembers
|
||||
members={detail.members}
|
||||
householdName={detail.name}
|
||||
currentUserId={user.id}
|
||||
canManage={isOwner}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
|
||||
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||
import {
|
||||
getAsset,
|
||||
getPeriodReport,
|
||||
REPORT_RANGES,
|
||||
type ReportRange,
|
||||
} from "@/features/assets/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.assets.history };
|
||||
|
||||
/** /birikim/:id — varlık hareketleri ve dönemsel rapor (gizli varlık yalnızca sahibine; aksi 404). */
|
||||
export default async function AssetPage({ params, searchParams }: PageProps<"/birikim/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const donem = (await searchParams).donem;
|
||||
const range: ReportRange = REPORT_RANGES.includes(donem as ReportRange)
|
||||
? (donem as ReportRange)
|
||||
: "6";
|
||||
const [data, report] = await Promise.all([
|
||||
getAsset(user.id, household.id, id),
|
||||
getPeriodReport(user.id, household.id, range, id),
|
||||
]);
|
||||
if (!data || !report) notFound();
|
||||
return (
|
||||
<AssetDetailView
|
||||
key={id}
|
||||
data={data}
|
||||
report={<PeriodReportView report={report} range={range} basePath={`/birikim/${id}`} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { AssetsView } from "@/features/assets/components/assets-view";
|
||||
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||
import {
|
||||
getPeriodReport,
|
||||
listAssets,
|
||||
REPORT_RANGES,
|
||||
type ReportRange,
|
||||
} from "@/features/assets/service";
|
||||
import { NetWorthCard } from "@/features/net-worth/components/net-worth-card";
|
||||
import { getNetWorth } from "@/features/net-worth/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.assets.title };
|
||||
|
||||
function parseRange(value: string | string[] | undefined): ReportRange {
|
||||
return REPORT_RANGES.includes(value as ReportRange) ? (value as ReportRange) : "6";
|
||||
}
|
||||
|
||||
/** /birikim — net varlık, varlıklar (ortalama maliyet, K/Z) ve dönemsel rapor. */
|
||||
export default async function AssetsPage({ searchParams }: PageProps<"/birikim">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const range = parseRange((await searchParams).donem);
|
||||
const [data, report, netWorth] = await Promise.all([
|
||||
listAssets(user.id, household.id),
|
||||
getPeriodReport(user.id, household.id, range),
|
||||
getNetWorth(user.id, household.id),
|
||||
]);
|
||||
if (!data || !report || !netWorth) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.assets.title} description={tr.assets.description} />
|
||||
<section aria-labelledby="net-worth-title" className="flex flex-col gap-2">
|
||||
<h2 id="net-worth-title" className="text-base font-medium">
|
||||
{tr.assets.netWorth.title}
|
||||
</h2>
|
||||
<NetWorthCard data={netWorth} />
|
||||
</section>
|
||||
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
|
||||
<PeriodReportView report={report} range={range} basePath="/birikim" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { BudgetView } from "@/features/budgets/components/budget-view";
|
||||
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||
import { getIncomePlan } from "@/features/budgets/plan";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { KakeiboBudgetCard } from "@/features/reports/components/kakeibo-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
export const metadata: Metadata = { title: tr.budget.title };
|
||||
|
||||
/** /butce?ay=YYYY-MM — aylık bütçe (varsayılan: içinde bulunulan bütçe ayı). */
|
||||
export default async function BudgetPage({ searchParams }: PageProps<"/butce">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
const income = await getIncomePlan(user.id, household.id, data.month);
|
||||
if (!income) notFound();
|
||||
const monthLabel = new Intl.DateTimeFormat("tr-TR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
}).format(new Date(`${data.month}-15T12:00:00+03:00`));
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||
<BudgetView
|
||||
key={data.month}
|
||||
data={data}
|
||||
summary={
|
||||
<KakeiboBudgetCard userId={user.id} householdId={household.id} month={data.month} />
|
||||
}
|
||||
/>
|
||||
<IncomePlanSection
|
||||
key={`income-${data.month}`}
|
||||
month={data.month}
|
||||
monthLabel={monthLabel}
|
||||
income={income}
|
||||
limitMinor={data.totals.limitMinor}
|
||||
forecastMinor={data.totals.forecastMinor}
|
||||
hasBudget={data.hasBudget}
|
||||
canWrite={data.canWrite}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContactStatementView } from "@/features/contacts/components/contact-statement-view";
|
||||
import { getContactStatement } from "@/features/contacts/service";
|
||||
import { listInKind } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.contacts.statement };
|
||||
|
||||
/** /cari/:id — cari ekstresi (gizli cari yalnızca sahibine; aksi 404). */
|
||||
export default async function ContactPage({ params }: PageProps<"/cari/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const [data, inKind] = await Promise.all([
|
||||
getContactStatement(user.id, household.id, id),
|
||||
listInKind(user.id, household.id, id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return <ContactStatementView key={id} data={data} inKind={inKind ?? []} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ContactsView } from "@/features/contacts/components/contacts-view";
|
||||
import { listContacts } from "@/features/contacts/service";
|
||||
import { openInKindByContact } from "@/features/in-kind/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.contacts.title };
|
||||
|
||||
export default async function ContactsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, inKind] = await Promise.all([
|
||||
listContacts(user.id, household.id),
|
||||
openInKindByContact(user.id, household.id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.contacts.title} description={tr.contacts.description} />
|
||||
<ContactsView
|
||||
data={data}
|
||||
inKind={Object.fromEntries(inKind)}
|
||||
canWrite={hasHouseholdRole(household.role, "member")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-4 w-80 max-w-full" />
|
||||
</div>
|
||||
<SummaryCardsSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OverviewCard } from "@/features/dashboard/components/overview-card";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
|
||||
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
|
||||
import { getIncomePlan } from "@/features/budgets/plan";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||
import { getNetWorth } from "@/features/net-worth/service";
|
||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||
import { ShoppingCard } from "@/features/shopping/components/shopping-card";
|
||||
import { LowStockCard } from "@/features/stock/components/low-stock-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
|
||||
export const metadata: Metadata = { title: tr.dashboard.title };
|
||||
|
||||
type SummaryCard = {
|
||||
key: keyof typeof tr.dashboard.cards;
|
||||
tone?: string;
|
||||
amountMinor: number | null;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Özet kartları (içinde bulunulan bütçe ayı): gerçekleşen gelir, gider ve farkı; hesap bakiyesi,
|
||||
* kart borcu ve net varlık.
|
||||
*/
|
||||
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
||||
const [nw, budget] = await Promise.all([
|
||||
getNetWorth(userId, householdId),
|
||||
getBudget(userId, householdId),
|
||||
]);
|
||||
const income = budget ? await getIncomePlan(userId, householdId, budget.month) : null;
|
||||
const inc = income?.actualMinor ?? null;
|
||||
const exp = budget?.totals.actualMinor ?? null;
|
||||
return [
|
||||
{ key: "monthIncome", tone: "text-income", amountMinor: inc, href: "/butce" },
|
||||
{ key: "monthExpense", tone: "text-expense", amountMinor: exp, href: "/butce" },
|
||||
{
|
||||
key: "monthRemaining",
|
||||
tone: "text-budget",
|
||||
amountMinor: inc !== null && exp !== null ? inc - exp : null,
|
||||
},
|
||||
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
||||
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
||||
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
||||
];
|
||||
}
|
||||
|
||||
async function SummaryCards({ userId, householdId }: { userId: string; householdId: string }) {
|
||||
const cards = await getSummary(userId, householdId);
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.key} size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
||||
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
||||
{c.amountMinor === null ? (
|
||||
"—"
|
||||
) : c.href ? (
|
||||
<Link href={c.href} className="hover:underline">
|
||||
{formatMoney(c.amountMinor)}
|
||||
</Link>
|
||||
) : (
|
||||
formatMoney(c.amountMinor)
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{c.amountMinor === null ? (
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
{tr.dashboard.noData}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage({ searchParams }: PageProps<"/dashboard">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
|
||||
description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<OverdueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<BudgetWarningBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||
<SummaryCards userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Skeleton className="h-96 rounded-xl" />}>
|
||||
<OverviewCard userId={user.id} householdId={household.id} searchParams={searchParams} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<Suspense fallback={null}>
|
||||
<LowStockCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<ShoppingCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { PricesView } from "@/features/in-kind/components/prices-view";
|
||||
import { listPrices } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.prices.title };
|
||||
|
||||
/** /fiyatlar — altın ve döviz fiyat tablosu (elle; Faz 9 varlık takibi de kullanır). */
|
||||
export default async function PricesPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listPrices(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.prices.title} description={tr.prices.description} />
|
||||
<PricesView rows={data.rows} canWrite={data.canWrite} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { House } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { getHouseholdContext } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.households.noHousehold.title };
|
||||
|
||||
/** Hiçbir haneye üye olmayan kullanıcılar (ör. hanesi olmayan admin) buraya yönlenir. */
|
||||
export default async function NoHouseholdPage() {
|
||||
const ctx = await getHouseholdContext();
|
||||
if (!ctx) redirect("/login");
|
||||
if (ctx.household) redirect("/dashboard");
|
||||
const t = tr.households.noHousehold;
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<span className="mb-2 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<House className="size-6" />
|
||||
</span>
|
||||
<CardTitle className="text-xl">{t.title}</CardTitle>
|
||||
<CardDescription>{t.description}</CardDescription>
|
||||
</CardHeader>
|
||||
{ctx.user.role === "admin" ? (
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<p className="text-sm text-muted-foreground">{t.adminHint}</p>
|
||||
<Button asChild>
|
||||
<Link href="/admin/haneler">{t.adminLink}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { StatementView } from "@/features/transactions/components/statement-view";
|
||||
import { getCardSummary, getOverdraftSummary } from "@/features/transactions/card";
|
||||
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { MONTH_PATTERN } from "@/shared/statement";
|
||||
|
||||
export const metadata: Metadata = { title: tr.statement.title };
|
||||
|
||||
/** /hesaplar/:id?ay=2026-11 — aylık hesap ekstresi (yürüyen bakiye) ve mutabakat. */
|
||||
export default async function AccountStatementPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps<"/hesaplar/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||
const now = currentMonth();
|
||||
const requested = typeof sp.ay === "string" && MONTH_PATTERN.test(sp.ay) ? sp.ay : now;
|
||||
const month = requested > now ? now : requested;
|
||||
const statement = await getAccountStatement(user.id, household.id, id, month);
|
||||
if (!statement) notFound();
|
||||
const [card, overdraft] = await Promise.all([
|
||||
statement.account.type === "credit_card"
|
||||
? getCardSummary(user.id, household.id, id)
|
||||
: Promise.resolve(null),
|
||||
statement.account.type === "bank"
|
||||
? getOverdraftSummary(user.id, household.id, id)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
return (
|
||||
<StatementView
|
||||
key={`${id}-${month}`}
|
||||
statement={statement}
|
||||
card={card}
|
||||
overdraft={overdraft}
|
||||
canWrite={hasHouseholdRole(household.role, "member")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { AccountsView } from "@/features/accounts/components/accounts-view";
|
||||
import { listAccounts, listMemberOptions } from "@/features/accounts/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.accounts.title };
|
||||
|
||||
export default async function AccountsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, members] = await Promise.all([
|
||||
listAccounts(user.id, household.id, { includeArchived: true }),
|
||||
listMemberOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !members) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.accounts.title} description={tr.accounts.description} />
|
||||
<AccountsView
|
||||
accounts={data.accounts}
|
||||
totals={data.totals}
|
||||
members={members}
|
||||
canEdit={household.role === "owner"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { exportTransactions } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { getHouseholdContext } from "@/lib/dal";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate } from "@/shared/format";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
|
||||
/** GET /islemler/disa-aktar?…filtreler → CSV (listedeki filtrelerle, sayfalama yok). */
|
||||
export async function GET(request: Request) {
|
||||
const ctx = await getHouseholdContext();
|
||||
if (!ctx?.household) return new Response(tr.common.unauthorized, { status: 401 });
|
||||
const params = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const filters = transactionFilterSchema.parse(params);
|
||||
const rows = await exportTransactions(ctx.user.id, ctx.household.id, filters);
|
||||
if (!rows) return new Response(tr.common.unauthorized, { status: 403 });
|
||||
|
||||
const t = tr.transactions;
|
||||
const csv = toCsv(
|
||||
t.csv.header,
|
||||
rows.map((r) => [
|
||||
r.no,
|
||||
formatDate(`${r.date}T12:00:00+03:00`),
|
||||
t.types[r.type],
|
||||
t.statuses[r.status],
|
||||
r.account,
|
||||
r.toAccount,
|
||||
r.category,
|
||||
r.payee,
|
||||
r.description,
|
||||
r.note,
|
||||
r.tags,
|
||||
csvMoney(r.amountMinor),
|
||||
r.createdBy,
|
||||
]),
|
||||
);
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${t.csv.filename(todayIstanbul())}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ImportWizard } from "@/features/imports/components/import-wizard";
|
||||
import { getImportDraft } from "@/features/imports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.imports.title };
|
||||
|
||||
/** /islemler/ice-aktar/:id — aktarma taslağı ya da işlenmiş aktarma. */
|
||||
export default async function ImportPage({ params }: PageProps<"/islemler/ice-aktar/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("member");
|
||||
const { id } = await params;
|
||||
const draft = await getImportDraft(user.id, household.id, id);
|
||||
if (!draft) notFound();
|
||||
return <ImportWizard key={`${draft.id}-${draft.status}`} draft={draft} />;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UploadCard } from "@/features/imports/components/upload-card";
|
||||
import { listImports } from "@/features/imports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { formatDateTime } from "@/shared/format";
|
||||
|
||||
const t = tr.imports;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /islemler/ice-aktar — Kakeibo çalışma kitabı yükleme ve aktarma geçmişi. */
|
||||
export default async function ImportsPage() {
|
||||
const { user, household } = await requireActiveHousehold("member");
|
||||
const list = await listImports(user.id, household.id);
|
||||
if (!list) notFound();
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/islemler">
|
||||
<ArrowLeft />
|
||||
{tr.nav.transactions}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader title={t.title} description={t.description} />
|
||||
<UploadCard />
|
||||
<section aria-labelledby="imp-h" className="flex flex-col gap-2">
|
||||
<h2 id="imp-h" className="text-sm font-medium text-muted-foreground">
|
||||
{t.history}
|
||||
</h2>
|
||||
{list.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{list.map((b) => (
|
||||
<li key={b.id}>
|
||||
<Link
|
||||
href={`/islemler/ice-aktar/${b.id}`}
|
||||
className="flex items-center gap-3 p-3 text-sm hover:bg-accent/40"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{b.fileName}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{formatDateTime(b.createdAt)} · {t.rowsCount(b.rowCount)}
|
||||
{b.committed ? ` · ${t.committed(b.committed.transactions)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={b.status === "committed" ? "default" : "secondary"}>
|
||||
{t.statuses[b.status]}
|
||||
</Badge>
|
||||
<ChevronRight className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { listMemberOptions } from "@/features/accounts/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { TransactionsView } from "@/features/transactions/components/transactions-view";
|
||||
import { getTransactionFormOptions, listTransactions } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
|
||||
export const metadata: Metadata = { title: tr.transactions.title };
|
||||
|
||||
export default async function TransactionsPage({ searchParams }: PageProps<"/islemler">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const params = await searchParams;
|
||||
// Yalnızca tekil string değerler (dizi parametreleri yok sayılır).
|
||||
const raw = Object.fromEntries(
|
||||
Object.entries(params).filter(
|
||||
(e): e is [string, string] => typeof e[1] === "string" && e[1] !== "",
|
||||
),
|
||||
);
|
||||
const filters = transactionFilterSchema.parse(raw);
|
||||
const [data, options, members] = await Promise.all([
|
||||
listTransactions(user.id, household.id, filters),
|
||||
getTransactionFormOptions(user.id, household.id),
|
||||
listMemberOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options || !members) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.transactions.title} description={tr.transactions.description} />
|
||||
<TransactionsView
|
||||
// Filtre değişince istemci durumu (açık diyaloglar) sıfırlansın.
|
||||
key={JSON.stringify(raw)}
|
||||
data={data}
|
||||
filters={filters}
|
||||
raw={raw}
|
||||
options={{ ...options, members }}
|
||||
canWrite={hasHouseholdRole(household.role, "member")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { CategoriesManager } from "@/features/categories/components/categories-manager";
|
||||
import { listCategories } from "@/features/categories/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.categories.title };
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const categories = await listCategories(user.id, household.id, { includeArchived: true });
|
||||
if (!categories) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.categories.title} description={tr.categories.description} />
|
||||
<CategoriesManager
|
||||
categories={categories}
|
||||
canEdit={hasHouseholdRole(household.role, "member")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { AppHeader } from "@/components/layout/app-header";
|
||||
import { AppShellFallback } from "@/components/layout/app-shell-fallback";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { BottomNav } from "@/components/layout/bottom-nav";
|
||||
import { QuickAddProvider } from "@/components/layout/quick-add";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { getHouseholdContext } from "@/lib/dal";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Oturum gerektiren alan. Oturum okuması istek anında yapılır (Cache Components), bu yüzden
|
||||
* kullanıcıya bağlı kabuk Suspense içinde render edilir. Her sayfa ayrıca kendi yetkisini
|
||||
* DAL üzerinden kontrol eder (layout istemci gezinmesinde yeniden çalışmaz).
|
||||
*/
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={<AppShellFallback />}>
|
||||
<AuthenticatedShell>{children}</AuthenticatedShell>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
|
||||
const ctx = await getHouseholdContext();
|
||||
if (!ctx) redirect("/login");
|
||||
const { user, household, memberships } = ctx;
|
||||
const canWrite = household ? hasHouseholdRole(household.role, "member") : false;
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<QuickAddProvider>
|
||||
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
|
||||
<SidebarInset>
|
||||
<AppHeader
|
||||
user={user}
|
||||
households={memberships}
|
||||
activeHouseholdId={household?.id}
|
||||
canWrite={canWrite}
|
||||
version={APP_VERSION}
|
||||
/>
|
||||
{/* Mobilde alt menü yüksekliği kadar boşluk */}
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div>
|
||||
</SidebarInset>
|
||||
<BottomNav canWrite={canWrite} />
|
||||
</QuickAddProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { PlannedView } from "@/features/recurring/components/planned-view";
|
||||
import { getPlannedOverview } from "@/features/recurring/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.recurring.title };
|
||||
|
||||
export default async function PlannedPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await getPlannedOverview(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.recurring.title} description={tr.recurring.description} />
|
||||
<PlannedView data={data} canWrite={hasHouseholdRole(household.role, "member")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getShoppingReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { parsePeriod } from "@/shared/reports";
|
||||
|
||||
/** GET /raporlar/alisveris/csv?donem=3|6|12&tablo=listeler|magazalar */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getShoppingReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
const today = todayIstanbul();
|
||||
if (c.params.get("tablo") === "magazalar")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.stores,
|
||||
r.stores.map((s) => [
|
||||
s.name,
|
||||
csvMoney(s.amountMinor),
|
||||
s.count,
|
||||
csvPct(s.sharePct),
|
||||
csvMoney(s.fromListsMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.stores(today),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.lists,
|
||||
r.lists.map((l) => [
|
||||
l.date,
|
||||
l.name,
|
||||
csvMoney(l.summary.plannedMinor),
|
||||
csvMoney(l.summary.actualMinor),
|
||||
csvMoney(l.summary.diffMinor),
|
||||
l.summary.counts.same,
|
||||
l.summary.counts.substitute,
|
||||
l.summary.counts.skipped,
|
||||
l.summary.counts.extra,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.lists(today),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { ShoppingReportView } from "@/features/reports/components/shopping-waste-view";
|
||||
import { getShoppingReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
|
||||
|
||||
const t = tr.reports.shopping;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/alisveris?donem=3|6|12 — liste vs. gerçekleşen, mağaza bazlı harcama. */
|
||||
export default async function ShoppingReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/alisveris">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const period = parsePeriod((await searchParams).donem);
|
||||
const data = await getShoppingReport(user.id, household.id, period);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/alisveris"
|
||||
label={tr.reports.periodLabel}
|
||||
value={String(period)}
|
||||
options={REPORT_PERIODS.map((p) => ({
|
||||
value: String(p),
|
||||
label: tr.reports.periods[p]!,
|
||||
}))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton
|
||||
href={`/raporlar/alisveris/csv?donem=${period}&tablo=listeler`}
|
||||
label={t.csvLists}
|
||||
/>
|
||||
<CsvButton
|
||||
href={`/raporlar/alisveris/csv?donem=${period}&tablo=magazalar`}
|
||||
label={t.csvStores}
|
||||
/>
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ShoppingReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getAssetsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatAssetQuantity } from "@/shared/assets";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/birikim/csv — varlık başına maliyet, değer ve kâr/zarar. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const q = c.params.get("donem");
|
||||
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
|
||||
const r = await getAssetsReport(c.userId, c.householdId, range);
|
||||
if (!r) return forbidden();
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
tr.reports.csvHeaders.assets,
|
||||
r.items.map((a) => [
|
||||
a.name || a.label,
|
||||
formatAssetQuantity(a.assetKey, a.summary.quantity),
|
||||
csvMoney(a.summary.costMinor),
|
||||
a.summary.currentValueMinor === null ? "" : csvMoney(a.summary.currentValueMinor),
|
||||
a.summary.unrealizedMinor === null ? "" : csvMoney(a.summary.unrealizedMinor),
|
||||
csvMoney(a.summary.realizedMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.assets(todayIstanbul()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AssetsReportView } from "@/features/reports/components/contacts-assets-view";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getAssetsReport } from "@/features/reports/extra";
|
||||
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.assets;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/birikim?donem=3|6|12|tum — varlık kâr/zarar ve dönem özeti. */
|
||||
export default async function AssetsReportPage({ searchParams }: PageProps<"/raporlar/birikim">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const q = (await searchParams).donem;
|
||||
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
|
||||
const data = await getAssetsReport(user.id, household.id, range);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/birikim"
|
||||
label={tr.reports.periodLabel}
|
||||
value={range}
|
||||
options={REPORT_RANGES.map((r) => ({ value: r, label: t.ranges[r]! }))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/birikim/csv" />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AssetsReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getUnitPriceReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { unitPriceBase } from "@/shared/prices";
|
||||
|
||||
/** GET /raporlar/birim-fiyat/csv?tip=<ürün tipi> — ay başına en düşük ve ortalama (gösterim biriminde). */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getUnitPriceReport(c.userId, c.householdId, c.params.get("tip") ?? undefined);
|
||||
if (!r?.selected) return forbidden();
|
||||
const sel = r.selected;
|
||||
const base = unitPriceBase(sel.unit);
|
||||
const money = (x: number | null) => (x === null ? "" : csvMoney(Math.round(x * base.factor)));
|
||||
const header = tr.reports.csvHeaders.unitPrice.map((h, i) =>
|
||||
i === 1 || i === 2 ? `${h} (TL/${base.unit})` : h,
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
header,
|
||||
sel.points.map((p) => [p.month, money(p.minMinor), money(p.avgMinor), p.count]),
|
||||
),
|
||||
tr.reports.csvFiles.unitPrice(
|
||||
sel.name.toLocaleLowerCase("tr-TR").replace(/\s+/g, "-"),
|
||||
todayIstanbul(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
|
||||
import { UnitPriceView } from "@/features/reports/components/unit-price-view";
|
||||
import { getUnitPriceReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.unitPrice;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/birim-fiyat?tip=<ürün tipi> — aylık en düşük ve ortalama birim fiyat. */
|
||||
export default async function UnitPriceReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/birim-fiyat">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const tip = (await searchParams).tip;
|
||||
const data = await getUnitPriceReport(
|
||||
user.id,
|
||||
household.id,
|
||||
typeof tip === "string" ? tip : undefined,
|
||||
);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
data.selected ? (
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/birim-fiyat/csv?tip=${data.selected.id}`} />
|
||||
</ToolsEnd>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<UnitPriceView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getBudgetReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/butce/csv?ay=YYYY-MM — kategori bazında limit ve gerçekleşen. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getBudgetReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.budget,
|
||||
r.lines.map((l) => [
|
||||
l.path,
|
||||
l.limitMinor === null ? "" : csvMoney(l.limitMinor),
|
||||
csvMoney(l.actualMinor),
|
||||
l.remainingMinor === null ? "" : csvMoney(l.remainingMinor),
|
||||
csvPct(l.ratioBp === null ? null : Math.round(l.ratioBp / 10) / 10),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.budget(r.month));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BudgetReportView } from "@/features/reports/components/budget-report-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getBudgetReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.budget;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/butce?ay=YYYY-MM — bütçe ve gerçekleşen. */
|
||||
export default async function BudgetReportPage({ searchParams }: PageProps<"/raporlar/butce">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getBudgetReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/butce" />
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/butce/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BudgetReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||