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>;