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