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 birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */ export type TypeOffer = { productId: string; productTypeId: string; storeId: string; priceMinor: number; inTypeUnit: number; unitPriceMinor: number; date: string; stale: boolean; isPromo: boolean; }; /** * Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik * ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak. */ export async function loadTypeOffers( householdId: Types.ObjectId, productTypeIds: Types.ObjectId[], { today = todayIstanbul(), activeOnly = false } = {}, ): Promise { if (!productTypeIds.length) return []; const [products, types, archivedStores] = await Promise.all([ ProductModel.find({ householdId, productTypeId: { $in: productTypeIds }, ...(activeOnly ? { archivedAt: null } : {}), }) .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 }[]>(), activeOnly ? StoreModel.find({ householdId, archivedAt: { $ne: null } }) .select({ _id: 1 }) .lean<{ _id: Types.ObjectId }[]>() : Promise.resolve([]), ]); const skipStore = new Set(archivedStores.map((x) => String(x._id))); 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) { if (skipStore.has(String(r.storeId))) continue; 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: TypeOffer[] = []; 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)!; out.push({ productId: String(p._id), productTypeId: typeId, storeId: String(records[0]!.storeId), priceMinor: eff.record.priceMinor, inTypeUnit: inType, unitPriceMinor: unitPrice(eff.record.priceMinor, inType), date: eff.record.date, stale: eff.stale, isPromo: eff.record.isPromo && !eff.promoEnded, }); } return out; } /** Bir ürün tipinin tüm fiyat kayıtları, ürün tipi biriminde birim fiyatla (dönüşümsüzler hariç). */ export async function loadTypePriceHistory( householdId: Types.ObjectId, productTypeId: Types.ObjectId, ): Promise<{ date: string; productId: string; storeId: string; unitPriceMinor: number }[]> { const [products, type] = await Promise.all([ ProductModel.find({ householdId, productTypeId }) .select({ packageQuantity: 1, packageUnit: 1, typeQuantity: 1 }) .lean< { _id: Types.ObjectId; packageQuantity: number; packageUnit: string; typeQuantity: number | null; }[] >(), ProductTypeModel.findOne({ householdId, _id: productTypeId }) .select({ unit: 1 }) .lean<{ unit: string }>(), ]); if (!type) return []; const inType = new Map( products.map((p) => [ String(p._id), packageInTypeUnit({ packageQuantity: p.packageQuantity, packageUnit: p.packageUnit, typeUnit: type.unit, typeQuantity: p.typeQuantity, }), ]), ); const prices = await loadPrices( householdId, products.map((p) => p._id), ); return prices.flatMap((r) => { const q = inType.get(String(r.productId)); return q ? [ { date: r.date, productId: String(r.productId), storeId: String(r.storeId), unitPriceMinor: unitPrice(r.priceMinor, q), }, ] : []; }); } /** * Ü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 out = new Map(); for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) { const prev = out.get(o.productTypeId); if (!prev || o.date > prev.date) out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.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 };