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 });
+2
View File
@@ -77,6 +77,8 @@ export const productTypeFormSchema = z.object({
step: quantity(),
countEveryDays,
note: z.string().trim().max(300).default(""),
/** Ağır/hacimli: akıllı mağaza önerisinde taşıma cezası. */
bulky: z.boolean().default(false),
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
initialQuantity: optionalQuantity.default(null),
});
+269
View File
@@ -0,0 +1,269 @@
import { describe, expect, it } from "vitest";
import {
BULKY_PENALTY_PER_POINT,
planShopping,
VISIT_PENALTY_PER_POINT,
type PlanInput,
type PlanItem,
type PlanOffer,
type PlanStore,
} from "./shopping-plan";
import { effortScore } from "./stores";
const store = (id: string, over: Partial<PlanStore> = {}): PlanStore => ({
id,
kind: "market",
distanceM: 0,
carryDifficulty: 1,
deliveryFeeMinor: null,
freeDeliveryMinMinor: null,
mealCardProviders: [],
...over,
});
const item = (
id: string,
type: string,
quantity = 1000,
over: Partial<PlanItem> = {},
): PlanItem => ({
id,
productTypeId: type,
quantity,
productId: null,
bulky: false,
...over,
});
const offer = (
storeId: string,
type: string,
priceMinor: number,
over: Partial<PlanOffer> = {},
): PlanOffer => ({
productId: `${type}-p`,
productTypeId: type,
storeId,
priceMinor,
inTypeUnit: 1000,
...over,
});
const base = (over: Partial<PlanInput>): PlanInput => ({
items: [],
offers: [],
stores: [],
mealCards: [],
extraStoreThresholdMinor: 10_000,
...over,
});
const storesOf = (o: { stores: { storeId: string }[] } | null) => o?.stores.map((s) => s.storeId);
describe("planShopping", () => {
it("fiyatı olmayan kalemler ayrılır; hiç fiyat yoksa plan yok", () => {
const r = planShopping(base({ items: [item("i1", "t1")], stores: [store("A")] }));
expect(r.recommended).toBeNull();
expect(r.single).toBeNull();
expect(r.unpriced).toEqual(["i1"]);
});
it("paket sayısı ihtiyaca göre yukarı yuvarlanır; mağazada en az harcamalı paket seçilir", () => {
const r = planShopping(
base({
items: [item("i1", "t1", 2500)],
stores: [store("A")],
offers: [
offer("A", "t1", 3000, { productId: "small", inTypeUnit: 1000 }),
// Birim fiyatı daha ucuz ama 5 birimlik paket: harcama 12.000 > 3 × 3.000.
offer("A", "t1", 12_000, { productId: "big", inTypeUnit: 5000 }),
],
}),
);
const line = r.recommended!.stores[0]!.lines[0]!;
expect(line).toMatchObject({ productId: "small", packages: 3, costMinor: 9000 });
// En ucuz birim fiyat bilgisi büyük paketi gösterir.
expect(r.cheapest.i1!.productId).toBe("big");
});
it("belirli marka istenen kalemde yalnızca o ürünün fiyatı kullanılır", () => {
const r = planShopping(
base({
items: [item("i1", "t1", 1000, { productId: "ariel" })],
stores: [store("A"), store("B")],
offers: [
offer("A", "t1", 5000, { productId: "ariel" }),
offer("B", "t1", 3000, { productId: "omo" }),
],
}),
);
expect(storesOf(r.recommended)).toEqual(["A"]);
expect(r.recommended!.totalMinor).toBe(5000);
});
it("tasarruf eşiği aşılmazsa tek mağazada toplar", () => {
const r = planShopping(
base({
items: [item("i1", "t1"), item("i2", "t2")],
stores: [store("A"), store("B")],
offers: [
offer("A", "t1", 10_000),
offer("A", "t2", 10_000),
offer("B", "t1", 9_000),
offer("B", "t2", 10_500),
],
extraStoreThresholdMinor: 10_000,
}),
);
// A: 200 TL, B: 195 TL tek başına; ikisi birden 190 TL (5 TL fark < 100 TL eşik).
expect(storesOf(r.single)).toEqual(["B"]);
expect(storesOf(r.recommended)).toEqual(["B"]);
expect(r.savingMinor).toBe(0);
});
it("tasarruf eşiği aşılırsa ek mağaza önerilir", () => {
const r = planShopping(
base({
items: [item("i1", "t1"), item("i2", "t2"), item("i3", "t3")],
stores: [store("A"), store("B")],
offers: [
offer("A", "t1", 50_000),
offer("A", "t2", 50_000),
offer("A", "t3", 50_000),
offer("B", "t1", 30_000),
offer("B", "t2", 60_000),
offer("B", "t3", 60_000),
],
extraStoreThresholdMinor: 10_000,
}),
);
expect(storesOf(r.single)).toEqual(["A"]);
expect(r.recommended!.stores.map((s) => [s.storeId, s.lines.length])).toEqual([
["A", 2],
["B", 1],
]);
expect(r.recommended!.totalMinor).toBe(130_000);
expect(r.savingMinor).toBe(20_000);
});
it("tek mağaza her şeyi karşılamıyorsa en az mağazalı eksiksiz plan önerilir", () => {
const r = planShopping(
base({
items: [item("i1", "t1"), item("i2", "t2")],
stores: [store("A"), store("B")],
offers: [offer("A", "t1", 1000), offer("B", "t2", 1000)],
}),
);
expect(r.single!.missing).toHaveLength(1);
expect(r.recommended!.missing).toEqual([]);
expect(r.recommended!.stores).toHaveLength(2);
expect(r.savingMinor).toBeNull();
});
it("uzaklık ve taşıma güçlüğü ziyaret cezası olarak puana eklenir", () => {
const far = store("FAR", { distanceM: 20_000, carryDifficulty: 5 });
const near = store("NEAR");
const r = planShopping(
base({
items: [item("i1", "t1")],
stores: [far, near],
offers: [offer("FAR", "t1", 10_000), offer("NEAR", "t1", 13_000)],
}),
);
// FAR 30 TL ucuz ama zahmet 100 → 50 TL ceza.
expect(effortScore(far) * VISIT_PENALTY_PER_POINT).toBe(5000);
expect(storesOf(r.recommended)).toEqual(["NEAR"]);
expect(storesOf(r.single)).toEqual(["NEAR"]);
});
it("hacimli kalemler zahmetli fiziksel mağaza yerine online'a yönelir", () => {
const hard = store("HARD", { distanceM: 10_000, carryDifficulty: 5 });
const online = store("ONLINE", { kind: "online", distanceM: null, deliveryFeeMinor: 0 });
const items = [item("water", "w", 1000, { bulky: true }), item("bread", "b")];
const offers = [
offer("HARD", "w", 10_000),
offer("ONLINE", "w", 10_500),
offer("HARD", "b", 1000),
];
const bulky = planShopping(
base({ items, stores: [hard, online], offers, extraStoreThresholdMinor: 0 }),
);
const line = bulky.recommended!.stores.find((s) => s.storeId === "ONLINE")?.lines[0];
expect(line?.itemId).toBe("water");
// Aynı kalem hacimli değilse 5 TL ucuz olan HARD seçilir.
const plain = planShopping(
base({
items: [item("water", "w"), item("bread", "b")],
stores: [hard, online],
offers,
extraStoreThresholdMinor: 0,
}),
);
expect(storesOf(plain.recommended)).toEqual(["HARD"]);
expect(effortScore(hard) * BULKY_PENALTY_PER_POINT).toBeGreaterThan(500);
});
it("online teslimat ücreti ücretsiz teslimat limitinin altında eklenir", () => {
const online = store("ON", {
kind: "online",
distanceM: null,
deliveryFeeMinor: 3000,
freeDeliveryMinMinor: 50_000,
});
const shop = store("SHOP");
const offers = [offer("ON", "t1", 10_000), offer("SHOP", "t1", 11_000)];
const small = planShopping(base({ items: [item("i1", "t1")], stores: [online, shop], offers }));
// 100 TL + 30 TL teslimat > 110 TL.
expect(storesOf(small.recommended)).toEqual(["SHOP"]);
const big = planShopping(
base({ items: [item("i1", "t1", 5000)], stores: [online, shop], offers }),
);
// 500 TL ≥ limit: teslimat ücretsiz, online 50 TL ucuz.
expect(storesOf(big.recommended)).toEqual(["ON"]);
expect(big.recommended!.stores[0]!.deliveryFeeMinor).toBe(0);
});
it("yemek kartı bakiyesi varsa fiyatı yakın kalemler kart geçen mağazaya yönelir", () => {
const meal = store("MEAL", { mealCardProviders: ["multinet"] });
const other = store("OTHER");
const offers = [offer("MEAL", "t1", 10_300), offer("OTHER", "t1", 10_000)];
const items = [item("i1", "t1")];
const without = planShopping(base({ items, stores: [meal, other], offers }));
expect(storesOf(without.recommended)).toEqual(["OTHER"]);
const withCard = planShopping(
base({
items,
stores: [meal, other],
offers,
mealCards: [{ provider: "multinet", balanceMinor: 50_000 }],
}),
);
expect(storesOf(withCard.recommended)).toEqual(["MEAL"]);
expect(withCard.recommended!.mealCardMinor).toBe(10_300);
// Kart sağlayıcısı mağazada geçmiyorsa öncelik yok.
const wrong = planShopping(
base({
items,
stores: [meal, other],
offers,
mealCards: [{ provider: "sodexo", balanceMinor: 50_000 }],
}),
);
expect(storesOf(wrong.recommended)).toEqual(["OTHER"]);
});
it("yemek kartı bakiyesi mağazalar arasında bir kez harcanır", () => {
const a = store("A", { mealCardProviders: ["multinet"] });
const b = store("B", { mealCardProviders: ["multinet"] });
const r = planShopping(
base({
items: [item("i1", "t1"), item("i2", "t2")],
stores: [a, b],
offers: [offer("A", "t1", 30_000), offer("B", "t2", 20_000)],
mealCards: [{ provider: "multinet", balanceMinor: 40_000 }],
}),
);
expect(r.recommended!.mealCardMinor).toBe(40_000);
expect(r.recommended!.stores.map((s) => s.mealCardMinor).sort()).toEqual([10_000, 30_000]);
});
});
+307
View File
@@ -0,0 +1,307 @@
import { effortScore, type StoreKind } from "./stores";
/**
* Akıllı mağaza önerisi (Bölüm 6.12). Saf fonksiyon; son bilinen fiyatlarla listedeki her kalemi
* bir mağazaya ve markaya dağıtır.
*
* Kalem maliyeti: ihtiyacı karşılayan paket sayısı × paket fiyatı (gerçek harcama). Bir mağazada
* birden çok marka/paket varsa en az harcamalı olan, eşitlikte birim fiyatı düşük olan seçilir.
*
* Planın puanı (küçük olan iyi) = harcama + teslimat ücretleri + zahmet cezaları − yemek kartı payı:
* - Ziyaret cezası: fiziksel mağaza başına zahmet skoru × ZİYARET_KATSAYISI (online: 0).
* - Hacimli kalem cezası: fiziksel mağazada hacimli kalem başına zahmet skoru × HACİMLİ_KATSAYI.
* - Online teslimat: ara toplam ücretsiz teslimat alt limitinin altındaysa (limit yoksa her
* zaman) teslimat ücreti eklenir.
* - Yemek kartı önceliği: yemek kartı geçen mağazalarda kartla ödenebilecek tutarın
* MEAL_CARD_PREFERENCE_PCT'si puandan düşülür; böylece fiyatı yakın kalemler karta yönelir.
*
* Seçim: tüm fiyatlı kalemleri karşılayan en az mağazalı plandan başlanır; bir mağaza daha
* eklemek ancak puanı eşikten (hane ayarı) fazla düşürüyorsa kabul edilir.
*/
/** Ziyaret cezası: zahmet puanı başına kuruş (zahmet 100 → 50 TL). */
export const VISIT_PENALTY_PER_POINT = 50;
/** Hacimli kalem cezası: zahmet puanı başına kuruş (zahmet 100 → kalem başına 15 TL). */
export const BULKY_PENALTY_PER_POINT = 15;
/** Yemek kartıyla ödenebilecek tutarın puandan düşülen yüzdesi. */
export const MEAL_CARD_PREFERENCE_PCT = 10;
/** Bir planda en fazla bu kadar mağaza. */
export const MAX_PLAN_STORES = 4;
export type PlanStore = {
id: string;
kind: StoreKind;
distanceM: number | null;
carryDifficulty: number;
deliveryFeeMinor: number | null;
freeDeliveryMinMinor: number | null;
/** Yemek kartı geçmiyorsa boş. */
mealCardProviders: readonly string[];
};
export type PlanOffer = {
productId: string;
productTypeId: string;
storeId: string;
/** Paket fiyatı (kuruş). */
priceMinor: number;
/** Bir paketin ürün tipi birimindeki miktarı (binde bir). */
inTypeUnit: number;
};
export type PlanItem = {
id: string;
productTypeId: string;
/** Gereken miktar, ürün tipi biriminde (binde bir). */
quantity: number;
/** Belirli marka/ürün istenmişse yalnızca o ürünün fiyatları. */
productId: string | null;
bulky: boolean;
};
export type PlanMealCard = { provider: string; balanceMinor: number };
export type PlanInput = {
items: readonly PlanItem[];
offers: readonly PlanOffer[];
stores: readonly PlanStore[];
mealCards: readonly PlanMealCard[];
/** Ek mağaza eşiği (kuruş). */
extraStoreThresholdMinor: number;
};
export type PlanLine = {
itemId: string;
productId: string;
storeId: string;
packages: number;
priceMinor: number;
costMinor: number;
/** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş). */
unitPriceMinor: number;
bulkyPenaltyMinor: number;
};
export type PlanStoreGroup = {
storeId: string;
lines: PlanLine[];
subtotalMinor: number;
deliveryFeeMinor: number;
visitPenaltyMinor: number;
/** Bu mağazada yemek kartıyla ödenebilecek tutar. */
mealCardMinor: number;
};
export type PlanOption = {
stores: PlanStoreGroup[];
/** Harcama + teslimat. */
totalMinor: number;
/** Zahmet cezaları (ziyaret + hacimli). */
penaltyMinor: number;
mealCardMinor: number;
/** Karşılaştırma puanı (kesirli olabilir). */
score: number;
/** Bu planda alınamayan (fiyatlı) kalemler. */
missing: string[];
};
export type PlanResult = {
/** Tek mağazanın en iyisi (hepsini karşılayan yoksa en çok kalemi karşılayan). */
single: PlanOption | null;
/** Önerilen dağılım; tek mağaza en iyiyse onunla aynı. */
recommended: PlanOption | null;
/** Tek mağazaya göre harcama farkı (pozitif = tasarruf); karşılaştırılamıyorsa null. */
savingMinor: number | null;
/** Hiçbir mağazada fiyatı bilinmeyen kalemler. */
unpriced: string[];
/** Kalem başına birim fiyatı en düşük teklif (mağaza ve marka). */
cheapest: Record<string, PlanLine>;
};
const unitOf = (o: PlanOffer) => (o.priceMinor * 1000) / o.inTypeUnit;
/** Bir teklifle kalemi karşılamak: paket sayısı ve harcama. */
function lineFor(item: PlanItem, o: PlanOffer, store: PlanStore): PlanLine {
const packages = Math.max(1, Math.ceil(item.quantity / o.inTypeUnit));
const physical = store.kind !== "online";
return {
itemId: item.id,
productId: o.productId,
storeId: o.storeId,
packages,
priceMinor: o.priceMinor,
costMinor: packages * o.priceMinor,
unitPriceMinor: unitOf(o),
bulkyPenaltyMinor: item.bulky && physical ? effortScore(store) * BULKY_PENALTY_PER_POINT : 0,
};
}
const better = (a: PlanLine, b: PlanLine) =>
a.costMinor !== b.costMinor ? a.costMinor < b.costMinor : a.unitPriceMinor < b.unitPriceMinor;
/** Mağazalardaki yemek kartı payı: kart bakiyeleri bir kez harcanır, büyük ara toplam önce. */
function allocateMealCards(
groups: PlanStoreGroup[],
stores: Map<string, PlanStore>,
cards: readonly PlanMealCard[],
) {
const left = cards.map((c) => ({ ...c }));
const order = [...groups].sort((a, b) => b.subtotalMinor - a.subtotalMinor);
for (const g of order) {
const accepted = new Set(stores.get(g.storeId)!.mealCardProviders);
let need = g.subtotalMinor + g.deliveryFeeMinor;
for (const c of left) {
if (need <= 0) break;
if (!accepted.has(c.provider) || c.balanceMinor <= 0) continue;
const use = Math.min(need, c.balanceMinor);
c.balanceMinor -= use;
need -= use;
g.mealCardMinor += use;
}
}
}
function* subsets(n: number, k: number, start = 0, acc: number[] = []): Generator<number[]> {
if (acc.length === k) {
yield [...acc];
return;
}
for (let i = start; i <= n - (k - acc.length); i++) {
acc.push(i);
yield* subsets(n, k, i + 1, acc);
acc.pop();
}
}
export function planShopping(input: PlanInput): PlanResult {
const storeById = new Map(input.stores.map((s) => [s.id, s]));
// Kalem × mağaza en iyi satır.
const best = new Map<string, Map<string, PlanLine>>();
const cheapest: Record<string, PlanLine> = {};
for (const item of input.items) {
const byStore = new Map<string, PlanLine>();
for (const o of input.offers) {
if (o.inTypeUnit <= 0 || o.productTypeId !== item.productTypeId) continue;
if (item.productId && o.productId !== item.productId) continue;
const store = storeById.get(o.storeId);
if (!store) continue;
const line = lineFor(item, o, store);
const prev = byStore.get(o.storeId);
if (!prev || better(line, prev)) byStore.set(o.storeId, line);
const c = cheapest[item.id];
if (!c || line.unitPriceMinor < c.unitPriceMinor) cheapest[item.id] = line;
}
best.set(item.id, byStore);
}
const priced = input.items.filter((i) => best.get(i.id)!.size > 0);
const unpriced = input.items.filter((i) => best.get(i.id)!.size === 0).map((i) => i.id);
const candidates = input.stores.filter((s) => priced.some((i) => best.get(i.id)!.has(s.id)));
if (priced.length === 0 || candidates.length === 0)
return { single: null, recommended: null, savingMinor: null, unpriced, cheapest };
function evaluate(chosen: PlanStore[]): PlanOption {
const groups = new Map<string, PlanStoreGroup>();
const missing: string[] = [];
for (const item of priced) {
const options = best.get(item.id)!;
let pick: PlanLine | null = null;
for (const s of chosen) {
const line = options.get(s.id);
if (!line) continue;
if (
!pick ||
line.costMinor + line.bulkyPenaltyMinor < pick.costMinor + pick.bulkyPenaltyMinor ||
(line.costMinor + line.bulkyPenaltyMinor === pick.costMinor + pick.bulkyPenaltyMinor &&
line.unitPriceMinor < pick.unitPriceMinor)
)
pick = line;
}
if (!pick) {
missing.push(item.id);
continue;
}
const g = groups.get(pick.storeId) ?? {
storeId: pick.storeId,
lines: [],
subtotalMinor: 0,
deliveryFeeMinor: 0,
visitPenaltyMinor: 0,
mealCardMinor: 0,
};
g.lines.push(pick);
g.subtotalMinor += pick.costMinor;
groups.set(pick.storeId, g);
}
const list = [...groups.values()];
for (const g of list) {
const s = storeById.get(g.storeId)!;
if (s.kind === "online") {
const fee = s.deliveryFeeMinor ?? 0;
const free = s.freeDeliveryMinMinor;
g.deliveryFeeMinor = free !== null && g.subtotalMinor >= free ? 0 : fee;
} else g.visitPenaltyMinor = effortScore(s) * VISIT_PENALTY_PER_POINT;
}
allocateMealCards(list, storeById, input.mealCards);
const totalMinor = list.reduce((a, g) => a + g.subtotalMinor + g.deliveryFeeMinor, 0);
const penaltyMinor = list.reduce(
(a, g) => a + g.visitPenaltyMinor + g.lines.reduce((b, l) => b + l.bulkyPenaltyMinor, 0),
0,
);
const mealCardMinor = list.reduce((a, g) => a + g.mealCardMinor, 0);
return {
stores: list.sort(
(a, b) => b.lines.length - a.lines.length || b.subtotalMinor - a.subtotalMinor,
),
totalMinor,
penaltyMinor,
mealCardMinor,
score: totalMinor + penaltyMinor - (mealCardMinor * MEAL_CARD_PREFERENCE_PCT) / 100,
missing,
};
}
// Boyut başına en iyi tam (eksiksiz) plan; tek mağazada eksiksiz yoksa en çok kalemi karşılayan.
const bestBySize: (PlanOption | null)[] = [];
let single: PlanOption | null = null;
const maxK = Math.min(MAX_PLAN_STORES, candidates.length);
for (let k = 1; k <= maxK; k++) {
let top: PlanOption | null = null;
for (const idx of subsets(candidates.length, k)) {
const option = evaluate(idx.map((i) => candidates[i]!));
// Kullanılmayan mağaza içeren alt küme daha küçük boyutta zaten değerlendirildi.
if (option.stores.length < k) continue;
if (
k === 1 &&
(!single ||
option.missing.length < single.missing.length ||
(option.missing.length === single.missing.length && option.score < single.score))
)
single = option;
if (option.missing.length === 0 && (!top || option.score < top.score)) top = option;
}
bestBySize[k] = top;
}
let k = bestBySize.findIndex((o, i) => i > 0 && o !== null);
let recommended: PlanOption | null = k > 0 ? bestBySize[k]! : null;
// Tüm alt kümeler sınırı aştıysa (kalemler çok dağınık): en iyi eksikli plan.
if (!recommended) recommended = single;
else
while (k + 1 <= maxK) {
const next = bestBySize[k + 1];
if (!next || recommended.score - next.score <= input.extraStoreThresholdMinor) break;
recommended = next;
k++;
}
const comparable =
single && recommended && single.missing.length === 0 && recommended.missing.length === 0;
return {
single,
recommended,
savingMinor: comparable ? single!.totalMinor - recommended!.totalMinor : null,
unpriced,
cheapest,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { requiredQuantity, suggestionHidden } from "./shopping";
describe("requiredQuantity", () => {
it("ürün tipi kaleminde girilen miktar; boşsa 0", () => {
expect(requiredQuantity({ kind: "type", quantity: 2500 }, null)).toBe(2500);
expect(requiredQuantity({ kind: "type", quantity: null }, null)).toBe(0);
});
it("ürün kaleminde paket adedi × paketin tip karşılığı; boşsa 1 paket", () => {
// 2 paket × 50 yıkama
expect(requiredQuantity({ kind: "product", quantity: 2000 }, 50_000)).toBe(100_000);
expect(requiredQuantity({ kind: "product", quantity: null }, 500)).toBe(500);
expect(requiredQuantity({ kind: "product", quantity: 2000 }, null)).toBe(0);
});
});
describe("suggestionHidden", () => {
const d = (s: string) => new Date(s);
it("reddedilmemişse görünür", () => {
expect(suggestionHidden(null, null)).toBe(false);
});
it("redden sonra alım yoksa gizli, alım gelince yeniden görünür", () => {
expect(suggestionHidden(d("2026-11-01"), null)).toBe(true);
expect(suggestionHidden(d("2026-11-01"), d("2026-10-20"))).toBe(true);
expect(suggestionHidden(d("2026-11-01"), d("2026-11-03"))).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { QUANTITY_SCALE } from "./quantity";
/** Alışveriş listeleri (Bölüm 6.12): sabitler ve saf yardımcılar. */
export const SHOPPING_LIST_STATUSES = ["active", "completed"] as const;
export type ShoppingListStatus = (typeof SHOPPING_LIST_STATUSES)[number];
export const SHOPPING_ITEM_KINDS = ["type", "product", "text"] as const;
export type ShoppingItemKind = (typeof SHOPPING_ITEM_KINDS)[number];
export const SHOPPING_ITEM_SOURCES = ["manual", "suggestion"] as const;
export type ShoppingItemSource = (typeof SHOPPING_ITEM_SOURCES)[number];
/** Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve bu aralıkla yenilenir. */
export const SHOPPING_REFRESH_MS = 30_000;
/**
* Planlayıcı için gereken miktar (ürün tipi biriminde, binde bir):
* - ürün tipi kalemi: girilen miktar; boşsa 0 (en az bir paket alınır),
* - ürün kalemi: paket adedi × paketin tip birimindeki karşılığı.
*/
export function requiredQuantity(
item: { kind: ShoppingItemKind; quantity: number | null },
inTypeUnit: number | null,
): number {
if (item.kind === "product") {
if (!inTypeUnit) return 0;
const packages = item.quantity ?? QUANTITY_SCALE;
return Math.round((packages * inTypeUnit) / QUANTITY_SCALE);
}
return item.quantity ?? 0;
}
/**
* Reddedilen eşik önerisi, reddedildikten sonra alım hareketi girilene (stok yenilenene) kadar
* gizli kalır; sonra ürün yine eşiğin altına düşerse öneri yeniden çıkar.
*/
export function suggestionHidden(dismissedAt: Date | null, lastPurchaseAt: Date | null): boolean {
if (!dismissedAt) return false;
return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime();
}