feat: Faz 12b - fiyat kayıtları ve birim fiyat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+20
-1
@@ -6,6 +6,24 @@ 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)
|
### 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".
|
- 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".
|
||||||
@@ -359,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ 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 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. */
|
||||||
@@ -30,6 +31,7 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m011,
|
m011,
|
||||||
m012,
|
m012,
|
||||||
m013,
|
m013,
|
||||||
|
m014,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe("seed", () => {
|
|||||||
expect(summary.created.stock).toBe(19);
|
expect(summary.created.stock).toBe(19);
|
||||||
expect(summary.created.stores).toBe(8);
|
expect(summary.created.stores).toBe(8);
|
||||||
expect(summary.created.products).toBe(22);
|
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);
|
||||||
});
|
});
|
||||||
@@ -59,6 +60,7 @@ describe("seed", () => {
|
|||||||
stock: 0,
|
stock: 0,
|
||||||
stores: 0,
|
stores: 0,
|
||||||
products: 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);
|
||||||
|
|||||||
+6
-1
@@ -13,7 +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 { seedShopping } from "./shopping";
|
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";
|
||||||
@@ -60,6 +60,7 @@ export type SeedSummary = {
|
|||||||
stock: number;
|
stock: number;
|
||||||
stores: number;
|
stores: number;
|
||||||
products: number;
|
products: number;
|
||||||
|
prices: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -89,6 +90,7 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"stockCounts",
|
"stockCounts",
|
||||||
"stores",
|
"stores",
|
||||||
"products",
|
"products",
|
||||||
|
"productPrices",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -147,6 +149,7 @@ export async function runSeed({
|
|||||||
stock: 0,
|
stock: 0,
|
||||||
stores: 0,
|
stores: 0,
|
||||||
products: 0,
|
products: 0,
|
||||||
|
prices: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -336,6 +339,8 @@ export async function runSeed({
|
|||||||
summary.created.products = shopping.products;
|
summary.created.products = shopping.products;
|
||||||
if (shopping.stores || shopping.products)
|
if (shopping.stores || shopping.products)
|
||||||
log(`✓ Örnek mağazalar ve ürünler: ${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 = [
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { ProductModel } from "@/models/product";
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
import { ProductTypeModel } from "@/models/product-type";
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
import { StoreModel } from "@/models/store";
|
import { StoreModel } from "@/models/store";
|
||||||
import type { MealCardProvider } from "@/shared/accounts";
|
import type { MealCardProvider } from "@/shared/accounts";
|
||||||
import { autoConvertible } from "@/shared/products";
|
import { autoConvertible } from "@/shared/products";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
import { foldTr } from "@/shared/search";
|
import { foldTr } from "@/shared/search";
|
||||||
import type { StoreKind } from "@/shared/stores";
|
import type { StoreKind } from "@/shared/stores";
|
||||||
|
|
||||||
@@ -326,3 +329,166 @@ export async function seedShopping(
|
|||||||
}
|
}
|
||||||
return { stores, 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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 { ProductsSection } from "@/features/products/components/products-section";
|
||||||
import { listProducts, listProductTypeOptions } from "@/features/products/service";
|
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";
|
||||||
@@ -13,24 +15,28 @@ 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, products, types] = 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 }),
|
listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
|
||||||
listProductTypeOptions(user.id, household.id),
|
listProductTypeOptions(user.id, household.id),
|
||||||
|
getTypePriceComparison(user.id, household.id, id),
|
||||||
]);
|
]);
|
||||||
if (!data || !options || !products || !types) notFound();
|
if (!data || !options || !products || !types || !prices) notFound();
|
||||||
return (
|
return (
|
||||||
<ProductDetailView
|
<ProductDetailView
|
||||||
data={data}
|
data={data}
|
||||||
categories={options.categories}
|
categories={options.categories}
|
||||||
products={
|
products={
|
||||||
<ProductsSection
|
<>
|
||||||
productTypeId={data.id}
|
<TypePriceComparison data={prices} />
|
||||||
products={products.products}
|
<ProductsSection
|
||||||
types={types}
|
productTypeId={data.id}
|
||||||
canWrite={products.canWrite}
|
products={products.products}
|
||||||
/>
|
types={types}
|
||||||
|
canWrite={products.canWrite}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -28,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 = 12.1;
|
export const CURRENT_PHASE = 12.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -83,7 +83,11 @@ export function ProductList({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium">{p.label}</p>
|
<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 className="truncate text-sm text-muted-foreground tabular-nums">
|
||||||
{p.packageLabel}
|
{p.packageLabel}
|
||||||
{showType ? (
|
{showType ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+64
-1
@@ -981,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",
|
||||||
@@ -1348,6 +1352,65 @@ export const tr = {
|
|||||||
archived: "Ürün arşivli.",
|
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:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const AUDIT_ACTIONS = [
|
|||||||
"store.unarchive",
|
"store.unarchive",
|
||||||
"product.archive",
|
"product.archive",
|
||||||
"product.unarchive",
|
"product.unarchive",
|
||||||
|
"price.delete",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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>;
|
||||||
Reference in New Issue
Block a user