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
+89
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
import { parseQuantity } from "../quantity";
const s = tr.shopping.validation;
@@ -72,3 +73,91 @@ export const shoppingItemUpdateSchema = z.object({
export type ShoppingItemUpdate = z.output<typeof shoppingItemUpdateSchema>;
export const shoppingItemRefSchema = z.object({ listId: id, itemId: id });
const positiveQuantity = z
.string()
.trim()
.transform((x, ctx) => {
const q = parseQuantity(x);
if (q === null || q <= 0) {
ctx.addIssue({ code: "custom", message: s.quantity });
return z.NEVER;
}
return q;
});
const amount = z
.string()
.trim()
.transform((x, ctx) => {
const minor = parseMoney(x);
if (minor === null || minor <= 0) {
ctx.addIssue({ code: "custom", message: s.amount });
return z.NEVER;
}
return minor;
});
/**
* Alışverişi tamamla. Satır: liste kalemi (itemId) ya da plansız ekstra (itemId boş). Ürün
* kaleminde/ekstrada ürün zorunlu; serbest metin kaleminde ürün yok. Tutar satırın ödenen tutarı.
*/
export const shoppingCompletionSchema = z
.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, tr.validation.date),
lines: z
.array(
z.object({
itemId: optionalId.default(""),
productId: optionalId.default(""),
storeId: id,
packages: positiveQuantity,
amount,
}),
)
.min(1, s.noLines)
.max(200),
stores: z
.array(
z.object({
storeId: id,
accountId: z.string().regex(/^[a-f0-9]{24}$/i, s.account),
attachmentIds: z.array(id).max(10).default([]),
}),
)
.max(10),
/** Gider kategorisine eşlenmemiş stok kategorilerindeki kalemler için. */
fallbackCategoryId: optionalId.default(""),
leftover: z.object({
action: z.enum(["move", "new", "drop"]),
targetListId: optionalId.default(""),
newListName: z.string().trim().max(80).default(""),
}),
})
.superRefine((x, ctx) => {
const items = x.lines.map((l) => l.itemId).filter(Boolean);
if (new Set(items).size !== items.length)
ctx.addIssue({ code: "custom", path: ["lines"], message: s.duplicateLine });
x.lines.forEach((l, i) => {
if (!l.itemId && !l.productId)
ctx.addIssue({ code: "custom", path: ["lines", i, "productId"], message: s.product });
});
const stores = new Set(x.stores.map((st) => st.storeId));
if (stores.size !== x.stores.length)
ctx.addIssue({ code: "custom", path: ["stores"], message: s.duplicateStore });
for (const l of x.lines)
if (!stores.has(l.storeId)) {
ctx.addIssue({ code: "custom", path: ["stores"], message: s.account });
break;
}
if (x.leftover.action === "move" && !x.leftover.targetListId)
ctx.addIssue({ code: "custom", path: ["leftover", "targetListId"], message: s.targetList });
if (x.leftover.action === "new" && !x.leftover.newListName)
ctx.addIssue({
code: "custom",
path: ["leftover", "newListName"],
message: tr.common.required,
});
});
export type ShoppingCompletionInput = z.input<typeof shoppingCompletionSchema>;
export type ShoppingCompletionForm = z.output<typeof shoppingCompletionSchema>;
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { compareShopping, matchKind, type ComparisonRow } from "./shopping-compare";
const plan = (productId: string, packages: number, priceMinor: number) => ({
productId,
packages: packages * 1000,
priceMinor,
costMinor: packages * priceMinor,
});
const actual = (productId: string | null, packages: number, amountMinor: number) => ({
productId,
packages: packages * 1000,
amountMinor,
});
describe("matchKind", () => {
it("ürün tipi kalemi: planlanan marka aynı, başka marka muadil, plan yoksa aynı", () => {
const item = { kind: "type" as const, productId: null };
expect(matchKind(item, plan("a", 1, 100), actual("a", 1, 100))).toBe("same");
expect(matchKind(item, plan("a", 1, 100), actual("b", 1, 100))).toBe("substitute");
expect(matchKind(item, null, actual("b", 1, 100))).toBe("same");
expect(matchKind(item, plan("a", 1, 100), null)).toBe("skipped");
});
it("ürün kalemi başka ürünle muadil; serbest metin alındıysa aynı; listede yoksa ekstra", () => {
expect(matchKind({ kind: "product", productId: "a" }, null, actual("c", 1, 100))).toBe(
"substitute",
);
expect(matchKind({ kind: "text", productId: null }, null, actual(null, 1, 100))).toBe("same");
expect(matchKind(null, null, actual("x", 1, 100))).toBe("extra");
});
});
describe("compareShopping", () => {
it("fark nedenlere ayrışır ve toplamı farka eşittir", () => {
const rows: ComparisonRow[] = [
// Aynı ürün: 2 paket planlandı (100), 3 alındı ve paket 110 → fiyat +30, miktar +100.
{ match: "same", plan: plan("a", 2, 100), actual: actual("a", 3, 330) },
// Muadil: plan 200, gerçek 180.
{ match: "substitute", plan: plan("b", 1, 200), actual: actual("b2", 1, 180) },
// Alınmadı: −50.
{ match: "skipped", plan: plan("c", 1, 50), actual: null },
// Fiyatı bilinmeyen kalem alındı: +40.
{ match: "same", plan: null, actual: actual("d", 1, 40) },
// Ekstra: +75.
{ match: "extra", plan: null, actual: actual("e", 1, 75) },
];
const s = compareShopping(rows);
expect(s.plannedMinor).toBe(200 + 200 + 50);
expect(s.actualMinor).toBe(330 + 180 + 40 + 75);
expect(s.reasons).toEqual({
price: 30,
quantity: 100,
substitute: -20,
skipped: -50,
extra: 75,
unplanned: 40,
});
const sum = Object.values(s.reasons).reduce((a, b) => a + b, 0);
expect(sum).toBe(s.diffMinor);
expect(s.counts).toEqual({ same: 2, substitute: 1, skipped: 1, extra: 1 });
});
it("kesirli paket: plan fiyatıyla beklenen yuvarlanır, toplam yine tam", () => {
const s = compareShopping([
{
match: "same",
plan: plan("a", 1, 999),
actual: { productId: "a", packages: 1500, amountMinor: 1500 },
},
]);
expect(s.reasons.price + s.reasons.quantity).toBe(s.diffMinor);
expect(s.reasons.quantity).toBe(1499 - 999);
});
});
+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 };
}
+49
View File
@@ -39,3 +39,52 @@ export function suggestionHidden(dismissedAt: Date | null, lastPurchaseAt: Date
if (!dismissedAt) return false;
return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime();
}
/** Alınmayan kalemler: başka aktif listeye taşı, yeni liste aç ya da bırak. */
export const LEFTOVER_ACTIONS = ["move", "new", "drop"] as const;
export type LeftoverAction = (typeof LEFTOVER_ACTIONS)[number];
/** Tamamlanan listede saklanan görüntü (etiketler tamamlama anındaki hâliyle). */
export type CompletionRow = {
itemId: string | null;
match: "same" | "substitute" | "skipped" | "extra";
/** Kalem adı (liste kalemi ya da ekstra ürün). */
label: string;
/** Alınan: ürün etiketi ve paketi; serbest metinde boş. */
productId: string | null;
productLabel: string;
storeId: string | null;
storeLabel: string;
/** Paket adedi (binde bir). */
packages: number;
/** Alınan miktar metni ("4 paket", markasızda "2,5 kg"); alınmadıysa boş. */
quantityLabel?: string;
amountMinor: number | null;
plan: {
productId: string;
productLabel: string;
storeId: string;
storeLabel: string;
packages: number;
priceMinor: number;
costMinor: number;
} | null;
};
export type CompletionStore = {
storeId: string;
label: string;
accountId: string;
accountName: string;
transactionId: string;
amountMinor: number;
attachmentCount: number;
};
export type ShoppingCompletion = {
date: string;
completedBy: string;
rows: CompletionRow[];
stores: CompletionStore[];
movedTo: { listId: string; name: string } | null;
};