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 ShoppingCompletion, type ShoppingListStatus, } from "@/shared/shopping"; import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare"; 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; export 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; }; export type ListLean = { _id: Types.ObjectId; name: string; status: ShoppingListStatus; note: string; items: ItemLean[]; completedAt: Date | null; completion: ShoppingCompletion | 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; products: Record; 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; /** Tamamlanmış listede karşılaştırma raporu. */ report: { completion: ShoppingCompletion; summary: ComparisonSummary } | null; }; // --- Yardımcılar --- const iso = (d: Date | null) => (d ? d.toISOString() : null); function summary(l: ListLean, labels: Map): 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) => i.kind === "type" ? `t:${i.productTypeId}` : i.kind === "product" ? `p:${i.productId}` : null; function itemName(i: ItemLean, labels: Map) { 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(); const productIds = new Set(); 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(); 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(); } /** Ü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(); 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 { 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(); 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 { 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(), 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, products: Map, archivedTypes: Map, ): 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, }; } export async function buildPlan( userId: string, m: Membership, items: ItemLean[], products: Map, types: Map, ): Promise { 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(); 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 { 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, report: list.completion ? { completion: list.completion, summary: compareShopping( list.completion.rows.map((r) => ({ match: r.match, plan: r.plan, actual: r.amountMinor === null ? null : { productId: r.productId, packages: r.packages, amountMinor: r.amountMinor }, })), ), } : null, }; } // --- Yazma: listeler --- export 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> { 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 { 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 { 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, ): Promise | 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> { 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 { 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 = { "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 { 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 { 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 { 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> { 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 { 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); }