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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user