/** * Alışveriş sonrası karşılaştırma (Bölüm 6.12). Saf fonksiyonlar. * * Her liste kalemi için: aynı (planlanan ürün alındı), muadil (aynı ürün tipinde başka marka/paket), * alınmadı; listede olmayanlar plansız ekstra. Planlanan tutar, tamamlama anındaki akıllı önerinin * (önerilen dağılım) kalem maliyetidir; fiyatı bilinmeyen kalemin planı yoktur. * * Fark (gerçek − plan) nedenlere ayrıştırılır ve toplamları farka tam eşittir: * - fiyat: aynı ürünün paket fiyatı farkı × alınan paket, * - miktar: (alınan − planlanan paket) × planlanan paket fiyatı, * - muadil: muadil kalemlerde gerçek − plan, * - alınmayan: alınmayan planlı kalemlerin planı (eksi), * - ekstra: plansız ekstraların tutarı, * - fiyatsız: planı olmayan (fiyatı bilinmeyen) kalemlerin tutarı. */ export const MATCH_KINDS = ["same", "substitute", "skipped", "extra"] as const; export type MatchKind = (typeof MATCH_KINDS)[number]; /** Liste kalemi veya ekstra için gerçekleşen alım. Paket adedi binde bir. */ export type ActualPurchase = { productId: string | null; packages: number; amountMinor: number }; /** Akıllı önerinin kalem için planı. */ export type PlannedPurchase = { productId: string; packages: number; priceMinor: number; costMinor: number; }; /** * Kalemin eşleşmesi. Ürün kalemi: aynı ürün → aynı. Ürün tipi kalemi: planlanan markayla aynı * ürün (ya da plan yoksa herhangi bir ürün) → aynı; başka marka/paket → muadil. Serbest metin * kalemi alındıysa aynı. */ export function matchKind( item: { kind: "type" | "product" | "text"; productId: string | null } | null, plan: PlannedPurchase | null, actual: ActualPurchase | null, ): MatchKind { if (!item) return "extra"; if (!actual) return "skipped"; if (item.kind === "text") return "same"; const expected = item.kind === "product" ? item.productId : (plan?.productId ?? null); return expected === null || expected === actual.productId ? "same" : "substitute"; } export type ComparisonRow = { match: MatchKind; plan: PlannedPurchase | null; actual: ActualPurchase | null; }; export type ComparisonReasons = { price: number; quantity: number; substitute: number; skipped: number; extra: number; unplanned: number; }; export type ComparisonSummary = { plannedMinor: number; actualMinor: number; diffMinor: number; reasons: ComparisonReasons; counts: Record; }; export function compareShopping(rows: readonly ComparisonRow[]): ComparisonSummary { const reasons: ComparisonReasons = { price: 0, quantity: 0, substitute: 0, skipped: 0, extra: 0, unplanned: 0, }; const counts: Record = { same: 0, substitute: 0, skipped: 0, extra: 0 }; let plannedMinor = 0; let actualMinor = 0; for (const r of rows) { counts[r.match]++; if (r.plan) plannedMinor += r.plan.costMinor; if (r.actual) actualMinor += r.actual.amountMinor; switch (r.match) { case "extra": reasons.extra += r.actual?.amountMinor ?? 0; break; case "skipped": reasons.skipped -= r.plan?.costMinor ?? 0; break; case "substitute": if (r.plan) reasons.substitute += r.actual!.amountMinor - r.plan.costMinor; else reasons.unplanned += r.actual!.amountMinor; break; case "same": { const a = r.actual!; if (!r.plan || a.productId !== r.plan.productId) { // Plansız (fiyatı bilinmeyen) ya da serbest metin. if (r.plan) reasons.substitute += a.amountMinor - r.plan.costMinor; else reasons.unplanned += a.amountMinor; break; } // Paket adedi binde bir: plan fiyatıyla beklenen = paket × fiyat. const expectedAtPlanPrice = Math.round((a.packages * r.plan.priceMinor) / 1000); reasons.price += a.amountMinor - expectedAtPlanPrice; reasons.quantity += expectedAtPlanPrice - r.plan.costMinor; break; } } } return { plannedMinor, actualMinor, diffMinor: actualMinor - plannedMinor, reasons, counts }; }