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