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>
164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
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>;
|