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 m012 } from "./012-stock-counts";
|
||||||
import { migration as m013 } from "./013-stores-products";
|
import { migration as m013 } from "./013-stores-products";
|
||||||
import { migration as m014 } from "./014-product-prices";
|
import { migration as m014 } from "./014-product-prices";
|
||||||
|
import { migration as m015 } from "./015-shopping-lists";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
@@ -32,6 +33,7 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m012,
|
m012,
|
||||||
m013,
|
m013,
|
||||||
m014,
|
m014,
|
||||||
|
m015,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe("seed", () => {
|
|||||||
stores: 0,
|
stores: 0,
|
||||||
products: 0,
|
products: 0,
|
||||||
prices: 0,
|
prices: 0,
|
||||||
|
shoppingLists: 0,
|
||||||
});
|
});
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||||
|
|||||||
+7
-1
@@ -13,7 +13,7 @@ import { runMigrations } from "../migrations";
|
|||||||
import { seedContacts } from "./contacts";
|
import { seedContacts } from "./contacts";
|
||||||
import { seedAssets } from "./assets";
|
import { seedAssets } from "./assets";
|
||||||
import { seedBudgets } from "./budgets";
|
import { seedBudgets } from "./budgets";
|
||||||
import { seedProductPrices, seedShopping } from "./shopping";
|
import { seedProductPrices, seedShopping, seedShoppingLists } from "./shopping";
|
||||||
import { seedStock } from "./stock";
|
import { seedStock } from "./stock";
|
||||||
import { seedInKind } from "./in-kind";
|
import { seedInKind } from "./in-kind";
|
||||||
import { seedInstallments } from "./installments";
|
import { seedInstallments } from "./installments";
|
||||||
@@ -61,6 +61,7 @@ export type SeedSummary = {
|
|||||||
stores: number;
|
stores: number;
|
||||||
products: number;
|
products: number;
|
||||||
prices: number;
|
prices: number;
|
||||||
|
shoppingLists: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -91,6 +92,7 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"stores",
|
"stores",
|
||||||
"products",
|
"products",
|
||||||
"productPrices",
|
"productPrices",
|
||||||
|
"shoppingLists",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -150,6 +152,7 @@ export async function runSeed({
|
|||||||
stores: 0,
|
stores: 0,
|
||||||
products: 0,
|
products: 0,
|
||||||
prices: 0,
|
prices: 0,
|
||||||
|
shoppingLists: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -341,6 +344,9 @@ export async function runSeed({
|
|||||||
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
||||||
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
|
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
|
||||||
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
|
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
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Types } from "mongoose";
|
|||||||
import { ProductModel } from "@/models/product";
|
import { ProductModel } from "@/models/product";
|
||||||
import { ProductPriceModel } from "@/models/product-price";
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
import { ProductTypeModel } from "@/models/product-type";
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
import { StoreModel } from "@/models/store";
|
import { StoreModel } from "@/models/store";
|
||||||
import type { MealCardProvider } from "@/shared/accounts";
|
import type { MealCardProvider } from "@/shared/accounts";
|
||||||
import { autoConvertible } from "@/shared/products";
|
import { autoConvertible } from "@/shared/products";
|
||||||
@@ -492,3 +493,88 @@ export async function seedProductPrices(
|
|||||||
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
|
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
|
||||||
return docs.length;
|
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" };
|
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. */
|
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
||||||
stopRefillDaysAgo?: number;
|
stopRefillDaysAgo?: number;
|
||||||
|
/** Ağır/hacimli (akıllı öneride taşıma cezası). */
|
||||||
|
bulky?: boolean;
|
||||||
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||||||
countDrift?: { everyCounts: number; pct: number };
|
countDrift?: { everyCounts: number; pct: number };
|
||||||
};
|
};
|
||||||
@@ -184,6 +186,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "toz-deterjan",
|
ref: "toz-deterjan",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.camasir",
|
category: "genel-temizlik.camasir",
|
||||||
name: "Toz Deterjan",
|
name: "Toz Deterjan",
|
||||||
unit: "yıkama",
|
unit: "yıkama",
|
||||||
@@ -195,6 +198,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "yumusatici",
|
ref: "yumusatici",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.camasir",
|
category: "genel-temizlik.camasir",
|
||||||
name: "Yumuşatıcı",
|
name: "Yumuşatıcı",
|
||||||
unit: "lt",
|
unit: "lt",
|
||||||
@@ -206,6 +210,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "camasir-suyu",
|
ref: "camasir-suyu",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.genel",
|
category: "genel-temizlik.genel",
|
||||||
name: "Çamaşır Suyu",
|
name: "Çamaşır Suyu",
|
||||||
unit: "lt",
|
unit: "lt",
|
||||||
@@ -217,6 +222,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "tuvalet-kagidi",
|
ref: "tuvalet-kagidi",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.genel",
|
category: "genel-temizlik.genel",
|
||||||
name: "Tuvalet Kağıdı",
|
name: "Tuvalet Kağıdı",
|
||||||
unit: "rulo",
|
unit: "rulo",
|
||||||
@@ -359,7 +365,15 @@ export async function seedStock(
|
|||||||
const today = todayIstanbul();
|
const today = todayIstanbul();
|
||||||
let created = 0;
|
let created = 0;
|
||||||
for (const p of PRODUCTS) {
|
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({
|
const doc = await ProductTypeModel.create({
|
||||||
householdId,
|
householdId,
|
||||||
categoryId: catByKey.get(p.category)!,
|
categoryId: catByKey.get(p.category)!,
|
||||||
@@ -370,6 +384,7 @@ export async function seedStock(
|
|||||||
thresholdPct: p.threshold ?? 20,
|
thresholdPct: p.threshold ?? 20,
|
||||||
location: p.location,
|
location: p.location,
|
||||||
step: p.step ?? K,
|
step: p.step ?? K,
|
||||||
|
bulky: p.bulky ?? false,
|
||||||
seedRef: p.ref,
|
seedRef: p.ref,
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
|
|||||||
@@ -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).
|
* Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik
|
||||||
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
* ü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,
|
householdId: Types.ObjectId,
|
||||||
productTypeIds: Types.ObjectId[],
|
productTypeIds: Types.ObjectId[],
|
||||||
today = todayIstanbul(),
|
{ today = todayIstanbul(), activeOnly = false } = {},
|
||||||
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
): Promise<TypeOffer[]> {
|
||||||
const [products, types] = await Promise.all([
|
if (!productTypeIds.length) return [];
|
||||||
ProductModel.find({ householdId, productTypeId: { $in: productTypeIds } })
|
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 })
|
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||||
.lean<
|
.lean<
|
||||||
{
|
{
|
||||||
@@ -317,7 +335,13 @@ export async function latestTypeUnitPrices(
|
|||||||
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
||||||
.select({ unit: 1 })
|
.select({ unit: 1 })
|
||||||
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
|
.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 unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
|
||||||
const prices = await loadPrices(
|
const prices = await loadPrices(
|
||||||
householdId,
|
householdId,
|
||||||
@@ -325,11 +349,12 @@ export async function latestTypeUnitPrices(
|
|||||||
);
|
);
|
||||||
const groups = new Map<string, PriceLean[]>();
|
const groups = new Map<string, PriceLean[]>();
|
||||||
for (const r of prices) {
|
for (const r of prices) {
|
||||||
|
if (skipStore.has(String(r.storeId))) continue;
|
||||||
const key = `${r.productId}:${r.storeId}`;
|
const key = `${r.productId}:${r.storeId}`;
|
||||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||||
}
|
}
|
||||||
const productById = new Map(products.map((p) => [String(p._id), p]));
|
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()) {
|
for (const records of groups.values()) {
|
||||||
const p = productById.get(String(records[0]!.productId))!;
|
const p = productById.get(String(records[0]!.productId))!;
|
||||||
const typeId = String(p.productTypeId);
|
const typeId = String(p.productTypeId);
|
||||||
@@ -341,12 +366,35 @@ export async function latestTypeUnitPrices(
|
|||||||
});
|
});
|
||||||
if (!inType) continue;
|
if (!inType) continue;
|
||||||
const eff = effectivePrice(records, today)!;
|
const eff = effectivePrice(records, today)!;
|
||||||
const prev = out.get(typeId);
|
out.push({
|
||||||
if (!prev || eff.record.date > prev.date)
|
productId: String(p._id),
|
||||||
out.set(typeId, {
|
productTypeId: typeId,
|
||||||
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
storeId: String(records[0]!.storeId),
|
||||||
date: eff.record.date,
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import {
|
||||||
|
shoppingItemFormSchema,
|
||||||
|
shoppingItemUpdateSchema,
|
||||||
|
shoppingListFormSchema,
|
||||||
|
} from "@/shared/schemas/shopping";
|
||||||
|
import {
|
||||||
|
addShoppingItem,
|
||||||
|
approveSuggestions,
|
||||||
|
createShoppingList,
|
||||||
|
deleteShoppingList,
|
||||||
|
dismissSuggestion,
|
||||||
|
moveShoppingItem,
|
||||||
|
removeShoppingItem,
|
||||||
|
setShoppingItemChecked,
|
||||||
|
updateShoppingItem,
|
||||||
|
updateShoppingList,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
|
||||||
|
function fieldErrors(error: z.ZodError) {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T, R>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||||
|
): Promise<ActionResult<R>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (...keys: string[]) => z.object(Object.fromEntries(keys.map((k) => [k, idSchema])));
|
||||||
|
|
||||||
|
export async function saveShoppingListAction(listId: string | null, input: unknown) {
|
||||||
|
if (listId === null)
|
||||||
|
return run(shoppingListFormSchema, input, (u, h, d) => createShoppingList(u, h, d));
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingListFormSchema, input, (u, h, d) => updateShoppingList(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShoppingListAction(listId: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteShoppingList(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addShoppingItemAction(listId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingItemFormSchema, input, (u, h, d) => addShoppingItem(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingItemAction(listId: unknown, itemId: unknown, input: unknown) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingItemUpdateSchema, input, (u, h, d) =>
|
||||||
|
updateShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, d),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShoppingItemAction(listId: unknown, itemId: unknown) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
removeShoppingItem(u, h, ref.data.listId!, ref.data.itemId!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShoppingItemCheckedAction(
|
||||||
|
listId: unknown,
|
||||||
|
itemId: unknown,
|
||||||
|
checked: unknown,
|
||||||
|
) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success || typeof checked !== "boolean") return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
setShoppingItemChecked(u, h, ref.data.listId!, ref.data.itemId!, checked),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveShoppingItemAction(listId: unknown, itemId: unknown, target: unknown) {
|
||||||
|
const ref = ids("listId", "itemId", "target").safeParse({ listId, itemId, target });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
moveShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, ref.data.target!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveSuggestionsAction(listId: unknown, productTypeIds: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
const types = z.array(idSchema).min(1).max(200).safeParse(productTypeIds);
|
||||||
|
if (!id.success || !types.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => approveSuggestions(u, h, id.data, types.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestionAction(productTypeId: unknown) {
|
||||||
|
const id = idSchema.safeParse(productTypeId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { shoppingItemFormSchema, shoppingItemUpdateSchema } from "@/shared/schemas/shopping";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
addShoppingItem,
|
||||||
|
approveSuggestions,
|
||||||
|
createShoppingList,
|
||||||
|
deleteShoppingList,
|
||||||
|
dismissSuggestion,
|
||||||
|
getShoppingList,
|
||||||
|
listShoppingLists,
|
||||||
|
moveShoppingItem,
|
||||||
|
removeShoppingItem,
|
||||||
|
setShoppingItemChecked,
|
||||||
|
updateShoppingItem,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
let weekly = "";
|
||||||
|
let monthEnd = "";
|
||||||
|
|
||||||
|
const typeId = (seedRef: string) =>
|
||||||
|
ProductTypeModel.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const productId = (seedRef: string) =>
|
||||||
|
ProductModel.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const item = (input: Record<string, unknown>) => shoppingItemFormSchema.parse(input);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||||
|
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("liste okuma ve akıllı öneri", () => {
|
||||||
|
it("aktif listeler, bekleyen öneri sayısı; başka hane göremez", async () => {
|
||||||
|
const data = (await listShoppingLists(ayse, cakir))!;
|
||||||
|
expect(data.active.map((l) => l.name).sort()).toEqual(["Ay sonu toptan", "Haftalık market"]);
|
||||||
|
expect(data.suggestions).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(data.canWrite).toBe(true);
|
||||||
|
expect(await listShoppingLists(fatma, cakir)).toBeNull();
|
||||||
|
expect(await getShoppingList(fatma, cakir, weekly)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kalem görünümleri, eşik önerileri ve plan", async () => {
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.items).toHaveLength(9);
|
||||||
|
const milk = d.items.find((i) => i.label === "Süt")!;
|
||||||
|
expect(milk).toMatchObject({ kind: "type", unit: "lt", quantityLabel: "4 lt" });
|
||||||
|
expect(milk.stock).not.toBeNull();
|
||||||
|
const yumos = d.items.find((i) => i.kind === "product")!;
|
||||||
|
expect(yumos.quantityLabel).toBe("1 paket");
|
||||||
|
const candle = d.items.find((i) => i.kind === "text")!;
|
||||||
|
expect(candle).toMatchObject({ label: "Doğum günü mumu", note: "Rakam mum: 7" });
|
||||||
|
|
||||||
|
// Eşiğin altındaki mercimek listede yok: öneri olarak gelir.
|
||||||
|
expect(d.suggestions.map((s) => s.name)).toContain("Kırmızı Mercimek");
|
||||||
|
expect(d.suggestions.every((s) => s.suggestedQuantity >= 0)).toBe(true);
|
||||||
|
|
||||||
|
// Plan: domatesin fiyatı yok; serbest metin plana katılmaz; diğerleri karşılanır.
|
||||||
|
const tomato = d.items.find((i) => i.label === "Domates")!;
|
||||||
|
expect(d.plan.unpriced).toEqual([tomato.id]);
|
||||||
|
const rec = d.plan.recommended!;
|
||||||
|
expect(rec.missing).toEqual([]);
|
||||||
|
const planned = rec.stores.flatMap((g) => g.lines.map((l) => l.itemId));
|
||||||
|
expect(planned).toHaveLength(7);
|
||||||
|
expect(planned).not.toContain(candle.id);
|
||||||
|
for (const g of rec.stores) {
|
||||||
|
expect(d.plan.stores[g.storeId]).toBeDefined();
|
||||||
|
for (const l of g.lines) expect(d.plan.products[l.productId]).toBeDefined();
|
||||||
|
}
|
||||||
|
// Belirli marka kalemi yalnızca o ürünle planlanır.
|
||||||
|
const yumosLine = rec.stores.flatMap((g) => g.lines).find((l) => l.itemId === yumos.id)!;
|
||||||
|
expect(yumosLine.productId).toBe(yumos.productId);
|
||||||
|
expect(d.plan.single).not.toBeNull();
|
||||||
|
expect(d.plan.thresholdMinor).toBe(10_000);
|
||||||
|
// Ayşe'nin yemek kartı bakiyesi plana girer (seed: Multinet).
|
||||||
|
expect(d.plan.mealCardBalanceMinor).toBeGreaterThan(0);
|
||||||
|
expect(d.options.types.length).toBeGreaterThan(10);
|
||||||
|
expect(d.otherLists.map((o) => o.name)).toEqual(["Ay sonu toptan"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kalem işlemleri", () => {
|
||||||
|
it("ekle (çift kayıt reddi, izleyici yazamaz), düzenle, işaretle, taşı, çıkar", async () => {
|
||||||
|
const milk = await typeId("sut");
|
||||||
|
expect(shoppingItemFormSchema.safeParse({ kind: "type", productTypeId: "" }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(shoppingItemFormSchema.safeParse({ kind: "text", text: "" }).success).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await addShoppingItem(ayse, cakir, weekly, item({ kind: "type", productTypeId: milk }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const text = item({ kind: "text", text: "Pil", quantity: "4", unit: "adet" });
|
||||||
|
expect((await addShoppingItem(zeynep, cakir, weekly, text)).ok).toBe(false);
|
||||||
|
|
||||||
|
const added = await addShoppingItem(ayse, cakir, weekly, text);
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
const id = added.ok ? added.data.id : "";
|
||||||
|
const brand = await addShoppingItem(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
weekly,
|
||||||
|
item({ kind: "product", productId: await productId("ariel-5kg"), quantity: "2" }),
|
||||||
|
);
|
||||||
|
expect(brand.ok).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await updateShoppingItem(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
weekly,
|
||||||
|
id,
|
||||||
|
shoppingItemUpdateSchema.parse({ quantity: "6", text: "AA pil", unit: "adet" }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
let d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.items.find((i) => i.id === id)).toMatchObject({
|
||||||
|
label: "AA pil",
|
||||||
|
quantityLabel: "6 adet",
|
||||||
|
});
|
||||||
|
const ariel = d.items.find((i) => i.label.startsWith("Ariel"))!;
|
||||||
|
expect(ariel.quantityLabel).toBe("2 paket");
|
||||||
|
|
||||||
|
expect((await setShoppingItemChecked(ayse, cakir, weekly, id, true)).ok).toBe(true);
|
||||||
|
d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
// Sepete alınanlar sona.
|
||||||
|
expect(d.items.at(-1)).toMatchObject({ id, checked: true });
|
||||||
|
expect(d.list.checkedCount).toBe(1);
|
||||||
|
|
||||||
|
expect((await moveShoppingItem(ayse, cakir, weekly, id, weekly)).ok).toBe(false);
|
||||||
|
expect((await moveShoppingItem(ayse, cakir, weekly, id, monthEnd)).ok).toBe(true);
|
||||||
|
const target = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||||
|
expect(target.items.find((i) => i.id === id)).toMatchObject({ checked: false });
|
||||||
|
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(true);
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(false);
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, weekly, ariel.id)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("eşik önerileri", () => {
|
||||||
|
it("onayla: önerilen miktarla eklenir, öneriden düşer; tekrar onay reddedilir", async () => {
|
||||||
|
const lentil = await typeId("kirmizi-mercimek");
|
||||||
|
const r = await approveSuggestions(ayse, cakir, weekly, [lentil]);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { added: 1 } });
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
const row = d.items.find((i) => i.productTypeId === lentil)!;
|
||||||
|
expect(row.source).toBe("suggestion");
|
||||||
|
expect(row.quantity).toBeGreaterThan(0);
|
||||||
|
expect(d.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||||
|
expect((await approveSuggestions(ayse, cakir, weekly, [lentil])).ok).toBe(false);
|
||||||
|
// Başka aktif listede de öneri olarak görünmez.
|
||||||
|
const other = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||||
|
expect(other.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||||
|
await removeShoppingItem(ayse, cakir, weekly, row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reddet: stok yenilenene kadar gizli; izleyici reddedemez", async () => {
|
||||||
|
const tablet = await typeId("tablet-deterjan");
|
||||||
|
expect((await dismissSuggestion(zeynep, cakir, tablet)).ok).toBe(false);
|
||||||
|
expect((await dismissSuggestion(ayse, cakir, tablet)).ok).toBe(true);
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.suggestions.some((s) => s.productTypeId === tablet)).toBe(false);
|
||||||
|
await ProductTypeModel.updateOne(
|
||||||
|
{ seedRef: "tablet-deterjan" },
|
||||||
|
{ $set: { suggestionDismissedAt: null } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liste yönetimi", () => {
|
||||||
|
it("oluştur ve sil (soft delete + denetim)", async () => {
|
||||||
|
const r = await createShoppingList(ayse, cakir, { name: "Deneme", note: "" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const id = r.ok ? r.data.id : "";
|
||||||
|
expect((await deleteShoppingList(zeynep, cakir, id)).ok).toBe(false);
|
||||||
|
expect((await deleteShoppingList(ayse, cakir, id)).ok).toBe(true);
|
||||||
|
expect(await getShoppingList(ayse, cakir, id)).toBeNull();
|
||||||
|
expect(
|
||||||
|
await AuditLogModel.exists({ action: "shoppingList.delete", targetId: id }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { loadTypeOffers } from "@/features/prices/service";
|
||||||
|
import { listProducts, type ProductView } from "@/features/products/service";
|
||||||
|
import { getStock, type ProductTypeView } from "@/features/stock/service";
|
||||||
|
import { storeLabel } from "@/features/stores/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { seriesSlot } from "@/shared/prices";
|
||||||
|
import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
|
import type {
|
||||||
|
ShoppingItemForm,
|
||||||
|
ShoppingItemUpdate,
|
||||||
|
ShoppingListForm,
|
||||||
|
} from "@/shared/schemas/shopping";
|
||||||
|
import {
|
||||||
|
requiredQuantity,
|
||||||
|
suggestionHidden,
|
||||||
|
type ShoppingItemKind,
|
||||||
|
type ShoppingItemSource,
|
||||||
|
type ShoppingListStatus,
|
||||||
|
} from "@/shared/shopping";
|
||||||
|
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
|
||||||
|
import { compareTr } from "@/shared/search";
|
||||||
|
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
|
||||||
|
import type { StoreKind } from "@/shared/stores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alışveriş listeleri (Bölüm 6.12). Hane içinde ortak; okuma viewer, yazma member.
|
||||||
|
* Stok eşiğinin altındaki ürün tipleri aktif listelere onayla/reddet önerisi olarak gelir.
|
||||||
|
* Akıllı mağaza önerisi saf motorla (shared/shopping-plan) okuma sırasında hesaplanır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
type ItemLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
kind: ShoppingItemKind;
|
||||||
|
productTypeId: Types.ObjectId | null;
|
||||||
|
productId: Types.ObjectId | null;
|
||||||
|
text: string;
|
||||||
|
quantity: number | null;
|
||||||
|
unit: string;
|
||||||
|
note: string;
|
||||||
|
source: ShoppingItemSource;
|
||||||
|
checked: boolean;
|
||||||
|
checkedAt: Date | null;
|
||||||
|
addedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
status: ShoppingListStatus;
|
||||||
|
note: string;
|
||||||
|
items: ItemLean[];
|
||||||
|
completedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingListSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: ShoppingListStatus;
|
||||||
|
note: string;
|
||||||
|
itemCount: number;
|
||||||
|
checkedCount: number;
|
||||||
|
/** İlk birkaç kalemin adı (kart önizlemesi). */
|
||||||
|
preview: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingItemView = {
|
||||||
|
id: string;
|
||||||
|
kind: ShoppingItemKind;
|
||||||
|
productTypeId: string | null;
|
||||||
|
productId: string | null;
|
||||||
|
text: string;
|
||||||
|
label: string;
|
||||||
|
/** Ürün: paket ve ürün tipi; ürün tipi: kategori yolu. */
|
||||||
|
detail: string;
|
||||||
|
quantity: number | null;
|
||||||
|
/** Ürün tipinde tipin birimi, üründe "paket", serbest metinde girilen birim. */
|
||||||
|
unit: string;
|
||||||
|
/** "2 kg", "3 paket"; boş miktarda ürün tipinde "" (en az 1 paket). */
|
||||||
|
quantityLabel: string;
|
||||||
|
note: string;
|
||||||
|
source: ShoppingItemSource;
|
||||||
|
checked: boolean;
|
||||||
|
/** Ürün tipi/ürün arşivlendi. */
|
||||||
|
archived: boolean;
|
||||||
|
/** Ürün tipi kaleminde evdeki stok. */
|
||||||
|
stock: { current: string; target: string; low: boolean } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuggestionView = {
|
||||||
|
productTypeId: string;
|
||||||
|
name: string;
|
||||||
|
categoryPath: string;
|
||||||
|
unit: string;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
pct: number | null;
|
||||||
|
suggestedQuantity: number;
|
||||||
|
suggestion: PurchaseSuggestion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanStoreRef = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
slot: number;
|
||||||
|
mealCard: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingPlanView = PlanResult & {
|
||||||
|
stores: Record<string, PlanStoreRef>;
|
||||||
|
products: Record<string, { label: string; packageLabel: string; typeUnit: string }>;
|
||||||
|
thresholdMinor: number;
|
||||||
|
/** Bu kullanıcının görebildiği yemek kartı bakiyelerinin toplamı. */
|
||||||
|
mealCardBalanceMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemOptions = {
|
||||||
|
types: { id: string; name: string; unit: string; path: string }[];
|
||||||
|
products: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
packageLabel: string;
|
||||||
|
productTypeId: string;
|
||||||
|
typeName: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingListDetail = {
|
||||||
|
list: ShoppingListSummary;
|
||||||
|
items: ShoppingItemView[];
|
||||||
|
suggestions: SuggestionView[];
|
||||||
|
plan: ShoppingPlanView;
|
||||||
|
options: ItemOptions;
|
||||||
|
/** Kalem taşımak için diğer aktif listeler. */
|
||||||
|
otherLists: { id: string; name: string }[];
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Yardımcılar ---
|
||||||
|
|
||||||
|
const iso = (d: Date | null) => (d ? d.toISOString() : null);
|
||||||
|
|
||||||
|
function summary(l: ListLean, labels: Map<string, string>): ShoppingListSummary {
|
||||||
|
return {
|
||||||
|
id: String(l._id),
|
||||||
|
name: l.name,
|
||||||
|
status: l.status,
|
||||||
|
note: l.note,
|
||||||
|
itemCount: l.items.length,
|
||||||
|
checkedCount: l.items.filter((i) => i.checked).length,
|
||||||
|
preview: l.items.slice(0, 4).map((i) => itemName(i, labels)),
|
||||||
|
updatedAt: l.updatedAt.toISOString(),
|
||||||
|
completedAt: iso(l.completedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const refKey = (i: Pick<ItemLean, "kind" | "productTypeId" | "productId">) =>
|
||||||
|
i.kind === "type" ? `t:${i.productTypeId}` : i.kind === "product" ? `p:${i.productId}` : null;
|
||||||
|
|
||||||
|
function itemName(i: ItemLean, labels: Map<string, string>) {
|
||||||
|
const key = refKey(i);
|
||||||
|
return key ? (labels.get(key) ?? "—") : i.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalemlerin ürün tipi ve ürün adları (arşivliler dahil). */
|
||||||
|
async function loadLabels(householdId: Types.ObjectId, lists: ListLean[]) {
|
||||||
|
const typeIds = new Set<string>();
|
||||||
|
const productIds = new Set<string>();
|
||||||
|
for (const l of lists)
|
||||||
|
for (const i of l.items) {
|
||||||
|
if (i.productTypeId) typeIds.add(String(i.productTypeId));
|
||||||
|
if (i.productId) productIds.add(String(i.productId));
|
||||||
|
}
|
||||||
|
const [types, products] = await Promise.all([
|
||||||
|
ProductTypeModel.find({
|
||||||
|
householdId,
|
||||||
|
_id: { $in: [...typeIds].map((x) => new Types.ObjectId(x)) },
|
||||||
|
})
|
||||||
|
.select({ name: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string }[]>(),
|
||||||
|
ProductModel.find({
|
||||||
|
householdId,
|
||||||
|
_id: { $in: [...productIds].map((x) => new Types.ObjectId(x)) },
|
||||||
|
})
|
||||||
|
.select({ brand: 1, name: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; brand: string; name: string }[]>(),
|
||||||
|
]);
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
for (const x of types) labels.set(`t:${x._id}`, x.name);
|
||||||
|
for (const x of products) labels.set(`p:${x._id}`, x.name ? `${x.brand} ${x.name}` : x.brand);
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList(m: Membership, listId: string) {
|
||||||
|
const id = toObjectId(listId);
|
||||||
|
if (!id) return null;
|
||||||
|
return ShoppingListModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<ListLean>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi başına son alım hareketinin giriş zamanı (öneri reddi için). */
|
||||||
|
async function lastPurchaseAt(householdId: Types.ObjectId, typeIds: Types.ObjectId[]) {
|
||||||
|
if (!typeIds.length) return new Map<string, Date>();
|
||||||
|
const rows = await StockMovementModel.aggregate<{ _id: Types.ObjectId; at: Date }>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
householdId,
|
||||||
|
productTypeId: { $in: typeIds },
|
||||||
|
kind: "purchase",
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $group: { _id: "$productTypeId", at: { $max: "$createdAt" } } },
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((r) => [String(r._id), r.at]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bekleyen eşik önerileri: eşiğin altındaki ürün tipleri; hiçbir aktif listede (alınmamış kalem
|
||||||
|
* olarak, tip ya da o tipin ürünü) yoksa ve reddedilmemişse.
|
||||||
|
*/
|
||||||
|
async function pendingSuggestions(
|
||||||
|
m: Membership,
|
||||||
|
low: ProductTypeView[],
|
||||||
|
): Promise<SuggestionView[]> {
|
||||||
|
if (!low.length) return [];
|
||||||
|
const lowIds = low.map((x) => new Types.ObjectId(x.id));
|
||||||
|
const [active, dismissed, purchases] = await Promise.all([
|
||||||
|
ShoppingListModel.find({ householdId: m.householdId, deletedAt: null, status: "active" })
|
||||||
|
.select({ items: 1 })
|
||||||
|
.lean<{ items: ItemLean[] }[]>(),
|
||||||
|
ProductTypeModel.find({ householdId: m.householdId, _id: { $in: lowIds } })
|
||||||
|
.select({ suggestionDismissedAt: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; suggestionDismissedAt: Date | null }[]>(),
|
||||||
|
lastPurchaseAt(m.householdId, lowIds),
|
||||||
|
]);
|
||||||
|
const listed = new Set<string>();
|
||||||
|
const productIds: Types.ObjectId[] = [];
|
||||||
|
for (const l of active)
|
||||||
|
for (const i of l.items) {
|
||||||
|
if (i.checked) continue;
|
||||||
|
if (i.productTypeId) listed.add(String(i.productTypeId));
|
||||||
|
if (i.productId) productIds.push(i.productId);
|
||||||
|
}
|
||||||
|
if (productIds.length) {
|
||||||
|
const rows = await ProductModel.find({ householdId: m.householdId, _id: { $in: productIds } })
|
||||||
|
.select({ productTypeId: 1 })
|
||||||
|
.lean<{ productTypeId: Types.ObjectId }[]>();
|
||||||
|
for (const r of rows) listed.add(String(r.productTypeId));
|
||||||
|
}
|
||||||
|
const dismissedAt = new Map(dismissed.map((d) => [String(d._id), d.suggestionDismissedAt]));
|
||||||
|
return low
|
||||||
|
.filter(
|
||||||
|
(x) =>
|
||||||
|
!listed.has(x.id) &&
|
||||||
|
!suggestionHidden(dismissedAt.get(x.id) ?? null, purchases.get(x.id) ?? null),
|
||||||
|
)
|
||||||
|
.map((x) => ({
|
||||||
|
productTypeId: x.id,
|
||||||
|
name: x.name,
|
||||||
|
categoryPath: x.categoryPath,
|
||||||
|
unit: x.unit,
|
||||||
|
current: x.current,
|
||||||
|
target: x.targetQuantity,
|
||||||
|
pct: x.level.pct,
|
||||||
|
suggestedQuantity: x.suggestedQuantity,
|
||||||
|
suggestion: x.suggestion,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eşik önerileri (dashboard ve stok sayfası özetleri için). */
|
||||||
|
export async function getPendingSuggestions(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<SuggestionView[] | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const stock = await getStock(userId, householdId);
|
||||||
|
return stock ? pendingSuggestions(m, stock.suggestions) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Okuma ---
|
||||||
|
|
||||||
|
export async function listShoppingLists(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<{
|
||||||
|
active: ShoppingListSummary[];
|
||||||
|
completed: ShoppingListSummary[];
|
||||||
|
suggestions: number;
|
||||||
|
canWrite: boolean;
|
||||||
|
} | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const [lists, suggestions] = await Promise.all([
|
||||||
|
ShoppingListModel.find({ householdId: m.householdId, deletedAt: null })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.lean<ListLean[]>(),
|
||||||
|
getPendingSuggestions(userId, householdId),
|
||||||
|
]);
|
||||||
|
const labels = await loadLabels(m.householdId, lists);
|
||||||
|
return {
|
||||||
|
active: lists.filter((l) => l.status === "active").map((l) => summary(l, labels)),
|
||||||
|
completed: lists
|
||||||
|
.filter((l) => l.status === "completed")
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((l) => summary(l, labels)),
|
||||||
|
suggestions: suggestions?.length ?? 0,
|
||||||
|
canWrite: hasHouseholdRole(m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard: aktif listelerin özeti. */
|
||||||
|
export async function getActiveListsSummary(userId: string, householdId: string) {
|
||||||
|
const data = await listShoppingLists(userId, householdId);
|
||||||
|
if (!data) return null;
|
||||||
|
return { lists: data.active, suggestions: data.suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemView(
|
||||||
|
i: ItemLean,
|
||||||
|
types: Map<string, ProductTypeView>,
|
||||||
|
products: Map<string, ProductView>,
|
||||||
|
archivedTypes: Map<string, { name: string; unit: string }>,
|
||||||
|
): ShoppingItemView {
|
||||||
|
const base = {
|
||||||
|
id: String(i._id),
|
||||||
|
kind: i.kind,
|
||||||
|
productTypeId: i.productTypeId ? String(i.productTypeId) : null,
|
||||||
|
productId: i.productId ? String(i.productId) : null,
|
||||||
|
text: i.text,
|
||||||
|
quantity: i.quantity,
|
||||||
|
note: i.note,
|
||||||
|
source: i.source,
|
||||||
|
checked: i.checked,
|
||||||
|
};
|
||||||
|
if (i.kind === "type") {
|
||||||
|
const ty = types.get(String(i.productTypeId));
|
||||||
|
const fallback = archivedTypes.get(String(i.productTypeId));
|
||||||
|
const unit = ty?.unit ?? fallback?.unit ?? "";
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: ty?.name ?? fallback?.name ?? "—",
|
||||||
|
detail: ty?.categoryPath ?? "",
|
||||||
|
unit,
|
||||||
|
quantityLabel: i.quantity === null ? "" : formatStock(i.quantity, unit),
|
||||||
|
archived: !ty,
|
||||||
|
stock: ty
|
||||||
|
? {
|
||||||
|
current: formatStock(Math.max(0, ty.current), ty.unit),
|
||||||
|
target: formatStock(ty.targetQuantity, ty.unit),
|
||||||
|
low: ty.level.low,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (i.kind === "product") {
|
||||||
|
const p = products.get(String(i.productId));
|
||||||
|
const packages = i.quantity ?? QUANTITY_SCALE;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: p?.label ?? "—",
|
||||||
|
detail: p ? `${p.packageLabel} · ${p.productTypeName}` : "",
|
||||||
|
unit: t.packageUnit,
|
||||||
|
quantityLabel: `${formatQuantity(packages)} ${t.packageUnit}`,
|
||||||
|
archived: !p || p.archived,
|
||||||
|
stock: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: i.text,
|
||||||
|
detail: "",
|
||||||
|
unit: i.unit,
|
||||||
|
quantityLabel: i.quantity === null ? "" : formatQuantity(i.quantity, i.unit || undefined),
|
||||||
|
archived: false,
|
||||||
|
stock: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPlan(
|
||||||
|
userId: string,
|
||||||
|
m: Membership,
|
||||||
|
items: ItemLean[],
|
||||||
|
products: Map<string, ProductView>,
|
||||||
|
types: Map<string, ProductTypeView>,
|
||||||
|
): Promise<ShoppingPlanView> {
|
||||||
|
const planItems = items
|
||||||
|
.filter((i) => i.kind !== "text")
|
||||||
|
.map((i) => {
|
||||||
|
const p = i.productId ? products.get(String(i.productId)) : undefined;
|
||||||
|
const typeId = i.kind === "product" ? (p?.productTypeId ?? "") : String(i.productTypeId);
|
||||||
|
return {
|
||||||
|
id: String(i._id),
|
||||||
|
productTypeId: typeId,
|
||||||
|
quantity: requiredQuantity(i, p?.inTypeUnit ?? null),
|
||||||
|
productId: i.kind === "product" ? String(i.productId) : null,
|
||||||
|
bulky: types.get(typeId)?.bulky ?? false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((i) => i.productTypeId);
|
||||||
|
const typeIds = [...new Set(planItems.map((i) => i.productTypeId))].map(
|
||||||
|
(x) => new Types.ObjectId(x),
|
||||||
|
);
|
||||||
|
const [offers, stores, household, accounts] = await Promise.all([
|
||||||
|
loadTypeOffers(m.householdId, typeIds, { activeOnly: true }),
|
||||||
|
StoreModel.find({ householdId: m.householdId }).sort({ createdAt: 1, _id: 1 }).lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
branch: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
distanceM: number | null;
|
||||||
|
carryDifficulty: number;
|
||||||
|
deliveryFeeMinor: number | null;
|
||||||
|
freeDeliveryMinMinor: number | null;
|
||||||
|
mealCardAccepted: boolean;
|
||||||
|
mealCardProviders: string[];
|
||||||
|
archivedAt: Date | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(),
|
||||||
|
listAccounts(userId, String(m.householdId)),
|
||||||
|
]);
|
||||||
|
const mealCards = (accounts?.accounts ?? [])
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.type === "meal_card" &&
|
||||||
|
!a.archived &&
|
||||||
|
a.mealCardProvider &&
|
||||||
|
a.balanceMinor !== null &&
|
||||||
|
a.balanceMinor > 0,
|
||||||
|
)
|
||||||
|
.map((a) => ({ provider: a.mealCardProvider!, balanceMinor: a.balanceMinor! }));
|
||||||
|
const active = stores.filter((s) => !s.archivedAt);
|
||||||
|
const threshold = household?.settings?.extraStoreSavingThresholdMinor ?? 10_000;
|
||||||
|
const result = planShopping({
|
||||||
|
items: planItems,
|
||||||
|
offers,
|
||||||
|
stores: active.map((s) => ({
|
||||||
|
id: String(s._id),
|
||||||
|
kind: s.kind,
|
||||||
|
distanceM: s.distanceM,
|
||||||
|
carryDifficulty: s.carryDifficulty,
|
||||||
|
deliveryFeeMinor: s.deliveryFeeMinor,
|
||||||
|
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
|
||||||
|
mealCardProviders: s.mealCardAccepted ? s.mealCardProviders : [],
|
||||||
|
})),
|
||||||
|
mealCards,
|
||||||
|
extraStoreThresholdMinor: threshold,
|
||||||
|
});
|
||||||
|
// Planda geçen ürünlerin etiketleri (listede olmayan muadil markalar dahil).
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const o of [result.single, result.recommended])
|
||||||
|
for (const g of o?.stores ?? []) for (const l of g.lines) used.add(l.productId);
|
||||||
|
for (const l of Object.values(result.cheapest)) used.add(l.productId);
|
||||||
|
const missing = [...used].filter((id) => !products.has(id));
|
||||||
|
if (missing.length) {
|
||||||
|
const extra = await listProducts(userId, String(m.householdId), { includeArchived: true });
|
||||||
|
for (const p of extra?.products ?? []) products.set(p.id, p);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
stores: Object.fromEntries(
|
||||||
|
stores.map((s, i) => [
|
||||||
|
String(s._id),
|
||||||
|
{
|
||||||
|
id: String(s._id),
|
||||||
|
label: storeLabel(s),
|
||||||
|
kind: s.kind,
|
||||||
|
slot: seriesSlot(i),
|
||||||
|
mealCard: s.mealCardAccepted,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
products: Object.fromEntries(
|
||||||
|
[...used].flatMap((id) => {
|
||||||
|
const p = products.get(id);
|
||||||
|
return p
|
||||||
|
? [[id, { label: p.label, packageLabel: p.packageLabel, typeUnit: p.typeUnit }]]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
thresholdMinor: threshold,
|
||||||
|
mealCardBalanceMinor: mealCards.reduce((a, c) => a + c.balanceMinor, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShoppingList(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<ShoppingListDetail | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return null;
|
||||||
|
const [stock, productList, others, labels] = await Promise.all([
|
||||||
|
getStock(userId, householdId),
|
||||||
|
listProducts(userId, householdId, { includeArchived: true }),
|
||||||
|
ShoppingListModel.find({
|
||||||
|
householdId: m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: "active",
|
||||||
|
_id: { $ne: list._id },
|
||||||
|
})
|
||||||
|
.select({ name: 1 })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string }[]>(),
|
||||||
|
loadLabels(m.householdId, [list]),
|
||||||
|
]);
|
||||||
|
if (!stock || !productList) return null;
|
||||||
|
const types = new Map(stock.items.map((x) => [x.id, x]));
|
||||||
|
const products = new Map(productList.products.map((p) => [p.id, p]));
|
||||||
|
// Arşivlenmiş ürün tiplerinin adı/birimi (getStock yalnızca aktifleri döner).
|
||||||
|
const archivedTypeIds = list.items
|
||||||
|
.filter((i) => i.productTypeId && !types.has(String(i.productTypeId)))
|
||||||
|
.map((i) => i.productTypeId!);
|
||||||
|
const archivedTypes = new Map(
|
||||||
|
(
|
||||||
|
await ProductTypeModel.find({ householdId: m.householdId, _id: { $in: archivedTypeIds } })
|
||||||
|
.select({ name: 1, unit: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>()
|
||||||
|
).map((x) => [String(x._id), { name: x.name, unit: x.unit }]),
|
||||||
|
);
|
||||||
|
const items = list.items
|
||||||
|
.map((i) => itemView(i, types, products, archivedTypes))
|
||||||
|
.sort((a, b) => Number(a.checked) - Number(b.checked));
|
||||||
|
const isActive = list.status === "active";
|
||||||
|
const canWrite = hasHouseholdRole(m.role, "member") && isActive;
|
||||||
|
const [suggestions, plan] = await Promise.all([
|
||||||
|
isActive ? pendingSuggestions(m, stock.suggestions) : Promise.resolve([]),
|
||||||
|
buildPlan(userId, m, list.items, products, types),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
list: summary(list, labels),
|
||||||
|
items,
|
||||||
|
suggestions,
|
||||||
|
plan,
|
||||||
|
options: {
|
||||||
|
types: stock.items
|
||||||
|
.map((x) => ({ id: x.id, name: x.name, unit: x.unit, path: x.categoryPath }))
|
||||||
|
.sort((a, b) => compareTr(a.name, b.name)),
|
||||||
|
products: productList.products
|
||||||
|
.filter((p) => !p.archived && !p.conversionMissing && types.has(p.productTypeId))
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
productTypeId: p.productTypeId,
|
||||||
|
typeName: p.productTypeName,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
|
||||||
|
canWrite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma: listeler ---
|
||||||
|
|
||||||
|
async function writableList(actorId: string, householdId: string, listId: string) {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return { error: fail(tr.common.notFound) } as const;
|
||||||
|
if (list.status !== "active") return { error: fail(t.validation.notActive) } as const;
|
||||||
|
return { error: null, m, list, actor: new Types.ObjectId(actorId) } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: ShoppingListForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await ShoppingListModel.create({
|
||||||
|
householdId: m.householdId,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
input: ShoppingListForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $set: { name: input.name, note: input.note, updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste silme (soft delete, audit). Tamamlanmış liste de silinebilir. */
|
||||||
|
export async function deleteShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return fail(tr.common.notFound);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: list._id },
|
||||||
|
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: "shoppingList.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "shoppingList",
|
||||||
|
targetId: listId,
|
||||||
|
diff: { name: list.name, status: list.status, items: list.items.length },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma: kalemler ---
|
||||||
|
|
||||||
|
/** Ürün tipi / ürün aktif mi; aynı kalem listede alınmamış olarak var mı? */
|
||||||
|
async function checkItemRef(
|
||||||
|
m: Membership,
|
||||||
|
list: ListLean,
|
||||||
|
input: Pick<ShoppingItemForm, "kind" | "productTypeId" | "productId">,
|
||||||
|
): Promise<ActionResult<never> | null> {
|
||||||
|
if (input.kind === "type") {
|
||||||
|
const ok = await ProductTypeModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.productTypeId!),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!ok) return fail(t.validation.productType, { productTypeId: t.validation.productType });
|
||||||
|
}
|
||||||
|
if (input.kind === "product") {
|
||||||
|
const ok = await ProductModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.productId!),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!ok) return fail(t.validation.product, { productId: t.validation.product });
|
||||||
|
}
|
||||||
|
const key = refKey({
|
||||||
|
kind: input.kind,
|
||||||
|
productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null,
|
||||||
|
productId: input.productId ? new Types.ObjectId(input.productId) : null,
|
||||||
|
});
|
||||||
|
if (key && list.items.some((i) => !i.checked && refKey(i) === key))
|
||||||
|
return fail(t.validation.duplicate);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
input: ShoppingItemForm,
|
||||||
|
source: ShoppingItemSource = "manual",
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const bad = await checkItemRef(r.m, r.list, input);
|
||||||
|
if (bad) return bad;
|
||||||
|
const _id = new Types.ObjectId();
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{
|
||||||
|
$push: {
|
||||||
|
items: {
|
||||||
|
_id,
|
||||||
|
kind: input.kind,
|
||||||
|
productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null,
|
||||||
|
productId: input.productId ? new Types.ObjectId(input.productId) : null,
|
||||||
|
text: input.text,
|
||||||
|
quantity: input.quantity,
|
||||||
|
unit: input.unit,
|
||||||
|
note: input.note,
|
||||||
|
source,
|
||||||
|
addedBy: r.actor,
|
||||||
|
addedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
$set: { updatedBy: r.actor },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ok({ id: String(_id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
input: ShoppingItemUpdate,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const item = r.list.items.find((i) => String(i._id) === itemId);
|
||||||
|
if (!item) return fail(tr.common.notFound);
|
||||||
|
if (item.kind === "text" && !input.text)
|
||||||
|
return fail(t.validation.text, { text: t.validation.text });
|
||||||
|
const set: Record<string, unknown> = {
|
||||||
|
"items.$.quantity": input.quantity,
|
||||||
|
"items.$.note": input.note,
|
||||||
|
updatedBy: r.actor,
|
||||||
|
};
|
||||||
|
if (item.kind === "text") {
|
||||||
|
set["items.$.text"] = input.text;
|
||||||
|
set["items.$.unit"] = input.unit;
|
||||||
|
}
|
||||||
|
await ShoppingListModel.updateOne({ _id: r.list._id, "items._id": item._id }, { $set: set });
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const id = toObjectId(itemId);
|
||||||
|
if (!id || !r.list.items.some((i) => i._id.equals(id))) return fail(tr.common.notFound);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $pull: { items: { _id: id } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alışveriş modunda sepete alındı / geri alındı. */
|
||||||
|
export async function setShoppingItemChecked(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
checked: boolean,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const id = toObjectId(itemId);
|
||||||
|
if (!id) return fail(tr.common.notFound);
|
||||||
|
const res = await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id, "items._id": id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
"items.$.checked": checked,
|
||||||
|
"items.$.checkedBy": checked ? r.actor : null,
|
||||||
|
"items.$.checkedAt": checked ? new Date() : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.matchedCount ? ok(undefined) : fail(tr.common.notFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalemi başka bir aktif listeye taşır. */
|
||||||
|
export async function moveShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
targetListId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const target = await writableList(actorId, householdId, targetListId);
|
||||||
|
if (target.error) return target.error;
|
||||||
|
if (target.list._id.equals(r.list._id)) return fail(t.validation.sameList);
|
||||||
|
const item = r.list.items.find((i) => String(i._id) === itemId);
|
||||||
|
if (!item) return fail(tr.common.notFound);
|
||||||
|
const key = refKey(item);
|
||||||
|
if (key && target.list.items.some((i) => !i.checked && refKey(i) === key))
|
||||||
|
return fail(t.validation.duplicateIn(target.list.name));
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: target.list._id },
|
||||||
|
{
|
||||||
|
$push: { items: { ...item, checked: false, checkedBy: null, checkedAt: null } },
|
||||||
|
$set: { updatedBy: r.actor },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $pull: { items: { _id: item._id } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Eşik önerileri ---
|
||||||
|
|
||||||
|
/** Önerileri onayla: önerilen miktarla ürün tipi kalemi olarak eklenir. */
|
||||||
|
export async function approveSuggestions(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
productTypeIds: string[],
|
||||||
|
): Promise<ActionResult<{ added: number }>> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const stock = await getStock(actorId, householdId);
|
||||||
|
if (!stock) return fail(tr.common.unauthorized);
|
||||||
|
const pending = new Map(
|
||||||
|
(await pendingSuggestions(r.m, stock.suggestions)).map((x) => [x.productTypeId, x]),
|
||||||
|
);
|
||||||
|
const items = productTypeIds
|
||||||
|
.map((id) => pending.get(id))
|
||||||
|
.filter((x): x is SuggestionView => Boolean(x))
|
||||||
|
.map((x) => ({
|
||||||
|
_id: new Types.ObjectId(),
|
||||||
|
kind: "type" as const,
|
||||||
|
productTypeId: new Types.ObjectId(x.productTypeId),
|
||||||
|
productId: null,
|
||||||
|
text: "",
|
||||||
|
quantity: x.suggestedQuantity > 0 ? x.suggestedQuantity : null,
|
||||||
|
unit: "",
|
||||||
|
note: "",
|
||||||
|
source: "suggestion" as const,
|
||||||
|
addedBy: r.actor,
|
||||||
|
addedAt: new Date(),
|
||||||
|
}));
|
||||||
|
if (!items.length) return fail(t.validation.suggestionGone);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $push: { items: { $each: items } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok({ added: items.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öneriyi reddet: stok yenilenene (yeni alım) kadar gösterilmez. */
|
||||||
|
export async function dismissSuggestion(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
productTypeId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(productTypeId);
|
||||||
|
const res = id
|
||||||
|
? await ProductTypeModel.updateOne(
|
||||||
|
{ _id: id, householdId: m.householdId, archivedAt: null },
|
||||||
|
{ $set: { suggestionDismissedAt: new Date() } },
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return res?.matchedCount ? ok(undefined) : fail(tr.common.notFound);
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ type ProductLean = {
|
|||||||
step: number;
|
step: number;
|
||||||
countEveryDays: number | null;
|
countEveryDays: number | null;
|
||||||
note: string;
|
note: string;
|
||||||
|
bulky?: boolean;
|
||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
@@ -132,6 +133,7 @@ export type ProductTypeView = {
|
|||||||
step: number;
|
step: number;
|
||||||
countEveryDays: number | null;
|
countEveryDays: number | null;
|
||||||
note: string;
|
note: string;
|
||||||
|
bulky: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
current: number;
|
current: number;
|
||||||
level: StockLevel;
|
level: StockLevel;
|
||||||
@@ -331,6 +333,7 @@ function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTyp
|
|||||||
step: p.step,
|
step: p.step,
|
||||||
countEveryDays: p.countEveryDays,
|
countEveryDays: p.countEveryDays,
|
||||||
note: p.note,
|
note: p.note,
|
||||||
|
bulky: Boolean(p.bulky),
|
||||||
archived: Boolean(p.archivedAt),
|
archived: Boolean(p.archivedAt),
|
||||||
current,
|
current,
|
||||||
level,
|
level,
|
||||||
@@ -807,6 +810,7 @@ const productFields = (input: ProductTypeForm) => ({
|
|||||||
step: input.step,
|
step: input.step,
|
||||||
countEveryDays: input.countEveryDays,
|
countEveryDays: input.countEveryDays,
|
||||||
note: input.note,
|
note: input.note,
|
||||||
|
bulky: input.bulky,
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createProductType(
|
export async function createProductType(
|
||||||
|
|||||||
+147
@@ -1012,6 +1012,7 @@ export const tr = {
|
|||||||
title: "Alınması önerilenler",
|
title: "Alınması önerilenler",
|
||||||
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
|
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
|
||||||
empty: "Eşiğin altına düşen ürün yok.",
|
empty: "Eşiğin altına düşen ürün yok.",
|
||||||
|
toShopping: "Alışveriş listelerinde onayla veya reddet",
|
||||||
suggested: (q: string) => `Öneri: ${q}`,
|
suggested: (q: string) => `Öneri: ${q}`,
|
||||||
wasteReduced: (purchases: number, pct: number) =>
|
wasteReduced: (purchases: number, pct: number) =>
|
||||||
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
||||||
@@ -1055,6 +1056,9 @@ export const tr = {
|
|||||||
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
|
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
|
||||||
countEveryDays: "Sayım sıklığı (gün)",
|
countEveryDays: "Sayım sıklığı (gün)",
|
||||||
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
|
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
|
||||||
|
bulky: "Ağır / hacimli",
|
||||||
|
bulkyHint:
|
||||||
|
"Su, deterjan gibi taşıması zor ürünler. Akıllı öneri bunları yakın ya da online mağazaya yönlendirir.",
|
||||||
note: "Not",
|
note: "Not",
|
||||||
initialQuantity: "Evdeki mevcut miktar",
|
initialQuantity: "Evdeki mevcut miktar",
|
||||||
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
|
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
|
||||||
@@ -1352,6 +1356,149 @@ export const tr = {
|
|||||||
archived: "Ürün arşivli.",
|
archived: "Ürün arşivli.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
shopping: {
|
||||||
|
title: "Alışveriş listeleri",
|
||||||
|
description:
|
||||||
|
"Aile üyeleri aynı listeleri kullanır. Stokta azalan ürünler öneri olarak gelir; akıllı öneri en uygun mağaza ve markayı seçer.",
|
||||||
|
readOnly: "Görüntüleme yetkiniz var; listeleri değiştiremezsiniz.",
|
||||||
|
newList: "Yeni liste",
|
||||||
|
createTitle: "Yeni alışveriş listesi",
|
||||||
|
editTitle: "Listeyi düzenle",
|
||||||
|
active: "Aktif listeler",
|
||||||
|
completed: "Tamamlanan alışverişler",
|
||||||
|
emptyActive: "Aktif liste yok. Yeni bir liste açın.",
|
||||||
|
itemCount: (n: number) => `${n} kalem`,
|
||||||
|
checkedCount: (done: number, total: number) => `${done}/${total} alındı`,
|
||||||
|
updated: (when: string) => `Güncellendi: ${when}`,
|
||||||
|
completedAt: (when: string) => `Tamamlandı: ${when}`,
|
||||||
|
emptyList: "Liste boş.",
|
||||||
|
suggestionsBanner: (n: number) =>
|
||||||
|
`Stokta eşiğin altına düşen ${n} ürün var. Bir listeyi açıp öneriyi onaylayın ya da reddedin.`,
|
||||||
|
fields: {
|
||||||
|
name: "Liste adı",
|
||||||
|
namePlaceholder: "ör. Haftalık market, Ay başı toptan",
|
||||||
|
note: "Not",
|
||||||
|
kind: "Kalem türü",
|
||||||
|
productType: "Ürün tipi",
|
||||||
|
product: "Ürün (marka/paket)",
|
||||||
|
text: "Kalem",
|
||||||
|
textPlaceholder: "ör. Doğum günü mumu",
|
||||||
|
quantity: "Miktar",
|
||||||
|
packages: "Paket adedi",
|
||||||
|
quantityHint: "Boş bırakılırsa en az bir paket.",
|
||||||
|
unit: "Birim",
|
||||||
|
unitPlaceholder: "ör. adet",
|
||||||
|
itemNote: "Not",
|
||||||
|
itemNotePlaceholder: "ör. laktozsuz olsun",
|
||||||
|
},
|
||||||
|
kinds: { type: "Ürün tipi", product: "Belirli marka", text: "Serbest metin" },
|
||||||
|
kindHints: {
|
||||||
|
type: "Markayı akıllı öneri seçer.",
|
||||||
|
product: "Yalnızca bu ürün önerilir.",
|
||||||
|
text: "Stokla ve fiyatlarla bağlanmaz.",
|
||||||
|
},
|
||||||
|
packageUnit: "paket",
|
||||||
|
stockLine: (current: string, target: string) => `Evde ${current} / hedef ${target}`,
|
||||||
|
fromSuggestion: "Stok önerisi",
|
||||||
|
archivedRef: "Arşivlendi",
|
||||||
|
actions: {
|
||||||
|
more: "Diğer işlemler",
|
||||||
|
addItem: "Kalem ekle",
|
||||||
|
edit: "Düzenle",
|
||||||
|
editList: "Listeyi düzenle",
|
||||||
|
deleteList: "Listeyi sil",
|
||||||
|
remove: "Listeden çıkar",
|
||||||
|
moveTo: "Başka listeye taşı",
|
||||||
|
goShopping: "Alışverişe çık",
|
||||||
|
back: "Alışveriş listeleri",
|
||||||
|
backToList: "Listeye dön",
|
||||||
|
},
|
||||||
|
editItemTitle: "Kalemi düzenle",
|
||||||
|
addItemTitle: "Kalem ekle",
|
||||||
|
moveTitle: "Başka listeye taşı",
|
||||||
|
moveDescription: (name: string) => `"${name}" seçilen aktif listeye taşınır.`,
|
||||||
|
moveTarget: "Hedef liste",
|
||||||
|
noOtherLists: "Taşınabilecek başka aktif liste yok.",
|
||||||
|
deleteTitle: "Liste silinsin mi?",
|
||||||
|
deleteDescription: (name: string) => `"${name}" listesi ve kalemleri silinir.`,
|
||||||
|
suggestions: {
|
||||||
|
title: "Stok önerileri",
|
||||||
|
description: "Eşiğin altına düşen ürünler. Onaylananlar önerilen miktarla listeye eklenir.",
|
||||||
|
addAll: "Tümünü ekle",
|
||||||
|
add: "Ekle",
|
||||||
|
dismiss: "Reddet",
|
||||||
|
dismissHint: "Stok yenilenene (yeni alım girilene) kadar önerilmez.",
|
||||||
|
line: (current: string, target: string, pct: number | null) =>
|
||||||
|
`Evde ${current} / hedef ${target}${pct === null ? "" : ` (%${pct})`}`,
|
||||||
|
suggested: (q: string) => `Önerilen: ${q}`,
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
title: "Akıllı mağaza önerisi",
|
||||||
|
description:
|
||||||
|
"Son bilinen fiyatlarla. Uzaklık ve taşıma güçlüğü, online teslimat ücreti ve yemek kartı bakiyesi hesaba katılır.",
|
||||||
|
recommended: "Önerilen dağılım",
|
||||||
|
single: "Tek mağaza",
|
||||||
|
singleBest: (store: string, total: string) => `Tek mağaza en iyi seçenek: ${store}, ${total}`,
|
||||||
|
split: (parts: string, total: string) => `Önerilen dağılım: ${parts}: ${total}`,
|
||||||
|
part: (store: string, n: number) => `${store} (${n} ürün)`,
|
||||||
|
saving: (amount: string) => `${amount} tasarruf`,
|
||||||
|
singleMissing: (n: number) => `Tek mağazada ${n} kalem bulunmuyor.`,
|
||||||
|
threshold: (amount: string) =>
|
||||||
|
`Ek mağaza ancak ${amount} üstü tasarruf sağlarsa önerilir (hane ayarı).`,
|
||||||
|
mealCard: (amount: string) => `Yemek kartıyla ödenebilir: ${amount}`,
|
||||||
|
mealCardBalance: (amount: string) => `Yemek kartı bakiyeniz: ${amount}`,
|
||||||
|
delivery: (amount: string) => `Teslimat ${amount}`,
|
||||||
|
freeDelivery: "Teslimat ücretsiz",
|
||||||
|
subtotal: "Ara toplam",
|
||||||
|
total: "Tahmini toplam",
|
||||||
|
line: (packages: string, price: string) => `${packages} × ${price}`,
|
||||||
|
unpriced: "Fiyatı bilinmeyenler",
|
||||||
|
unpricedHint: "Bu kalemler için mağaza fiyatı girilmemiş.",
|
||||||
|
cheapestAt: (store: string, unitPrice: string) =>
|
||||||
|
`En ucuz birim fiyat: ${store}, ${unitPrice}`,
|
||||||
|
empty: "Öneri için listede fiyatı bilinen ürün yok.",
|
||||||
|
textItems: "Serbest metin kalemleri öneriye katılmaz.",
|
||||||
|
missing: "Bu planda alınamayan",
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
title: "Alışveriş modu",
|
||||||
|
progress: (done: number, total: number) => `${done}/${total} sepette`,
|
||||||
|
other: "Diğer",
|
||||||
|
otherHint: "Fiyatı bilinmeyen ve serbest metin kalemleri",
|
||||||
|
inCart: "Sepette",
|
||||||
|
allDone: "Hepsi sepette.",
|
||||||
|
planChoice: "Plan",
|
||||||
|
check: (label: string) => `${label}: sepete alındı olarak işaretle`,
|
||||||
|
refreshed: "Liste güncellendi",
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
saved: "Liste kaydedildi.",
|
||||||
|
deleted: "Liste silindi.",
|
||||||
|
itemAdded: "Kalem eklendi.",
|
||||||
|
itemSaved: "Kalem güncellendi.",
|
||||||
|
itemRemoved: "Kalem listeden çıkarıldı.",
|
||||||
|
moved: "Kalem taşındı.",
|
||||||
|
suggestionsAdded: (n: number) => `${n} öneri listeye eklendi.`,
|
||||||
|
dismissed: "Öneri reddedildi.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
quantity: "Geçerli bir miktar girin (ör. 2 veya 1,5)",
|
||||||
|
productType: "Aktif bir ürün tipi seçin",
|
||||||
|
product: "Aktif bir ürün seçin",
|
||||||
|
text: "Kalem adını yazın",
|
||||||
|
duplicate: "Bu ürün listede zaten var; miktarını düzenleyin.",
|
||||||
|
duplicateIn: (name: string) => `Bu ürün "${name}" listesinde zaten var.`,
|
||||||
|
notActive: "Bu liste tamamlandı; değiştirilemez.",
|
||||||
|
sameList: "Kalem zaten bu listede.",
|
||||||
|
suggestionGone: "Öneri artık geçerli değil (listede ya da stok yenilenmiş).",
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
title: "Alışveriş",
|
||||||
|
empty: "Aktif liste yok.",
|
||||||
|
open: "Listeleri aç",
|
||||||
|
suggestions: (n: number) => `${n} stok önerisi bekliyor`,
|
||||||
|
},
|
||||||
|
},
|
||||||
productPrices: {
|
productPrices: {
|
||||||
add: "Fiyat gir",
|
add: "Fiyat gir",
|
||||||
addTitle: "Fiyat gir",
|
addTitle: "Fiyat gir",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const AUDIT_ACTIONS = [
|
|||||||
"product.archive",
|
"product.archive",
|
||||||
"product.unarchive",
|
"product.unarchive",
|
||||||
"price.delete",
|
"price.delete",
|
||||||
|
"shoppingList.delete",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ const productTypeSchema = new Schema(
|
|||||||
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
|
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
|
||||||
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
||||||
note: { type: String, default: "", maxlength: 300 },
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
/** Ağır/hacimli (su, deterjan): akıllı öneri zahmetli fiziksel mağazadan uzak tutar (Faz 13). */
|
||||||
|
bulky: { type: Boolean, default: false },
|
||||||
|
/**
|
||||||
|
* Eşik önerisi reddedildi: bu tarihten sonra alım hareketi gelene (stok yenilenene) kadar
|
||||||
|
* öneri gösterilmez (Faz 13).
|
||||||
|
*/
|
||||||
|
suggestionDismissedAt: { type: Date, default: null },
|
||||||
archivedAt: { type: Date, default: null },
|
archivedAt: { type: Date, default: null },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import {
|
||||||
|
SHOPPING_ITEM_KINDS,
|
||||||
|
SHOPPING_ITEM_SOURCES,
|
||||||
|
SHOPPING_LIST_STATUSES,
|
||||||
|
} from "@/shared/shopping";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alışveriş kalemi (Bölüm 6.12): ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin.
|
||||||
|
* Miktar binde bir: ürün tipinde tipin biriminde, üründe paket adedi, serbest metinde `unit` ile.
|
||||||
|
* `checked`: alışveriş modunda sepete alındı.
|
||||||
|
*/
|
||||||
|
const shoppingItemSchema = new Schema(
|
||||||
|
{
|
||||||
|
kind: { type: String, enum: SHOPPING_ITEM_KINDS, required: true },
|
||||||
|
productTypeId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
productId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
text: { type: String, default: "", trim: true, maxlength: 120 },
|
||||||
|
/** Boşsa "1 paket" kabul edilir. */
|
||||||
|
quantity: { type: Number, default: null, min: 0 },
|
||||||
|
/** Serbest metin kaleminin birimi. */
|
||||||
|
unit: { type: String, default: "", trim: true, maxlength: 20 },
|
||||||
|
note: { type: String, default: "", trim: true, maxlength: 200 },
|
||||||
|
source: { type: String, enum: SHOPPING_ITEM_SOURCES, default: "manual" },
|
||||||
|
checked: { type: Boolean, default: false },
|
||||||
|
checkedBy: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
checkedAt: { type: Date, default: null },
|
||||||
|
addedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
addedAt: { type: Date, default: () => new Date() },
|
||||||
|
},
|
||||||
|
{ _id: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Alışveriş listesi; hane içinde ortak. Birden çok aktif liste olabilir. Silme: soft delete. */
|
||||||
|
const shoppingListSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
status: { type: String, enum: SHOPPING_LIST_STATUSES, default: "active" },
|
||||||
|
note: { type: String, default: "", trim: true, maxlength: 300 },
|
||||||
|
items: { type: [shoppingItemSchema], default: [] },
|
||||||
|
completedAt: { type: Date, default: null },
|
||||||
|
deletedAt: { type: Date, default: null },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "shoppingLists" },
|
||||||
|
);
|
||||||
|
|
||||||
|
shoppingListSchema.index({ householdId: 1, deletedAt: 1, status: 1, updatedAt: -1 });
|
||||||
|
shoppingListSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ShoppingList = InferSchemaType<typeof shoppingListSchema>;
|
||||||
|
|
||||||
|
export const ShoppingListModel: Model<ShoppingList> =
|
||||||
|
(models.ShoppingList as Model<ShoppingList> | undefined) ??
|
||||||
|
model<ShoppingList>("ShoppingList", shoppingListSchema);
|
||||||
@@ -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(),
|
step: quantity(),
|
||||||
countEveryDays,
|
countEveryDays,
|
||||||
note: z.string().trim().max(300).default(""),
|
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ı. */
|
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
|
||||||
initialQuantity: optionalQuantity.default(null),
|
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