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:
@@ -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 });
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user