feat(shopping): alışveriş listeleri, stok eşiği önerileri ve akıllı mağaza önerisi motoru

- shoppingLists koleksiyonu (ürün tipi / marka / serbest metin kalemleri), migration 015
- planShopping saf fonksiyonu: paket yuvarlama, zahmet ve hacimli cezası, teslimat, yemek kartı önceliği, ek mağaza eşiği
- eşik önerileri onay/red (red stok yenilenene kadar), ürün tipine hacimli işareti
- fiyat servisinde ortak loadTypeOffers; seed listeleri ve testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 23:28:54 +03:00
parent 0b7de17b5c
commit 8ab2244755
21 changed files with 2325 additions and 16 deletions
+74
View File
@@ -0,0 +1,74 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
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 });