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
+62 -14
View File
@@ -293,17 +293,35 @@ export async function getTypePriceComparison(
};
}
/** Ürün tipi birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */
export type TypeOffer = {
productId: string;
productTypeId: string;
storeId: string;
priceMinor: number;
inTypeUnit: number;
unitPriceMinor: number;
date: string;
stale: boolean;
isPromo: boolean;
};
/**
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
* Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik
* ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak.
*/
export async function latestTypeUnitPrices(
export async function loadTypeOffers(
householdId: Types.ObjectId,
productTypeIds: Types.ObjectId[],
today = todayIstanbul(),
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
const [products, types] = await Promise.all([
ProductModel.find({ householdId, productTypeId: { $in: productTypeIds } })
{ today = todayIstanbul(), activeOnly = false } = {},
): Promise<TypeOffer[]> {
if (!productTypeIds.length) return [];
const [products, types, archivedStores] = await Promise.all([
ProductModel.find({
householdId,
productTypeId: { $in: productTypeIds },
...(activeOnly ? { archivedAt: null } : {}),
})
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
.lean<
{
@@ -317,7 +335,13 @@ export async function latestTypeUnitPrices(
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
.select({ unit: 1 })
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
activeOnly
? StoreModel.find({ householdId, archivedAt: { $ne: null } })
.select({ _id: 1 })
.lean<{ _id: Types.ObjectId }[]>()
: Promise.resolve([]),
]);
const skipStore = new Set(archivedStores.map((x) => String(x._id)));
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
const prices = await loadPrices(
householdId,
@@ -325,11 +349,12 @@ export async function latestTypeUnitPrices(
);
const groups = new Map<string, PriceLean[]>();
for (const r of prices) {
if (skipStore.has(String(r.storeId))) continue;
const key = `${r.productId}:${r.storeId}`;
groups.set(key, [...(groups.get(key) ?? []), r]);
}
const productById = new Map(products.map((p) => [String(p._id), p]));
const out = new Map<string, { unitPriceMinor: number; date: string }>();
const out: TypeOffer[] = [];
for (const records of groups.values()) {
const p = productById.get(String(records[0]!.productId))!;
const typeId = String(p.productTypeId);
@@ -341,12 +366,35 @@ export async function latestTypeUnitPrices(
});
if (!inType) continue;
const eff = effectivePrice(records, today)!;
const prev = out.get(typeId);
if (!prev || eff.record.date > prev.date)
out.set(typeId, {
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
date: eff.record.date,
});
out.push({
productId: String(p._id),
productTypeId: typeId,
storeId: String(records[0]!.storeId),
priceMinor: eff.record.priceMinor,
inTypeUnit: inType,
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
date: eff.record.date,
stale: eff.stale,
isPromo: eff.record.isPromo && !eff.promoEnded,
});
}
return out;
}
/**
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
*/
export async function latestTypeUnitPrices(
householdId: Types.ObjectId,
productTypeIds: Types.ObjectId[],
today = todayIstanbul(),
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
const out = new Map<string, { unitPriceMinor: number; date: string }>();
for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) {
const prev = out.get(o.productTypeId);
if (!prev || o.date > prev.date)
out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.date });
}
return out;
}