7 Commits

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