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
+10
View File
@@ -0,0 +1,10 @@
import { ShoppingListModel } from "@/models/shopping-list";
import type { Migration } from "./types";
export const migration: Migration = {
name: "015-shopping-lists",
description: "alışveriş listeleri (shoppingLists) index'leri",
async up() {
await ShoppingListModel.createIndexes();
},
};
+2
View File
@@ -14,6 +14,7 @@ import { migration as m011 } from "./011-stock";
import { migration as m012 } from "./012-stock-counts";
import { migration as m013 } from "./013-stores-products";
import { migration as m014 } from "./014-product-prices";
import { migration as m015 } from "./015-shopping-lists";
import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
@@ -32,6 +33,7 @@ export const MIGRATIONS: Migration[] = [
m012,
m013,
m014,
m015,
];
const COLLECTION = "migrations";
+1
View File
@@ -61,6 +61,7 @@ describe("seed", () => {
stores: 0,
products: 0,
prices: 0,
shoppingLists: 0,
});
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
+7 -1
View File
@@ -13,7 +13,7 @@ import { runMigrations } from "../migrations";
import { seedContacts } from "./contacts";
import { seedAssets } from "./assets";
import { seedBudgets } from "./budgets";
import { seedProductPrices, seedShopping } from "./shopping";
import { seedProductPrices, seedShopping, seedShoppingLists } from "./shopping";
import { seedStock } from "./stock";
import { seedInKind } from "./in-kind";
import { seedInstallments } from "./installments";
@@ -61,6 +61,7 @@ export type SeedSummary = {
stores: number;
products: number;
prices: number;
shoppingLists: number;
};
accounts: SeedAccountRow[];
};
@@ -91,6 +92,7 @@ const DOMAIN_COLLECTIONS = [
"stores",
"products",
"productPrices",
"shoppingLists",
];
type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -150,6 +152,7 @@ export async function runSeed({
stores: 0,
products: 0,
prices: 0,
shoppingLists: 0,
},
accounts: [],
};
@@ -341,6 +344,9 @@ export async function runSeed({
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
summary.created.shoppingLists = await seedShoppingLists(householdIds, userIdMap);
if (summary.created.shoppingLists)
log(`✓ Örnek alışveriş listeleri: ${summary.created.shoppingLists}`);
// 7) Geliştirme hesapları tablosu
const all = [
+86
View File
@@ -2,6 +2,7 @@ import { Types } from "mongoose";
import { ProductModel } from "@/models/product";
import { ProductPriceModel } from "@/models/product-price";
import { ProductTypeModel } from "@/models/product-type";
import { ShoppingListModel } from "@/models/shopping-list";
import { StoreModel } from "@/models/store";
import type { MealCardProvider } from "@/shared/accounts";
import { autoConvertible } from "@/shared/products";
@@ -492,3 +493,88 @@ export async function seedProductPrices(
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
return docs.length;
}
type SeedListItem =
| { type: string; qty?: number }
| { product: string; qty?: number }
| { text: string; qty?: number; unit?: string; note?: string };
/**
* Örnek alışveriş listeleri (Faz 13). Eşiğin altındaki ürünler (mercimek, tablet deterjan, diş
* macunu) listelere eklenmez; liste sayfasında öneri olarak görünür. Domatesin fiyatı yok:
* akıllı öneride "fiyatı bilinmeyen" örneği.
*/
const SHOPPING_LISTS: { ref: string; name: string; note?: string; items: SeedListItem[] }[] = [
{
ref: "haftalik",
name: "Haftalık market",
items: [
{ type: "sut", qty: 4 * K },
{ type: "yumurta", qty: 30 * K },
{ type: "pirinc", qty: 2_500 },
{ type: "beyaz-peynir", qty: 500 },
{ type: "domates", qty: 2 * K },
{ type: "toz-deterjan", qty: 40 * K },
{ type: "tuvalet-kagidi", qty: 32 * K },
{ product: "yumos-1440", qty: 1 * K },
{ text: "Doğum günü mumu", qty: 1 * K, unit: "paket", note: "Rakam mum: 7" },
],
},
{
ref: "ay-sonu",
name: "Ay sonu toptan",
note: "Metro'ya arabayla.",
items: [{ type: "camasir-suyu", qty: 4 * K }],
},
];
export async function seedShoppingLists(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<number> {
const actor = new Types.ObjectId(users.get("ayse")!);
const householdId = householdIds.get("Çakır Ailesi")!;
const [types, products] = await Promise.all([
ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
ProductModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
]);
const typeByRef = new Map(types.map((x) => [x.seedRef, x._id]));
const productByRef = new Map(products.map((x) => [x.seedRef, x._id]));
let created = 0;
for (const l of SHOPPING_LISTS) {
if (await ShoppingListModel.exists({ householdId, seedRef: l.ref })) continue;
const items = l.items.flatMap((i): Record<string, unknown>[] => {
const common = {
_id: new Types.ObjectId(),
quantity: i.qty ?? null,
source: "manual",
addedBy: actor,
addedAt: new Date(),
};
if ("type" in i) {
const id = typeByRef.get(i.type);
return id ? [{ ...common, kind: "type", productTypeId: id }] : [];
}
if ("product" in i) {
const id = productByRef.get(i.product);
return id ? [{ ...common, kind: "product", productId: id }] : [];
}
return [{ ...common, kind: "text", text: i.text, unit: i.unit ?? "", note: i.note ?? "" }];
});
await ShoppingListModel.create({
householdId,
name: l.name,
note: l.note ?? "",
items,
seedRef: l.ref,
createdBy: actor,
updatedBy: actor,
});
created++;
}
return created;
}
+16 -1
View File
@@ -33,6 +33,8 @@ type SeedProduct = {
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
stopRefillDaysAgo?: number;
/** Ağır/hacimli (akıllı öneride taşıma cezası). */
bulky?: boolean;
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
countDrift?: { everyCounts: number; pct: number };
};
@@ -184,6 +186,7 @@ export const PRODUCTS: SeedProduct[] = [
},
{
ref: "toz-deterjan",
bulky: true,
category: "genel-temizlik.camasir",
name: "Toz Deterjan",
unit: "yıkama",
@@ -195,6 +198,7 @@ export const PRODUCTS: SeedProduct[] = [
},
{
ref: "yumusatici",
bulky: true,
category: "genel-temizlik.camasir",
name: "Yumuşatıcı",
unit: "lt",
@@ -206,6 +210,7 @@ export const PRODUCTS: SeedProduct[] = [
},
{
ref: "camasir-suyu",
bulky: true,
category: "genel-temizlik.genel",
name: "Çamaşır Suyu",
unit: "lt",
@@ -217,6 +222,7 @@ export const PRODUCTS: SeedProduct[] = [
},
{
ref: "tuvalet-kagidi",
bulky: true,
category: "genel-temizlik.genel",
name: "Tuvalet Kağıdı",
unit: "rulo",
@@ -359,7 +365,15 @@ export async function seedStock(
const today = todayIstanbul();
let created = 0;
for (const p of PRODUCTS) {
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue;
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) {
// Faz 13 öncesi seed'lenmiş veritabanında hacimli işaretini tamamla.
if (p.bulky)
await ProductTypeModel.updateOne(
{ householdId, seedRef: p.ref, bulky: { $exists: false } },
{ $set: { bulky: true } },
);
continue;
}
const doc = await ProductTypeModel.create({
householdId,
categoryId: catByKey.get(p.category)!,
@@ -370,6 +384,7 @@ export async function seedStock(
thresholdPct: p.threshold ?? 20,
location: p.location,
step: p.step ?? K,
bulky: p.bulky ?? false,
seedRef: p.ref,
createdBy: actor,
updatedBy: actor,