Files
butcem/src/shared/schemas/shopping.ts
T
suphicakir bd5a20ce64 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>
2026-11-06 23:57:50 +03:00

164 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
import { parseQuantity } from "../quantity";
const s = tr.shopping.validation;
const id = z.string().regex(/^[a-f0-9]{24}$/i);
const optionalId = z
.string()
.regex(/^([a-f0-9]{24})?$/i)
.transform((x) => (x === "" ? null : x));
/** Boş → null (1 paket / belirtilmemiş); dolu → binde bir pozitif tamsayı. */
const optionalQuantity = z.preprocess(
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
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;
})
.nullable(),
);
export const shoppingListFormSchema = z.object({
name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax),
note: z.string().trim().max(300).default(""),
});
export type ShoppingListFormInput = z.input<typeof shoppingListFormSchema>;
export type ShoppingListForm = z.output<typeof shoppingListFormSchema>;
/** Kalem: ürün tipi, belirli ürün (marka/paket) ya da serbest metin. */
export const shoppingItemFormSchema = z
.object({
kind: z.enum(["type", "product", "text"]),
productTypeId: optionalId.default(""),
productId: optionalId.default(""),
text: z.string().trim().max(120).default(""),
quantity: optionalQuantity.default(null),
unit: z.string().trim().max(20).default(""),
note: z.string().trim().max(200).default(""),
})
.superRefine((x, ctx) => {
if (x.kind === "type" && !x.productTypeId)
ctx.addIssue({ code: "custom", path: ["productTypeId"], message: s.productType });
if (x.kind === "product" && !x.productId)
ctx.addIssue({ code: "custom", path: ["productId"], message: s.product });
if (x.kind === "text" && !x.text)
ctx.addIssue({ code: "custom", path: ["text"], message: s.text });
})
.transform((x) => ({
...x,
productTypeId: x.kind === "type" ? x.productTypeId : null,
productId: x.kind === "product" ? x.productId : null,
text: x.kind === "text" ? x.text : "",
unit: x.kind === "text" ? x.unit : "",
}));
export type ShoppingItemFormInput = z.input<typeof shoppingItemFormSchema>;
export type ShoppingItemForm = z.output<typeof shoppingItemFormSchema>;
/** Kalem düzenleme: miktar ve not (serbest metinde metin ve birim de). */
export const shoppingItemUpdateSchema = z.object({
quantity: optionalQuantity.default(null),
text: z.string().trim().max(120).default(""),
unit: z.string().trim().max(20).default(""),
note: z.string().trim().max(200).default(""),
});
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>;