import "server-only"; import { Types } from "mongoose"; import { syncOwnerAttachments } from "@/features/attachments/service"; import type { CategoryOption } from "@/features/categories/tree"; import { buildTree, toOptions } from "@/features/categories/tree"; import { loadTypeOffers } from "@/features/prices/service"; import { listProducts, type ProductView } from "@/features/products/service"; import { getStock } from "@/features/stock/service"; import { buildDoc, loadContext } from "@/features/transactions/service"; import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { connectMongoose } from "@/lib/db/mongoose"; import { AccountModel } from "@/models/account"; import { CategoryModel } from "@/models/category"; import { ProductPriceModel } from "@/models/product-price"; import { ShoppingListModel } from "@/models/shopping-list"; import { StoreModel } from "@/models/store"; import { StockMovementModel } from "@/models/stock-movement"; import { TransactionModel } from "@/models/transaction"; import { allowedAttachmentKinds } from "@/shared/attachments"; import type { MealCardProvider } from "@/shared/accounts"; import { todayIstanbul } from "@/shared/dates"; import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity"; import { formatStock } from "@/shared/stock"; import type { ShoppingCompletionForm } from "@/shared/schemas/shopping"; import type { CompletionRow, CompletionStore, ShoppingCompletion } from "@/shared/shopping"; import { matchKind, type PlannedPurchase } from "@/shared/shopping-compare"; import { MAX_SPLITS } from "@/shared/transactions"; import type { TransactionForm } from "@/shared/schemas/transaction"; import { getShoppingList, writableList, type ItemLean, type ShoppingItemView, type ShoppingListSummary, } from "./service"; /** * "Alışverişi tamamla" (Bölüm 6.12). Tek MongoDB transaction içinde: stok alım hareketleri, fiyat * kayıtları (kaynak: fiş), mağaza başına bölünmüş gider (stok → gider kategorisi eşlemesi), fiş * görselleri ve alınmayan kalemlerin taşınması; liste kapanır ve karşılaştırma görüntüsü saklanır. */ const t = tr.shopping; const v = t.validation; export type CompletionSheet = { list: ShoppingListSummary; items: ShoppingItemView[]; /** Kalem başına akıllı önerinin planı (önerilen dağılım). */ planned: Record< string, { productId: string; storeId: string; packages: number; priceMinor: number } >; products: { id: string; label: string; packageLabel: string; productTypeId: string; typeName: string; }[]; stores: { id: string; label: string; slot: number; mealCard: boolean; providers: string[] }[]; /** Geçerli paket fiyatları: `${productId}:${storeId}` → kuruş (tutar ön doldurması için). */ prices: Record; accounts: { id: string; name: string; type: string; mealCardProvider: MealCardProvider | null }[]; expenseCategories: CategoryOption[]; /** Gider kategorisine eşlenmemiş ürün tipleri (yedek kategori gerekir). */ unmappedTypeIds: string[]; defaultFallbackCategoryId: string | null; otherLists: { id: string; name: string }[]; today: string; }; async function expenseCategoryOptions(householdId: Types.ObjectId) { const rows = await CategoryModel.find({ householdId, kind: "expense" }).lean< { _id: Types.ObjectId; name: string; parentId: Types.ObjectId | null; order: number; archivedAt: Date | null; defaultKey: string | null; }[] >(); const options = toOptions( buildTree( rows.map((c) => ({ id: String(c._id), kind: "expense" as const, name: c.name, parentId: c.parentId ? String(c.parentId) : null, order: c.order, archived: Boolean(c.archivedAt), })), ), ); const market = rows.find((c) => c.defaultKey === "market" && !c.archivedAt); return { options, marketId: market ? String(market._id) : null }; } /** Ürün tipi → etkin gider kategorisi (stok kategorisinden, üstten devralınarak). */ async function typeExpenseMap(userId: string, householdId: string) { const stock = await getStock(userId, householdId); if (!stock) return null; const catExpense = new Map(stock.categories.map((c) => [c.id, c.expense?.id ?? null])); return new Map(stock.items.map((i) => [i.id, catExpense.get(i.categoryId) ?? null])); } /** /alisveris/:id/tamamla — alınanları girme ekranının verisi. */ export async function getCompletionSheet( userId: string, householdId: string, listId: string, ): Promise { const detail = await getShoppingList(userId, householdId, listId); if (!detail || !detail.canWrite) return null; const ctx = await loadContext(userId, householdId, "member"); if (!ctx) return null; const [productList, expense, categories, accounts] = await Promise.all([ listProducts(userId, householdId), typeExpenseMap(userId, householdId), expenseCategoryOptions(ctx.m.householdId), AccountModel.find({ householdId: ctx.m.householdId, archivedAt: null }) .select({ name: 1, type: 1, order: 1, isPrivate: 1, ownerUserId: 1, mealCardProvider: 1 }) .sort({ order: 1 }) .lean< { _id: Types.ObjectId; name: string; type: string; isPrivate: boolean; ownerUserId: Types.ObjectId | null; mealCardProvider: MealCardProvider | null; }[] >(), ]); if (!productList || !expense) return null; const products = productList.products.filter((p) => p.inTypeUnit !== null); const offers = await loadTypeOffers( ctx.m.householdId, [...new Set(products.map((p) => p.productTypeId))].map((x) => new Types.ObjectId(x)), { activeOnly: true }, ); const planned: CompletionSheet["planned"] = {}; for (const g of detail.plan.recommended?.stores ?? []) for (const l of g.lines) planned[l.itemId] = { productId: l.productId, storeId: l.storeId, packages: l.packages, priceMinor: l.priceMinor, }; const { stores: storeRefs } = detail.plan; const storeRows = await StoreModel.find({ householdId: ctx.m.householdId, archivedAt: null }) .select({ mealCardProviders: 1, mealCardAccepted: 1 }) .lean<{ _id: Types.ObjectId; mealCardAccepted: boolean; mealCardProviders: string[] }[]>(); return { list: detail.list, items: detail.items, planned, products: products.map((p) => ({ id: p.id, label: p.label, packageLabel: p.packageLabel, productTypeId: p.productTypeId, typeName: p.productTypeName, })), stores: storeRows .map((s) => { const ref = storeRefs[String(s._id)]!; return { id: ref.id, label: ref.label, slot: ref.slot, mealCard: s.mealCardAccepted, providers: s.mealCardAccepted ? s.mealCardProviders : [], }; }) .sort((a, b) => a.label.localeCompare(b.label, "tr")), prices: Object.fromEntries(offers.map((o) => [`${o.productId}:${o.storeId}`, o.priceMinor])), accounts: accounts .filter((a) => !a.isPrivate || String(a.ownerUserId) === userId) .map((a) => ({ id: String(a._id), name: a.name, type: a.type, mealCardProvider: a.mealCardProvider ?? null, })), expenseCategories: categories.options, unmappedTypeIds: [...expense.entries()].filter(([, e]) => !e).map(([id]) => id), defaultFallbackCategoryId: categories.marketId, otherLists: detail.otherLists, today: todayIstanbul(), }; } /** Transaction içindeki doğrulama hatası: geri alıp kullanıcıya döndürmek için. */ class CompletionError extends Error { constructor(readonly result: ActionResult) { super(result.ok ? "" : result.error); } } type ResolvedLine = { item: ItemLean | null; product: ProductView | null; storeId: string; /** Üründe paket adedi; markasız ürün tipi kaleminde tipin birimindeki miktar (binde bir). */ packages: number; amountMinor: number; categoryId: string; /** Stoğa işlenecek ürün tipi ve miktar (serbest metinde null). */ stock: { productTypeId: string; quantity: number } | null; }; export async function completeShopping( actorId: string, householdId: string, listId: string, input: ShoppingCompletionForm, ): Promise> { const r = await writableList(actorId, householdId, listId); if (r.error) return r.error; const { m, list, actor } = r; if (input.date > todayIstanbul()) return fail(v.futureDate, { date: v.futureDate }); const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const [productList, expense, stockData, activeStores] = await Promise.all([ listProducts(actorId, householdId, { includeArchived: true }), typeExpenseMap(actorId, householdId), getShoppingList(actorId, householdId, listId), StoreModel.find({ householdId: m.householdId, archivedAt: null }) .select({ _id: 1 }) .lean<{ _id: Types.ObjectId }[]>(), ]); const activeStoreIds = new Set(activeStores.map((x) => String(x._id))); if (!productList || !expense || !stockData) return fail(tr.common.notFound); const products = new Map(productList.products.map((p) => [p.id, p])); const views = new Map(stockData.items.map((i) => [i.id, i])); const storeRefs = stockData.plan.stores; const itemById = new Map(list.items.map((i) => [String(i._id), i])); // Yedek gider kategorisi (eşlenmemiş ve serbest metin kalemleri için). const fallback = input.fallbackCategoryId; const fallbackOk = fallback && ctx.categoryMap.get(fallback)?.kind === "expense" && !ctx.categoryMap.get(fallback)?.archived; // --- Satırları doğrula --- const lines: ResolvedLine[] = []; for (const [i, l] of input.lines.entries()) { const at = (field: string) => `lines.${i}.${field}`; const item = l.itemId ? (itemById.get(l.itemId) ?? null) : null; if (l.itemId && !item) return fail(v.itemGone); if (!activeStoreIds.has(l.storeId)) return fail(tr.productPrices.validation.store, { [at("storeId")]: tr.productPrices.validation.store, }); let product: ProductView | null = null; let typeId: string | null = null; if (item?.kind === "text") { if (l.productId) return fail(v.textNoProduct, { [at("productId")]: v.textNoProduct }); } else if (item?.kind === "type" && !l.productId) { // Markasız (ör. pazardan domates): miktar tipin biriminde, fiyat kaydı yok. typeId = String(item.productTypeId); } else { product = l.productId ? (products.get(l.productId) ?? null) : null; if (!product || product.archived) return fail(v.product, { [at("productId")]: v.product }); if (product.inTypeUnit === null) return fail(v.conversion(product.label), { [at("productId")]: v.conversion(product.label), }); const expectedType = item?.kind === "type" ? String(item.productTypeId) : item?.kind === "product" ? (products.get(String(item.productId))?.productTypeId ?? null) : null; if (expectedType && product.productTypeId !== expectedType) return fail(v.substituteType, { [at("productId")]: v.substituteType }); typeId = product.productTypeId; } const mapped = typeId ? (expense.get(typeId) ?? null) : null; const categoryId = mapped ?? (fallbackOk ? fallback : null); if (!categoryId) return fail(v.fallbackCategory, { fallbackCategoryId: v.fallbackCategory }); lines.push({ item, product, storeId: l.storeId, packages: l.packages, amountMinor: l.amount, categoryId, stock: typeId ? { productTypeId: typeId, quantity: product ? Math.max(1, Math.round((l.packages * product.inTypeUnit!) / QUANTITY_SCALE)) : l.packages, } : null, }); } // --- Mağaza başına gider işlemleri (doğrulama buildDoc ile) --- const accountOf = new Map(input.stores.map((s) => [s.storeId, s])); const byStore = new Map(); for (const l of lines) byStore.set(l.storeId, [...(byStore.get(l.storeId) ?? []), l]); const txDocs: { storeId: string; doc: Record; attachmentIds: string[] }[] = []; for (const [storeId, group] of byStore) { const entry = accountOf.get(storeId)!; const amount = group.reduce((a, l) => a + l.amountMinor, 0); const perCategory = new Map(); for (const l of group) { const c = perCategory.get(l.categoryId) ?? { amount: 0, count: 0 }; c.amount += l.amountMinor; c.count++; perCategory.set(l.categoryId, c); } if (perCategory.size > MAX_SPLITS) return fail(v.tooManySplits); const split = perCategory.size >= 2; const form: TransactionForm = { type: "expense", amount, date: input.date, accountId: entry.accountId, toAccountId: null, categoryId: split ? null : [...perCategory.keys()][0]!, splits: split ? [...perCategory.entries()].map(([categoryId, c]) => ({ categoryId, amount: c.amount, note: t.complete.splitNote(c.count), })) : [], payee: storeRefs[storeId]!.label, description: t.complete.txDescription(list.name), tags: [], installments: 1, attachmentIds: entry.attachmentIds, }; const built = buildDoc(ctx, form); if (!built.ok) { const msg = built.error; return fail(msg, { [`stores.${input.stores.indexOf(entry)}.accountId`]: msg }); } txDocs.push({ storeId, doc: built.data, attachmentIds: entry.attachmentIds }); } // --- Alınmayanlar --- const boughtIds = new Set(lines.flatMap((l) => (l.item ? [String(l.item._id)] : []))); const leftover = list.items.filter((i) => !boughtIds.has(String(i._id))); let target: { _id: Types.ObjectId; name: string; items: ItemLean[] } | null = null; if (leftover.length && input.leftover.action === "move") { const tl = await writableList(actorId, householdId, input.leftover.targetListId!); if (tl.error || tl.list._id.equals(list._id)) return fail(v.targetList, { "leftover.targetListId": v.targetList }); target = tl.list; } // --- Plan ve karşılaştırma görüntüsü --- // Kullanıcının gördüğü akıllı öneri (önerilen dağılım). const plan = stockData.plan; const planned = new Map(); for (const g of plan.recommended?.stores ?? []) for (const l of g.lines) planned.set(l.itemId, { productId: l.productId, storeId: l.storeId, packages: l.packages * QUANTITY_SCALE, priceMinor: l.priceMinor, costMinor: l.costMinor, }); const label = (id: string) => products.get(id)?.label ?? "—"; const pkgLabel = (id: string) => { const p = products.get(id); return p ? `${p.label} · ${p.packageLabel}` : "—"; }; const planOf = (itemId: string): CompletionRow["plan"] => { const p = planned.get(itemId); return p ? { ...p, productLabel: pkgLabel(p.productId), storeLabel: storeRefs[p.storeId]?.label ?? "—", } : null; }; const rows: CompletionRow[] = []; const lineByItem = new Map(lines.flatMap((l) => (l.item ? [[String(l.item._id), l]] : []))); for (const item of list.items) { const id = String(item._id); const l = lineByItem.get(id) ?? null; const p = planned.get(id) ?? null; rows.push({ itemId: id, match: matchKind( { kind: item.kind, productId: item.productId ? String(item.productId) : null }, p, l ? { productId: l.product?.id ?? null, packages: l.packages, amountMinor: l.amountMinor } : null, ), label: views.get(id)?.label ?? item.text, productId: l?.product?.id ?? null, productLabel: l?.product ? pkgLabel(l.product.id) : "", storeId: l?.storeId ?? null, storeLabel: l ? (storeRefs[l.storeId]?.label ?? "—") : "", packages: l?.packages ?? 0, quantityLabel: !l ? "" : l.product ? `${formatQuantity(l.packages)} ${t.packageUnit}` : l.stock ? formatStock(l.packages, views.get(id)?.unit ?? "") : "", amountMinor: l?.amountMinor ?? null, plan: planOf(id), }); } for (const l of lines.filter((x) => !x.item)) rows.push({ itemId: null, match: "extra", label: label(l.product!.id), productId: l.product!.id, productLabel: pkgLabel(l.product!.id), storeId: l.storeId, storeLabel: storeRefs[l.storeId]?.label ?? "—", packages: l.packages, quantityLabel: `${formatQuantity(l.packages)} ${t.packageUnit}`, amountMinor: l.amountMinor, plan: null, }); // --- Yaz (tek transaction) --- const mongoose = await connectMongoose(); const accountNames = new Map([...ctx.accountMap.values()].map((a) => [String(a.id), a.name])); try { const stores: CompletionStore[] = []; await mongoose.connection.transaction(async (session) => { stores.length = 0; for (const { storeId, doc, attachmentIds } of txDocs) { const [tx] = await TransactionModel.create( [ { ...doc, householdId: m.householdId, status: "posted", shoppingListId: list._id, createdBy: actor, updatedBy: actor, }, ], { session }, ); const synced = await syncOwnerAttachments({ householdId: m.householdId, actorId, ownerType: "transaction", ownerId: tx!._id, ids: attachmentIds, allowedKinds: allowedAttachmentKinds("expense"), session, }); if (!synced.ok) throw new CompletionError(synced); await TransactionModel.updateOne( { _id: tx!._id }, { $set: { attachmentCount: synced.data } }, { session }, ); const accountId = String(doc.accountId); stores.push({ storeId, label: storeRefs[storeId]!.label, accountId, accountName: accountNames.get(accountId) ?? "—", transactionId: String(tx!._id), amountMinor: doc.amountMinor as number, attachmentCount: synced.data, }); } const stockLines = lines.filter((l) => l.stock); if (stockLines.length) await StockMovementModel.insertMany( stockLines.map((l) => ({ householdId: m.householdId, productTypeId: new Types.ObjectId(l.stock!.productTypeId), kind: "purchase", date: input.date, quantity: l.stock!.quantity, note: t.complete.txDescription(list.name), shoppingListId: list._id, createdBy: actor, })), { session }, ); const productLines = lines.filter((l) => l.product); if (productLines.length) { // Fiyat kaydı: ürün × mağaza başına tek kayıt; paket fiyatı = tutar / paket adedi. const prices = new Map(); for (const l of productLines) { const key = `${l.product!.id}:${l.storeId}`; prices.set(key, [...(prices.get(key) ?? []), l]); } await ProductPriceModel.insertMany( [...prices.values()].map((group) => { const packages = group.reduce((a, l) => a + l.packages, 0); const amount = group.reduce((a, l) => a + l.amountMinor, 0); return { householdId: m.householdId, productId: new Types.ObjectId(group[0]!.product!.id), storeId: new Types.ObjectId(group[0]!.storeId), date: input.date, priceMinor: Math.max(1, Math.round((amount * QUANTITY_SCALE) / packages)), isPromo: false, promoUntil: null, source: "receipt", note: "", createdBy: actor, }; }), { session }, ); } let movedTo: ShoppingCompletion["movedTo"] = null; const carry = leftover.map((i) => ({ ...i, checked: false, checkedBy: null, checkedAt: null, })); if (leftover.length && input.leftover.action === "move" && target) { await ShoppingListModel.updateOne( { _id: target._id }, { $push: { items: { $each: carry } }, $set: { updatedBy: actor } }, { session }, ); movedTo = { listId: String(target._id), name: target.name }; } else if (leftover.length && input.leftover.action === "new") { const [created] = await ShoppingListModel.create( [ { householdId: m.householdId, name: input.leftover.newListName, items: carry, createdBy: actor, updatedBy: actor, }, ], { session }, ); movedTo = { listId: String(created!._id), name: input.leftover.newListName }; } const completion: ShoppingCompletion = { date: input.date, completedBy: actorId, rows, stores, movedTo, }; const res = await ShoppingListModel.updateOne( { _id: list._id, status: "active", deletedAt: null }, { $set: { status: "completed", completedAt: new Date(), completion, updatedBy: actor, }, }, { session }, ); if (!res.modifiedCount) throw new CompletionError(fail(v.notActive)); }); return ok({ transactions: txDocs.length }); } catch (error) { if (error instanceof CompletionError) return error.result; throw error; } }