feat(shopping): alışverişi tamamla transaction'ı ve karşılaştırma motoru

- tek transaction: stok alımı, fiş kaynaklı fiyat, mağaza başına bölünmüş gider, fiş ekleri, kalanların taşınması
- muadil, markasız ürün tipi ve plansız ekstra satırları; stok → gider kategorisi eşlemesi ve yedek kategori
- compareShopping saf fonksiyonu: aynı/muadil/alınmadı/ekstra, farkın nedenlere tam ayrışması
- tamamlanan listede rapor görüntüsü; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 23:57:50 +03:00
parent 3dd9834a3d
commit bd5a20ce64
12 changed files with 1291 additions and 4 deletions
+115
View File
@@ -0,0 +1,115 @@
/**
* 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<MatchKind, number>;
};
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<MatchKind, number> = { 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 };
}