From e67a1d5411cdf31f36949688ef77a00ffba00528 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 12:27:51 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(prices):=20fiyat=20kay=C4=B1tlar=C4=B1?= =?UTF-8?q?,=20birim=20fiyat=20motoru=20ve=20analizde=20ayl=C4=B1k=20maliy?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/014-product-prices.ts | 10 + scripts/migrations/index.ts | 2 + scripts/seed/run.int.test.ts | 2 + scripts/seed/run.ts | 7 +- scripts/seed/shopping.ts | 166 +++++++++ src/features/prices/actions.ts | 44 +++ src/features/prices/options.ts | 18 + src/features/prices/service.int.test.ts | 150 ++++++++ src/features/prices/service.ts | 436 +++++++++++++++++++++++ src/features/stock/service.ts | 22 +- src/i18n/tr.ts | 65 +++- src/models/audit-log.ts | 1 + src/models/product-price.ts | 38 ++ src/shared/prices.test.ts | 92 +++++ src/shared/prices.ts | 124 +++++++ src/shared/schemas/product-price.ts | 41 +++ 16 files changed, 1212 insertions(+), 6 deletions(-) create mode 100644 scripts/migrations/014-product-prices.ts create mode 100644 src/features/prices/actions.ts create mode 100644 src/features/prices/options.ts create mode 100644 src/features/prices/service.int.test.ts create mode 100644 src/features/prices/service.ts create mode 100644 src/models/product-price.ts create mode 100644 src/shared/prices.test.ts create mode 100644 src/shared/prices.ts create mode 100644 src/shared/schemas/product-price.ts diff --git a/scripts/migrations/014-product-prices.ts b/scripts/migrations/014-product-prices.ts new file mode 100644 index 0000000..6c12c12 --- /dev/null +++ b/scripts/migrations/014-product-prices.ts @@ -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(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index d90024a..dc8e080 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -13,6 +13,7 @@ import { migration as m010 } from "./010-budgets"; import { migration as m011 } from "./011-stock"; import { migration as m012 } from "./012-stock-counts"; import { migration as m013 } from "./013-stores-products"; +import { migration as m014 } from "./014-product-prices"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ @@ -30,6 +31,7 @@ export const MIGRATIONS: Migration[] = [ m011, m012, m013, + m014, ]; const COLLECTION = "migrations"; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index 92b7dc6..a3e466a 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -37,6 +37,7 @@ describe("seed", () => { expect(summary.created.stock).toBe(19); expect(summary.created.stores).toBe(8); expect(summary.created.products).toBe(22); + expect(summary.created.prices).toBeGreaterThan(200); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); @@ -59,6 +60,7 @@ describe("seed", () => { stock: 0, stores: 0, products: 0, + prices: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 284a447..0227767 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -13,7 +13,7 @@ import { runMigrations } from "../migrations"; import { seedContacts } from "./contacts"; import { seedAssets } from "./assets"; import { seedBudgets } from "./budgets"; -import { seedShopping } from "./shopping"; +import { seedProductPrices, seedShopping } from "./shopping"; import { seedStock } from "./stock"; import { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; @@ -60,6 +60,7 @@ export type SeedSummary = { stock: number; stores: number; products: number; + prices: number; }; accounts: SeedAccountRow[]; }; @@ -89,6 +90,7 @@ const DOMAIN_COLLECTIONS = [ "stockCounts", "stores", "products", + "productPrices", ]; type AuthApi = ReturnType["api"]; @@ -147,6 +149,7 @@ export async function runSeed({ stock: 0, stores: 0, products: 0, + prices: 0, }, accounts: [], }; @@ -336,6 +339,8 @@ export async function runSeed({ summary.created.products = shopping.products; if (shopping.stores || shopping.products) log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`); + summary.created.prices = await seedProductPrices(householdIds, userIdMap); + if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`); // 7) Geliştirme hesapları tablosu const all = [ diff --git a/scripts/seed/shopping.ts b/scripts/seed/shopping.ts index cdabf25..52da88f 100644 --- a/scripts/seed/shopping.ts +++ b/scripts/seed/shopping.ts @@ -1,9 +1,12 @@ import { Types } from "mongoose"; import { ProductModel } from "@/models/product"; +import { ProductPriceModel } from "@/models/product-price"; import { ProductTypeModel } from "@/models/product-type"; import { StoreModel } from "@/models/store"; import type { MealCardProvider } from "@/shared/accounts"; import { autoConvertible } from "@/shared/products"; +import { todayIstanbul } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; import { foldTr } from "@/shared/search"; import type { StoreKind } from "@/shared/stores"; @@ -326,3 +329,166 @@ export async function seedShopping( } return { stores, products }; } + +// --- Fiyatlar (Faz 12b) --- + +/** Bugünkü paket fiyatı (kuruş, Kasım 2026 tahmini). */ +const CURRENT_PRICE: Record = { + "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 = { + 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, + users: Map, +): Promise { + 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; +} diff --git a/src/features/prices/actions.ts b/src/features/prices/actions.ts new file mode 100644 index 0000000..70e489e --- /dev/null +++ b/src/features/prices/actions.ts @@ -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 = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + 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)); +} diff --git a/src/features/prices/options.ts b/src/features/prices/options.ts new file mode 100644 index 0000000..1f045ce --- /dev/null +++ b/src/features/prices/options.ts @@ -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, +}); diff --git a/src/features/prices/service.int.test.ts b/src/features/prices/service.int.test.ts new file mode 100644 index 0000000..4cfe4bc --- /dev/null +++ b/src/features/prices/service.int.test.ts @@ -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>; +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 = {}) => + 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(); + }); +}); diff --git a/src/features/prices/service.ts b/src/features/prices/service.ts new file mode 100644 index 0000000..a510386 --- /dev/null +++ b/src/features/prices/service.ts @@ -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[]; + /** Tüm kayıtlar, yeniden eskiye. */ + history: PriceRow[]; + /** Grafik: mağaza serileri ve tarih noktaları (paket fiyatı, kuruş). */ + series: StoreRef[]; + points: Record[]; + stores: StoreRef[]; + canWrite: boolean; + today: string; +}; + +export type TypePriceComparison = { + typeUnit: string; + offers: RankedOffer[]; + /** 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(); + 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(); + 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, + today: string, +): StoreOffer[] { + const groups = new Map(); + 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) => + [...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 { + 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>(); + 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 { + 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> { + 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(); + 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(); + 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) { + 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> { + 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 { + 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() + : 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 }; diff --git a/src/features/stock/service.ts b/src/features/stock/service.ts index 229227f..b31b546 100644 --- a/src/features/stock/service.ts +++ b/src/features/stock/service.ts @@ -12,6 +12,7 @@ import { } from "@/features/categories/tree"; import { hasHouseholdRole } from "@/features/households/roles"; import { resolveMembership, type Membership } from "@/features/households/service"; +import { latestTypeUnitPrices } from "@/features/prices/service"; import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { writeAudit } from "@/lib/audit"; @@ -24,6 +25,7 @@ import { StockCountModel } from "@/models/stock-count"; import { StockMovementModel } from "@/models/stock-movement"; import { todayIstanbul } from "@/shared/dates"; import { shiftMonth } from "@/shared/budget"; +import { QUANTITY_SCALE } from "@/shared/quantity"; import type { ProductTypeForm, StockCategoryForm, @@ -1242,6 +1244,10 @@ export type StockAnalysisRow = { avgMonthlyWasted: number | null; /** Fire / (kullanım + fire), %. */ wastePct: number | null; + /** Son bilinen birim fiyat (ürün tipi biriminde, kesirli kuruş) ve tarihi. */ + unitPrice: { minor: number; date: string } | null; + /** Aylık ortalama maliyet tahmini: ortalama tüketim × son birim fiyat (kuruş). */ + avgMonthlyCostMinor: number | null; /** Bu ay (bugüne kadar). */ thisMonth: MonthUsage; }; @@ -1264,10 +1270,11 @@ export async function getStockAnalysis( shiftMonth(thisMonth, i - ANALYSIS_MONTHS), ); const m = (await resolveMembership(userId, householdId, "viewer"))!; - const moves = await loadMoves( - m.householdId, - data.items.map((i) => new Types.ObjectId(i.id)), - ); + const ids = data.items.map((i) => new Types.ObjectId(i.id)); + const [moves, prices] = await Promise.all([ + loadMoves(m.householdId, ids), + latestTypeUnitPrices(m.householdId, ids, data.today), + ]); const rows = data.items.map((item): StockAnalysisRow => { const list = moves.get(item.id) ?? []; const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!]; @@ -1277,6 +1284,8 @@ export async function getStockAnalysis( const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0); const used = sum("used"); const wasted = sum("wasted"); + const price = prices.get(item.id) ?? null; + const avgUsed = full.length ? used / full.length : null; return { id: item.id, name: item.name, @@ -1287,6 +1296,11 @@ export async function getStockAnalysis( avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null, avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null, wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null, + unitPrice: price ? { minor: price.unitPriceMinor, date: price.date } : null, + avgMonthlyCostMinor: + price && avgUsed !== null + ? Math.round((avgUsed * price.unitPriceMinor) / QUANTITY_SCALE) + : null, thisMonth: current, }; }); diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 6e10a0b..c4def65 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -981,7 +981,11 @@ export const tr = { analysis: { title: "Tüketim analizi", description: - "Ürün tipi başına aylık tüketim ve fire. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet, ürün fiyatları (Faz 12) gelince eklenecek.", + "Ürün tipi başına aylık tüketim, fire ve tahmini maliyet. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet: aylık ortalama tüketim × son bilinen birim fiyat.", + avgCost: "Aylık ort. maliyet", + costHint: (price: string, date: string) => `Son birim fiyat ${price} (${date})`, + noPrice: "Fiyat yok", + totalCost: (total: string) => `Fiyatı bilinen ürünlerde aylık tahmini maliyet: ${total}`, back: "Ev stoğuna dön", product: "Ürün tipi", avgUsed: "Aylık ort. tüketim", @@ -1348,6 +1352,65 @@ export const tr = { 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, + 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: { title: "Fiyat tablosu", description: diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index 1abbc93..f7f3336 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -44,6 +44,7 @@ export const AUDIT_ACTIONS = [ "store.unarchive", "product.archive", "product.unarchive", + "price.delete", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/product-price.ts b/src/models/product-price.ts new file mode 100644 index 0000000..8eb9474 --- /dev/null +++ b/src/models/product-price.ts @@ -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:`. 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; + +export const ProductPriceModel: Model = + (models.ProductPrice as Model | undefined) ?? + model("ProductPrice", productPriceSchema); diff --git a/src/shared/prices.test.ts b/src/shared/prices.test.ts new file mode 100644 index 0000000..bcc9beb --- /dev/null +++ b/src/shared/prices.test.ts @@ -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]); + }); +}); diff --git a/src/shared/prices.ts b/src/shared/prices.ts new file mode 100644 index 0000000..5ccf51f --- /dev/null +++ b/src/shared/prices.ts @@ -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 = { + 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( + records: readonly T[], + today: string, +): EffectivePrice | 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 & { + 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(offers: readonly T[]): RankedOffer[] { + 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; diff --git a/src/shared/schemas/product-price.ts b/src/shared/schemas/product-price.ts new file mode 100644 index 0000000..0cc2a26 --- /dev/null +++ b/src/shared/schemas/product-price.ts @@ -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; +export type ProductPriceForm = z.output; From 9e32f2988d4f3cc5a2764c01a40393e8115bc8d2 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 12:27:51 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(ui):=20=C3=BCr=C3=BCn=20sayfas=C4=B1,?= =?UTF-8?q?=20fiyat=20ge=C3=A7mi=C5=9Fi=20grafi=C4=9Fi=20ve=20birim=20fiya?= =?UTF-8?q?t=20kar=C5=9F=C4=B1la=C5=9Ft=C4=B1rmas=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/stok/[id]/page.tsx | 22 +- src/app/(app)/stok/urunler/[id]/page.tsx | 17 ++ src/app/globals.css | 29 ++ src/components/layout/nav-config.ts | 2 +- src/components/page-header.tsx | 2 +- .../prices/components/offers-list.tsx | 124 ++++++++ .../prices/components/price-dialog.tsx | 200 +++++++++++++ .../prices/components/product-price-view.tsx | 271 ++++++++++++++++++ .../components/type-price-comparison.tsx | 52 ++++ .../products/components/product-list.tsx | 6 +- .../stock/components/stock-analysis-view.tsx | 32 ++- 11 files changed, 744 insertions(+), 13 deletions(-) create mode 100644 src/app/(app)/stok/urunler/[id]/page.tsx create mode 100644 src/features/prices/components/offers-list.tsx create mode 100644 src/features/prices/components/price-dialog.tsx create mode 100644 src/features/prices/components/product-price-view.tsx create mode 100644 src/features/prices/components/type-price-comparison.tsx diff --git a/src/app/(app)/stok/[id]/page.tsx b/src/app/(app)/stok/[id]/page.tsx index 2b6e2ae..65e9f4a 100644 --- a/src/app/(app)/stok/[id]/page.tsx +++ b/src/app/(app)/stok/[id]/page.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { TypePriceComparison } from "@/features/prices/components/type-price-comparison"; +import { getTypePriceComparison } from "@/features/prices/service"; import { ProductsSection } from "@/features/products/components/products-section"; import { listProducts, listProductTypeOptions } from "@/features/products/service"; import { ProductDetailView } from "@/features/stock/components/product-detail-view"; @@ -13,24 +15,28 @@ export const metadata: Metadata = { title: tr.stock.detail.movements }; export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) { const { user, household } = await requireActiveHousehold("viewer"); 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), getStockOptions(user.id, household.id), listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }), listProductTypeOptions(user.id, household.id), + getTypePriceComparison(user.id, household.id, id), ]); - if (!data || !options || !products || !types) notFound(); + if (!data || !options || !products || !types || !prices) notFound(); return ( + <> + + + } /> ); diff --git a/src/app/(app)/stok/urunler/[id]/page.tsx b/src/app/(app)/stok/urunler/[id]/page.tsx new file mode 100644 index 0000000..6dcab9f --- /dev/null +++ b/src/app/(app)/stok/urunler/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/globals.css b/src/app/globals.css index da536eb..31f01e6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -31,6 +31,14 @@ --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --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-input: var(--input); --color-border: var(--border); @@ -95,6 +103,17 @@ --chart-3: var(--expense); --chart-4: var(--warning); --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; /* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */ @@ -156,6 +175,16 @@ --chart-4: var(--warning); --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-sky: oklch(0.74 0.13 230); --swatch-teal: oklch(0.74 0.13 185); diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 32810ed..f6c63ed 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -28,7 +28,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 12.1; +export const CURRENT_PHASE = 12.2; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx index c3fe65d..c1b74ec 100644 --- a/src/components/page-header.tsx +++ b/src/components/page-header.tsx @@ -5,7 +5,7 @@ export function PageHeader({ actions, }: { title: string; - description?: string; + description?: React.ReactNode; actions?: React.ReactNode; }) { return ( diff --git a/src/features/prices/components/offers-list.tsx b/src/features/prices/components/offers-list.tsx new file mode 100644 index 0000000..4d4ea71 --- /dev/null +++ b/src/features/prices/components/offers-list.tsx @@ -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 ( + + ); +} + +/** Teklif rozetleri: en uygun, kampanya, kampanya bitti, eski fiyat. */ +function OfferBadges({ offer }: { offer: RankedOffer }) { + const p = offer.price; + return ( + + {offer.cheapest ? ( + + + {t.cheapest} + + ) : null} + {p.isPromo && !offer.promoEnded ? ( + + + {t.promo} + {p.promoUntil ? ` · ${t.promoUntil(ymdLabel(p.promoUntil))}` : ""} + + ) : null} + {offer.fellBack ? {t.fellBack} : null} + {offer.promoEnded ? {t.promoEnded} : null} + {offer.stale ? ( + + + {t.stale(offer.ageDays)} + + ) : null} + + ); +} + +/** + * 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[]; + typeUnit: string; + showProduct?: boolean; +}) { + return ( +
    + {offers.map((o) => ( +
  1. + {o.rank ?? "–"} +
    + {showProduct ? ( +

    + + {o.productLabel} + {" "} + {o.packageLabel} +

    + ) : null} +

    + + {o.store.label} + + · {ymdLabel(o.price.date)} + +

    +
    + +
    +
    +
    +

    + {o.unitPriceMinor === null ? ( + {t.noConversion} + ) : ( + formatUnitPrice(o.unitPriceMinor, typeUnit) + )} +

    +

    + {formatMoney(o.price.priceMinor)} + {o.diffPct ? ` · ${t.diff(o.diffPct)}` : ""} +

    +
    +
  2. + ))} +
+ ); +} diff --git a/src/features/prices/components/price-dialog.tsx b/src/features/prices/components/price-dialog.tsx new file mode 100644 index 0000000..3446765 --- /dev/null +++ b/src/features/prices/components/price-dialog.tsx @@ -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; + +/** 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({}); + const [busy, setBusy] = useState(false); + const set = (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 ( + !o && onClose()} + title={t.addTitle} + description={product ? `${product.label} · ${product.packageLabel}` : undefined} + footer={ + <> + + + + } + > +
+ + {productId ? null : ( + + {f.product} + + {errors.productId ? {errors.productId} : null} + + )} + + {f.store} + + {errors.storeId ? {errors.storeId} : null} + +
+ + {f.date} + set("date", e.target.value)} + aria-invalid={Boolean(errors.date)} + /> + {errors.date ? {errors.date} : null} + + + {f.price} + set("price", e.target.value)} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.price)} + autoFocus + /> + {errors.price ? {errors.price} : null} + +
+ {preview ? ( + + {f.unitPreview(preview)} + + ) : null} + + set("isPromo", x === true)} + /> + {f.isPromo} + + {v.isPromo ? ( + + {f.promoUntil} + set("promoUntil", e.target.value)} + aria-invalid={Boolean(errors.promoUntil)} + /> + {f.promoUntilHint} + {errors.promoUntil ? {errors.promoUntil} : null} + + ) : null} + + {f.note} + set("note", e.target.value)} /> + + {errors.root ? {errors.root} : null} +
+
+
+ ); +} diff --git a/src/features/prices/components/product-price-view.tsx b/src/features/prices/components/product-price-view.tsx new file mode 100644 index 0000000..7436abb --- /dev/null +++ b/src/features/prices/components/product-price-view.tsx @@ -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 ( + + + + shortDate(v)} + /> + compactTl(v)} + /> + + payload?.[0] ? ymdLabel(String(payload[0].payload.date)) : "" + } + formatter={(value, name) => ( +
+ + `s${s.id}` === name)?.slot ?? 1} /> + {config[String(name)]?.label} + + {formatMoney(Number(value))} +
+ )} + /> + } + /> + } /> + {data.series.map((s) => ( + + ))} +
+
+ ); +} + +/** /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(null); + const p = data.product; + const canAdd = data.canWrite && !p.archived && data.stores.length > 0; + + return ( +
+ + + + {t.detail.typeLink(p.productTypeName)} + + {" · "} + {p.packageLabel} + {p.barcode ? ` · ${tr.products.barcode(p.barcode)}` : ""} + + } + actions={ + canAdd ? ( + + ) : null + } + /> +
+ {p.archived ? {tr.products.archived} : null} + {p.conversionMissing ? ( + + {tr.products.conversionMissing} + + ) : null} + +
+ + + + {t.detail.currentTitle} + {t.detail.currentDescription} + + + {data.offers.length === 0 ? ( +

{t.noPrices}

+ ) : ( + + )} +
+
+ + {data.history.length > 1 ? ( + + + {t.detail.chartTitle} + {t.detail.chartDescription} + + + + + + ) : null} + + {data.history.length ? ( +
+

+ {t.detail.historyTitle} +

+
    + {data.history.map((r) => ( +
  • +
    +

    + + {r.store.label} +

    +

    + {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}` : ""} +

    +
    +
    +

    {formatMoney(r.priceMinor)}

    + {r.unitPriceMinor !== null ? ( +

    + {formatUnitPrice(r.unitPriceMinor, p.typeUnit)} +

    + ) : null} +
    + {data.canWrite ? ( + + ) : null} +
  • + ))} +
+
+ ) : null} + + {adding ? ( + setAdding(false)} + /> + ) : null} + !o && setDeleting(null)}> + + + {t.detail.deleteTitle} + {t.detail.deleteDescription} + + + {tr.common.cancel} + + + + +
+ ); +} diff --git a/src/features/prices/components/type-price-comparison.tsx b/src/features/prices/components/type-price-comparison.tsx new file mode 100644 index 0000000..69c265a --- /dev/null +++ b/src/features/prices/components/type-price-comparison.tsx @@ -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 ( + + +
+ {t.comparisonTitle} + + {t.comparisonDescription(unitPriceBase(data.typeUnit).unit)} + +
+ {data.canWrite && data.products.length && data.stores.length ? ( + + ) : null} +
+ + {data.products.length === 0 ? ( +

{t.noProducts}

+ ) : data.offers.length === 0 ? ( +

{t.noPrices}

+ ) : ( + + )} +
+ {adding ? ( + setAdding(false)} + /> + ) : null} +
+ ); +} diff --git a/src/features/products/components/product-list.tsx b/src/features/products/components/product-list.tsx index 87091f4..32a9dab 100644 --- a/src/features/products/components/product-list.tsx +++ b/src/features/products/components/product-list.tsx @@ -83,7 +83,11 @@ export function ProductList({
)}
-

{p.label}

+

+ + {p.label} + +

{p.packageLabel} {showType ? ( diff --git a/src/features/stock/components/stock-analysis-view.tsx b/src/features/stock/components/stock-analysis-view.tsx index 448ef5d..ad5b380 100644 --- a/src/features/stock/components/stock-analysis-view.tsx +++ b/src/features/stock/components/stock-analysis-view.tsx @@ -4,6 +4,8 @@ import { PageHeader } from "@/components/page-header"; import { Button } from "@/components/ui/button"; import { tr } from "@/i18n/tr"; import { cn } from "@/lib/utils"; +import { formatDate, formatMoney } from "@/shared/format"; +import { formatUnitPrice } from "@/shared/prices"; import { formatStock } from "@/shared/stock"; import type { StockAnalysis, StockAnalysisRow } from "../service"; @@ -72,6 +74,7 @@ function Num({ value, unit, tone }: { value: number | null; unit: string; tone?: export function StockAnalysisView({ data }: { data: StockAnalysis }) { const groups = new Map(); for (const r of data.rows) groups.set(r.categoryPath, [...(groups.get(r.categoryPath) ?? []), r]); + const totalCost = data.rows.reduce((sum, r) => sum + (r.avgMonthlyCostMinor ?? 0), 0); const first = data.months[0]!; const last = data.months.at(-1)!; @@ -91,6 +94,7 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {

) : ( <> + {totalCost > 0 ?

{t.totalCost(formatMoney(totalCost))}

: null}
{t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)} @@ -106,10 +110,11 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
-
+
{t.product} {t.trend(data.months.length)} {t.avgUsed} + {t.avgCost} {t.avgWasted} {t.wastePct} {t.thisMonth} @@ -125,7 +130,7 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) { return (
  • +

    + + {t.avgCost} + + + {r.avgMonthlyCostMinor === null + ? t.noData + : formatMoney(r.avgMonthlyCostMinor)} + +

    {t.avgWasted} From d46869c64446f021b0875443360312687c66f6b5 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 12:27:51 +0300 Subject: [PATCH 3/3] chore(release): 0.12.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 ++++++++++++++++++++- docs/PROMPT.md | 1 + package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5055c..1407af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [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:` (şimdilik elle giriş). Gelecek tarihli fiyat ve arşivli ürün/mağazaya fiyat girilemez; kayıt silme denetim kaydına yazılır. +- Birim fiyat: her fiyat ürün tipinin birimine çevrilir (TL/kg, TL/lt, TL/adet, TL/yıkama; g ve ml gösterimde kg ve lt). Yuvarlama yalnızca gösterimde. Geçerli fiyat mağaza başına son kayıt; süresi biten kampanyada önceki normal fiyata dönülür ("Kampanya bitti, normal fiyat"), 90 günden eski fiyat işaretlenir. +- Ürün tipi detayında "Birim fiyat karşılaştırması": tüm marka/paket ve mağazalar birim fiyata göre sıralı, en uygun vurgulu, diğerleri "%X pahalı"; buradan fiyat girişi (birim fiyat anlık önizlemesiyle). +- Ürün sayfası (`/stok/urunler/:id`): görseller, mağazalardaki geçerli fiyatlar, mağaza başına fiyat geçmişi grafiği (basamaklı çizgi, bugüne uzanır) ve fiyat kayıtları listesi. Mağaza renkleri sabit kategorik paletten (mağazanın ekleniş sırası; açık/koyu tema için doğrulanmış `--series-1…8`). +- Tüketim analizinde aylık ortalama maliyet: aylık ortalama tüketim × ürün tipinin son bilinen birim fiyatı; fiyatı bilinen ürünlerin toplamı. +- Migration `014-product-prices`. Seed: 22 ürün için son 6 ayın 265 fiyat kaydı (mağaza farkları, aylık artış, süren ve biten kampanya, eski fiyat örneği). +- Testler: birim fiyat ve gösterim birimi, geçerli fiyat (biten kampanya, eski fiyat), sıralama ve eşitlik, renk yuvası (birim); karşılaştırma sırası, kampanya dönüşü, fiyat girişi kuralları, silme ve denetim, analiz maliyeti (entegrasyon). + +### Değişti — Faz 12b + +- Sayfa başlığı açıklaması bağlantı içerebilir; ek dosya seçicisine özel düğme metni ve ipucu. + ### Eklendi — Faz 12a: mağazalar ve ürünler (marka/paket) - Mağazalar (`stores`, `/magazalar`): ad, tür (fiziksel market / online platform / pazar), şube, eve uzaklık (km), taşıma güçlüğü (1–5), online ise teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı geçerliliği ve geçerli sağlayıcılar, not. Zahmet skoru (0–100; uzaklık 20 km'ye kadar ve taşıma güçlüğü eşit ağırlıkla, online mağazada uzaklık sayılmaz) ve düzeyi; "ada göre / en yakın / en uzak / en zahmetli" sıralama. Aynı ad + şube tekil; arşivleme denetim kaydına yazılır. Kenar menüde "Mağazalar". @@ -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. - `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.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 diff --git a/docs/PROMPT.md b/docs/PROMPT.md index 7c790de..890aa70 100644 --- a/docs/PROMPT.md +++ b/docs/PROMPT.md @@ -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 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer. - **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür. +- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. --- diff --git a/package-lock.json b/package-lock.json index 66f7b18..a3fdb17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "butcem", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "butcem", - "version": "0.11.0", + "version": "0.12.0", "dependencies": { "@hookform/resolvers": "^7.0.2", "@tanstack/react-table": "^9.3.1", diff --git a/package.json b/package.json index 8ed1495..d4835ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.11.0", + "version": "0.12.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": {