bd5a20ce64
- 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>
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|