From bd5a20ce642dcaac4348e47b6fc714bde7451100 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 23:57:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(shopping):=20al=C4=B1=C5=9Fveri=C5=9Fi=20t?= =?UTF-8?q?amamla=20transaction'=C4=B1=20ve=20kar=C5=9F=C4=B1la=C5=9Ft?= =?UTF-8?q?=C4=B1rma=20motoru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tek transaction: stok alımı, fiş kaynaklı fiyat, mağaza başına bölünmüş gider, fiş ekleri, kalanların taşınması - muadil, markasız ürün tipi ve plansız ekstra satırları; stok → gider kategorisi eşlemesi ve yedek kategori - compareShopping saf fonksiyonu: aynı/muadil/alınmadı/ekstra, farkın nedenlere tam ayrışması - tamamlanan listede rapor görüntüsü; testler Co-Authored-By: Claude Opus 5.5 --- src/features/shopping/actions.ts | 8 + src/features/shopping/completion.int.test.ts | 250 ++++++++ src/features/shopping/completion.ts | 586 +++++++++++++++++++ src/features/shopping/service.ts | 28 +- src/i18n/tr.ts | 89 +++ src/models/shopping-list.ts | 2 + src/models/stock-movement.ts | 2 + src/models/transaction.ts | 2 + src/shared/schemas/shopping.ts | 89 +++ src/shared/shopping-compare.test.ts | 75 +++ src/shared/shopping-compare.ts | 115 ++++ src/shared/shopping.ts | 49 ++ 12 files changed, 1291 insertions(+), 4 deletions(-) create mode 100644 src/features/shopping/completion.int.test.ts create mode 100644 src/features/shopping/completion.ts create mode 100644 src/shared/shopping-compare.test.ts create mode 100644 src/shared/shopping-compare.ts diff --git a/src/features/shopping/actions.ts b/src/features/shopping/actions.ts index 12b76e2..07a1335 100644 --- a/src/features/shopping/actions.ts +++ b/src/features/shopping/actions.ts @@ -6,10 +6,12 @@ import { tr } from "@/i18n/tr"; import { fail, type ActionResult } from "@/lib/action-result"; import { getHouseholdActionContext } from "@/lib/dal"; import { + shoppingCompletionSchema, shoppingItemFormSchema, shoppingItemUpdateSchema, shoppingListFormSchema, } from "@/shared/schemas/shopping"; +import { completeShopping } from "./completion"; import { addShoppingItem, approveSuggestions, @@ -118,3 +120,9 @@ export async function dismissSuggestionAction(productTypeId: unknown) { if (!id.success) return fail(tr.common.notFound); return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data)); } + +export async function completeShoppingAction(listId: unknown, input: unknown) { + const id = idSchema.safeParse(listId); + if (!id.success) return fail(tr.common.notFound); + return run(shoppingCompletionSchema, input, (u, h, d) => completeShopping(u, h, id.data, d)); +} diff --git a/src/features/shopping/completion.int.test.ts b/src/features/shopping/completion.int.test.ts new file mode 100644 index 0000000..1f2b39e --- /dev/null +++ b/src/features/shopping/completion.int.test.ts @@ -0,0 +1,250 @@ +import { Types } from "mongoose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { ProductModel } from "@/models/product"; +import { ProductPriceModel } from "@/models/product-price"; +import { ShoppingListModel } from "@/models/shopping-list"; +import { StockMovementModel } from "@/models/stock-movement"; +import { StoreModel } from "@/models/store"; +import { TransactionModel } from "@/models/transaction"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoneyInput } from "@/shared/money"; +import { addDays } from "@/shared/recurrence"; +import { shoppingCompletionSchema } from "@/shared/schemas/shopping"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { completeShopping, getCompletionSheet } from "./completion"; +import { getShoppingList } from "./service"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let zeynep = ""; +let weekly = ""; +let monthEnd = ""; +const today = todayIstanbul(); + +const bySeed = (model: typeof ProductModel | typeof StoreModel, seedRef: string) => + (model as typeof ProductModel) + .findOne({ seedRef }) + .lean() + .then((x) => String(x!._id)); +const accountId = (name: string) => + AccountModel.findOne({ name }) + .lean() + .then((x) => String(x!._id)); + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + zeynep = ids.user("zeynep"); + weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id); + monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id); +}); +afterAll(teardownDb); + +async function scenario() { + const sheet = (await getCompletionSheet(ayse, cakir, weekly))!; + const item = (label: string) => sheet.items.find((i) => i.label === label)!; + const milk = item("Süt"); + const eggs = item("Yumurta"); + const detergent = item("Toz Deterjan"); + const candle = item("Doğum günü mumu"); + const tomato = item("Domates"); + const sok = await bySeed(StoreModel, "sok"); + const migros = await bySeed(StoreModel, "migros"); + const a101 = await bySeed(StoreModel, "a101"); + const milkPlan = sheet.planned[milk.id]!; + const detPlan = sheet.planned[detergent.id]!; + const market = (await CategoryModel.findOne({ + defaultKey: "market", + householdId: cakir, + }).lean())!; + const accounts = { + bank: await accountId("Ziraat Maaş"), + meal: await accountId("Ayşe — Pluxee"), + card: await accountId("Garanti Bonus"), + }; + return { + sheet, + milk, + eggs, + detergent, + candle, + tomato, + stores: { sok, migros, a101 }, + form: (over: Record = {}) => + shoppingCompletionSchema.parse({ + date: today, + lines: [ + // Planlanan ürün, planlanan mağaza; paket fiyatı plandan 1 TL fazla. + { + itemId: milk.id, + productId: milkPlan.productId, + storeId: milkPlan.storeId, + packages: String(milkPlan.packages), + amount: formatMoneyInput((milkPlan.priceMinor + 100) * milkPlan.packages), + }, + // Muadil: 15'li köy yumurtası, A101. + { + itemId: eggs.id, + productId: sheet.products.find((p) => p.label.includes("Köy"))!.id, + storeId: a101, + packages: "2", + amount: "189,80", + }, + // Planlanan ürün başka mağazada (Migros, yemek kartı). + { + itemId: detergent.id, + productId: detPlan.productId, + storeId: migros, + packages: "1", + amount: "549,00", + }, + // Markasız ürün tipi (pazardan domates): 2,5 kg, fiyat kaydı yok. + { itemId: tomato.id, productId: "", storeId: sok, packages: "2,5", amount: "60" }, + // Serbest metin kalemi: yedek gider kategorisi. + { itemId: candle.id, productId: "", storeId: sok, packages: "1", amount: "25" }, + // Plansız ekstra. + { + itemId: "", + productId: sheet.products.find((p) => p.label.startsWith("Fairy"))!.id, + storeId: sok, + packages: "1", + amount: "84,50", + }, + ], + stores: [ + { storeId: sok, accountId: accounts.bank, attachmentIds: [] }, + { storeId: migros, accountId: accounts.meal, attachmentIds: [] }, + { storeId: a101, accountId: accounts.card, attachmentIds: [] }, + ], + fallbackCategoryId: String(market._id), + leftover: { action: "move", targetListId: monthEnd, newListName: "" }, + ...over, + }), + }; +} + +describe("tamamlama ekranı", () => { + it("plan, fiyatlar, hesaplar ve eşleme bilgisi; izleyici açamaz", async () => { + const s = await scenario(); + expect(s.sheet.items).toHaveLength(9); + expect(Object.keys(s.sheet.planned).length).toBe(7); + expect(Object.keys(s.sheet.prices).length).toBeGreaterThan(20); + expect(s.sheet.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee"); + expect(s.sheet.defaultFallbackCategoryId).not.toBeNull(); + expect(s.sheet.otherLists.map((l) => l.name)).toEqual(["Ay sonu toptan"]); + expect(await getCompletionSheet(zeynep, cakir, weekly)).toBeNull(); + }); +}); + +describe("alışverişi tamamla", () => { + it("doğrulama: muadil aynı tipten, gelecek tarih, izleyici, şema kuralları", async () => { + const s = await scenario(); + const wrongType = s.form(); + wrongType.lines[0]!.productId = await bySeed(ProductModel, "ariel-5kg"); + expect((await completeShopping(ayse, cakir, weekly, wrongType)).ok).toBe(false); + expect( + (await completeShopping(ayse, cakir, weekly, s.form({ date: addDays(today, 1) }))).ok, + ).toBe(false); + expect((await completeShopping(zeynep, cakir, weekly, s.form())).ok).toBe(false); + // Mağazası için ödeme hesabı girilmemiş satır. + expect( + shoppingCompletionSchema.safeParse({ + date: today, + lines: [ + { + itemId: "", + productId: s.milk.productTypeId, + storeId: s.stores.sok, + packages: "1", + amount: "1", + }, + ], + stores: [], + leftover: { action: "drop" }, + }).success, + ).toBe(false); + }); + + it("ek dosya hatasında hiçbir şey yazılmaz (transaction geri alınır)", async () => { + const s = await scenario(); + const form = s.form(); + form.stores[0]!.attachmentIds = [String(new Types.ObjectId())]; + expect((await completeShopping(ayse, cakir, weekly, form)).ok).toBe(false); + expect(await TransactionModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0); + expect(await StockMovementModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0); + expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(0); + expect((await ShoppingListModel.findById(weekly).lean())!.status).toBe("active"); + }); + + it("stok, fiyat, mağaza başına bölünmüş gider, kalanların taşınması ve rapor", async () => { + const s = await scenario(); + const monthEndBefore = (await ShoppingListModel.findById(monthEnd).lean())!.items.length; + + const r = await completeShopping(ayse, cakir, weekly, s.form()); + expect(r).toMatchObject({ ok: true, data: { transactions: 3 } }); + + // Mağaza başına gider; Şok: gıda + temizlik + yedek (mum) bölünmüş. + const txs = await TransactionModel.find({ shoppingListId: new Types.ObjectId(weekly) }).lean(); + expect(txs).toHaveLength(3); + const sokTx = txs.find((t) => t.payeeName.startsWith("Şok"))!; + expect(sokTx.splits).toHaveLength(3); + expect(sokTx.splits.reduce((a, x) => a + x.amountMinor, 0)).toBe(sokTx.amountMinor); + const migrosTx = txs.find((t) => t.payeeName.startsWith("Migros"))!; + expect(migrosTx).toMatchObject({ amountMinor: 549_00, splits: [] }); + expect(String(migrosTx.accountId)).toBe(await accountId("Ayşe — Pluxee")); + + // Stok alımları (serbest metin hariç 5 kalem) ve fiş kaynaklı fiyatlar (markasız domates hariç 4). + expect( + await StockMovementModel.countDocuments({ + shoppingListId: new Types.ObjectId(weekly), + kind: "purchase", + }), + ).toBe(5); + expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(4); + const eggPrice = await ProductPriceModel.findOne({ source: "receipt", priceMinor: 94_90 }); + expect(eggPrice).not.toBeNull(); + + // Liste kapandı; alınmayan 5 kalem "Ay sonu toptan"a taşındı. + const after = (await getShoppingList(ayse, cakir, weekly))!; + expect(after.list.status).toBe("completed"); + expect(after.canWrite).toBe(false); + expect(after.report!.completion.movedTo?.name).toBe("Ay sonu toptan"); + expect((await ShoppingListModel.findById(monthEnd).lean())!.items.length).toBe( + monthEndBefore + 4, + ); + // Süt alımı: paket × 1 lt. + const milkMove = await StockMovementModel.findOne({ + shoppingListId: new Types.ObjectId(weekly), + productTypeId: new Types.ObjectId(s.milk.productTypeId!), + }).lean(); + expect(milkMove!.quantity).toBe(s.sheet.planned[s.milk.id]!.packages * 1000); + // Markasız domates: tipin biriminde 2,5 kg. + const tomatoMove = await StockMovementModel.findOne({ + shoppingListId: new Types.ObjectId(weekly), + productTypeId: new Types.ObjectId(s.tomato.productTypeId!), + }).lean(); + expect(tomatoMove!.quantity).toBe(2500); + + // Rapor: aynı/muadil/alınmadı/ekstra; nedenlerin toplamı farka eşit. + const { summary, completion } = after.report!; + expect(summary.counts).toEqual({ same: 4, substitute: 1, skipped: 4, extra: 1 }); + const sum = Object.values(summary.reasons).reduce((a, b) => a + b, 0); + expect(sum).toBe(summary.diffMinor); + // Süt: paket başına +1 TL; deterjan: aynı ürün başka mağazada (Migros) farklı fiyat. + expect(summary.reasons.price).toBe( + 100 * s.sheet.planned[s.milk.id]!.packages + + (549_00 - s.sheet.planned[s.detergent.id]!.priceMinor), + ); + expect(summary.reasons.extra).toBe(84_50); + expect(summary.reasons.unplanned).toBe(25_00 + 60_00); + expect(summary.reasons.skipped).toBeLessThan(0); + expect(completion.stores).toHaveLength(3); + + // Tamamlanan liste değiştirilemez, ikinci kez tamamlanamaz. + expect((await completeShopping(ayse, cakir, weekly, s.form())).ok).toBe(false); + }); +}); diff --git a/src/features/shopping/completion.ts b/src/features/shopping/completion.ts new file mode 100644 index 0000000..fe3f1af --- /dev/null +++ b/src/features/shopping/completion.ts @@ -0,0 +1,586 @@ +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; + } +} diff --git a/src/features/shopping/service.ts b/src/features/shopping/service.ts index 9e85834..a3b4897 100644 --- a/src/features/shopping/service.ts +++ b/src/features/shopping/service.ts @@ -29,8 +29,10 @@ import { 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"; @@ -44,7 +46,7 @@ import type { StoreKind } from "@/shared/stores"; const t = tr.shopping; -type ItemLean = { +export type ItemLean = { _id: Types.ObjectId; kind: ShoppingItemKind; productTypeId: Types.ObjectId | null; @@ -59,13 +61,14 @@ type ItemLean = { addedAt: Date; }; -type ListLean = { +export type ListLean = { _id: Types.ObjectId; name: string; status: ShoppingListStatus; note: string; items: ItemLean[]; completedAt: Date | null; + completion: ShoppingCompletion | null; createdAt: Date; updatedAt: Date; }; @@ -154,6 +157,8 @@ export type ShoppingListDetail = { /** 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 --- @@ -401,7 +406,7 @@ function itemView( }; } -async function buildPlan( +export async function buildPlan( userId: string, m: Membership, items: ItemLean[], @@ -576,12 +581,27 @@ export async function getShoppingList( }, 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 --- -async function writableList(actorId: string, householdId: string, listId: string) { +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); diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 563b498..4a36f6d 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -1491,6 +1491,95 @@ export const tr = { 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ş).", + amount: "Geçerli bir tutar girin (ör. 89,90)", + noLines: "Alınan en az bir ürün seçin", + duplicateLine: "Bir liste kalemi yalnızca bir kez girilebilir", + duplicateStore: "Mağaza iki kez girilmiş", + account: "Her mağaza için ödeme hesabı seçin", + targetList: "Alınmayanlar için hedef liste seçin", + futureDate: "Gelecek tarihli alışveriş girilemez", + itemGone: "Listede olmayan bir kalem seçilmiş; sayfayı yenileyin", + substituteType: "Muadil aynı ürün tipinden olmalı", + textNoProduct: "Serbest metin kalemine ürün seçilemez", + conversion: (name: string) => `"${name}" ürününün tip birimine dönüşümü yok`, + fallbackCategory: "Eşlenmemiş kalemler için gider kategorisi seçin", + tooManySplits: "Bir mağazada çok fazla gider kategorisi var", + }, + complete: { + action: "Alışverişi tamamla", + title: "Alışverişi tamamla", + description: + "Aldıklarınızı seçin: aynı ürün ya da muadili, paket adedi, ödenen tutar ve mağaza. Kaydedince stok, fiyatlar, mağaza başına gider ve fişler tek seferde işlenir.", + date: "Alışveriş tarihi", + bought: "Alındı", + itemsTitle: "Listedekiler", + extrasTitle: "Listede olmayanlar", + addExtra: "Listede olmayan ürün ekle", + removeExtra: "Ekstrayı çıkar", + product: "Alınan ürün", + store: "Mağaza", + packages: "Paket", + quantity: "Miktar", + generic: (unit: string) => `Marka belirtmeden (${unit})`, + amount: "Tutar (₺)", + amountHint: "Fişteki satır tutarı.", + planned: (text: string) => `Plan: ${text}`, + substitute: "Muadil", + noPlan: "Planda fiyat yoktu", + textItem: "Serbest metin: stok ve fiyata işlenmez.", + storesTitle: "Ödeme ve fiş", + storesDescription: + "Her mağaza ayrı gider işlemi olur; kalemler stok kategorisinin gider kategorisine bölünür.", + account: "Ödeme hesabı", + receipts: "Fiş görselleri", + addReceipt: "Fiş ekle", + receiptHint: "Fiş fotoğrafı; işlemin ekine kaydedilir.", + subtotal: (n: number) => `${n} kalem`, + fallbackCategory: "Eşlenmemiş kalemlerin gider kategorisi", + fallbackHint: + "Stok kategorisi bir gider kategorisine eşlenmemiş ürünler ve serbest metin kalemleri buraya yazılır.", + leftoverTitle: "Alınmayanlar", + leftoverDescription: (n: number) => `${n} kalem alınmadı.`, + leftoverActions: { + move: "Başka aktif listeye taşı", + new: "Yeni liste aç", + drop: "Bırak (listeyle kapanır)", + }, + newListName: "Yeni liste adı", + newListDefault: (name: string) => `${name} – kalanlar`, + total: "Toplam", + plannedTotal: "Planlanan", + submit: "Alışverişi tamamla", + saved: "Alışveriş tamamlandı; stok, fiyatlar ve giderler işlendi.", + txDescription: (list: string) => `Alışveriş: ${list}`, + splitNote: (n: number) => `${n} kalem`, + noStores: "Mağaza yok. Önce bir mağaza ekleyin.", + }, + report: { + title: "Karşılaştırma raporu", + description: (date: string) => + `Alışveriş tarihi ${date}. Plan, tamamlama anındaki akıllı öneridir.`, + planned: "Planlanan", + actual: "Gerçekleşen", + diff: "Fark", + reasonsTitle: "Farkın nedenleri", + reasons: { + price: "Fiyat farkı (aynı ürün)", + quantity: "Miktar farkı", + substitute: "Muadil ürün", + skipped: "Alınmayanlar", + extra: "Plansız ekstralar", + unplanned: "Planda fiyatı olmayanlar", + }, + matches: { same: "Aynı", substitute: "Muadil", skipped: "Alınmadı", extra: "Plansız ekstra" }, + item: "Kalem", + plan: "Plan", + bought: "Alınan", + expenses: "Mağaza giderleri", + expenseLine: (account: string, receipts: number) => + receipts ? `${account} · ${receipts} fiş` : account, + movedTo: (name: string) => `Alınmayanlar "${name}" listesine taşındı.`, + noPlanCell: "—", }, dashboard: { title: "Alışveriş", diff --git a/src/models/shopping-list.ts b/src/models/shopping-list.ts index d842d30..214897f 100644 --- a/src/models/shopping-list.ts +++ b/src/models/shopping-list.ts @@ -40,6 +40,8 @@ const shoppingListSchema = new Schema( note: { type: String, default: "", trim: true, maxlength: 300 }, items: { type: [shoppingItemSchema], default: [] }, completedAt: { type: Date, default: null }, + /** Tamamlama anındaki karşılaştırma görüntüsü (ShoppingCompletion; etiketler sabitlenir). */ + completion: { type: Schema.Types.Mixed, default: null }, deletedAt: { type: Date, default: null }, seedRef: { type: String, default: null }, createdBy: { type: Schema.Types.ObjectId, required: true }, diff --git a/src/models/stock-movement.ts b/src/models/stock-movement.ts index 01ce86f..c4fc058 100644 --- a/src/models/stock-movement.ts +++ b/src/models/stock-movement.ts @@ -21,6 +21,8 @@ const stockMovementSchema = new Schema( initial: { type: Boolean, default: false }, /** Sayım oturumu (Faz 11b). */ countId: { type: Schema.Types.ObjectId, default: null }, + /** Alışverişi tamamlamadan gelen alım (Faz 13b). */ + shoppingListId: { type: Schema.Types.ObjectId, default: null }, deletedAt: { type: Date, default: null }, seedRef: { type: String, default: null }, createdBy: { type: Schema.Types.ObjectId, required: true }, diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 21a2efc..18cdd8d 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -76,6 +76,8 @@ const transactionSchema = new Schema( reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] }, /** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */ isAdjustment: { type: Boolean, default: false }, + /** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */ + shoppingListId: { type: Schema.Types.ObjectId, default: null }, deletedAt: { type: Date, default: null }, /** Seed verisinin idempotent yüklenmesi için sabit anahtar. */ seedRef: { type: String, default: null }, diff --git a/src/shared/schemas/shopping.ts b/src/shared/schemas/shopping.ts index d0ca59f..868f28d 100644 --- a/src/shared/schemas/shopping.ts +++ b/src/shared/schemas/shopping.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { tr } from "../../i18n/tr"; +import { parseMoney } from "../money"; import { parseQuantity } from "../quantity"; const s = tr.shopping.validation; @@ -72,3 +73,91 @@ export const shoppingItemUpdateSchema = z.object({ export type ShoppingItemUpdate = z.output; export const shoppingItemRefSchema = z.object({ listId: id, itemId: id }); + +const positiveQuantity = 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; + }); + +const amount = z + .string() + .trim() + .transform((x, ctx) => { + const minor = parseMoney(x); + if (minor === null || minor <= 0) { + ctx.addIssue({ code: "custom", message: s.amount }); + return z.NEVER; + } + return minor; + }); + +/** + * Alışverişi tamamla. Satır: liste kalemi (itemId) ya da plansız ekstra (itemId boş). Ürün + * kaleminde/ekstrada ürün zorunlu; serbest metin kaleminde ürün yok. Tutar satırın ödenen tutarı. + */ +export const shoppingCompletionSchema = z + .object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, tr.validation.date), + lines: z + .array( + z.object({ + itemId: optionalId.default(""), + productId: optionalId.default(""), + storeId: id, + packages: positiveQuantity, + amount, + }), + ) + .min(1, s.noLines) + .max(200), + stores: z + .array( + z.object({ + storeId: id, + accountId: z.string().regex(/^[a-f0-9]{24}$/i, s.account), + attachmentIds: z.array(id).max(10).default([]), + }), + ) + .max(10), + /** Gider kategorisine eşlenmemiş stok kategorilerindeki kalemler için. */ + fallbackCategoryId: optionalId.default(""), + leftover: z.object({ + action: z.enum(["move", "new", "drop"]), + targetListId: optionalId.default(""), + newListName: z.string().trim().max(80).default(""), + }), + }) + .superRefine((x, ctx) => { + const items = x.lines.map((l) => l.itemId).filter(Boolean); + if (new Set(items).size !== items.length) + ctx.addIssue({ code: "custom", path: ["lines"], message: s.duplicateLine }); + x.lines.forEach((l, i) => { + if (!l.itemId && !l.productId) + ctx.addIssue({ code: "custom", path: ["lines", i, "productId"], message: s.product }); + }); + const stores = new Set(x.stores.map((st) => st.storeId)); + if (stores.size !== x.stores.length) + ctx.addIssue({ code: "custom", path: ["stores"], message: s.duplicateStore }); + for (const l of x.lines) + if (!stores.has(l.storeId)) { + ctx.addIssue({ code: "custom", path: ["stores"], message: s.account }); + break; + } + if (x.leftover.action === "move" && !x.leftover.targetListId) + ctx.addIssue({ code: "custom", path: ["leftover", "targetListId"], message: s.targetList }); + if (x.leftover.action === "new" && !x.leftover.newListName) + ctx.addIssue({ + code: "custom", + path: ["leftover", "newListName"], + message: tr.common.required, + }); + }); +export type ShoppingCompletionInput = z.input; +export type ShoppingCompletionForm = z.output; diff --git a/src/shared/shopping-compare.test.ts b/src/shared/shopping-compare.test.ts new file mode 100644 index 0000000..df7b599 --- /dev/null +++ b/src/shared/shopping-compare.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { compareShopping, matchKind, type ComparisonRow } from "./shopping-compare"; + +const plan = (productId: string, packages: number, priceMinor: number) => ({ + productId, + packages: packages * 1000, + priceMinor, + costMinor: packages * priceMinor, +}); +const actual = (productId: string | null, packages: number, amountMinor: number) => ({ + productId, + packages: packages * 1000, + amountMinor, +}); + +describe("matchKind", () => { + it("ürün tipi kalemi: planlanan marka aynı, başka marka muadil, plan yoksa aynı", () => { + const item = { kind: "type" as const, productId: null }; + expect(matchKind(item, plan("a", 1, 100), actual("a", 1, 100))).toBe("same"); + expect(matchKind(item, plan("a", 1, 100), actual("b", 1, 100))).toBe("substitute"); + expect(matchKind(item, null, actual("b", 1, 100))).toBe("same"); + expect(matchKind(item, plan("a", 1, 100), null)).toBe("skipped"); + }); + + it("ürün kalemi başka ürünle muadil; serbest metin alındıysa aynı; listede yoksa ekstra", () => { + expect(matchKind({ kind: "product", productId: "a" }, null, actual("c", 1, 100))).toBe( + "substitute", + ); + expect(matchKind({ kind: "text", productId: null }, null, actual(null, 1, 100))).toBe("same"); + expect(matchKind(null, null, actual("x", 1, 100))).toBe("extra"); + }); +}); + +describe("compareShopping", () => { + it("fark nedenlere ayrışır ve toplamı farka eşittir", () => { + const rows: ComparisonRow[] = [ + // Aynı ürün: 2 paket planlandı (100), 3 alındı ve paket 110 → fiyat +30, miktar +100. + { match: "same", plan: plan("a", 2, 100), actual: actual("a", 3, 330) }, + // Muadil: plan 200, gerçek 180. + { match: "substitute", plan: plan("b", 1, 200), actual: actual("b2", 1, 180) }, + // Alınmadı: −50. + { match: "skipped", plan: plan("c", 1, 50), actual: null }, + // Fiyatı bilinmeyen kalem alındı: +40. + { match: "same", plan: null, actual: actual("d", 1, 40) }, + // Ekstra: +75. + { match: "extra", plan: null, actual: actual("e", 1, 75) }, + ]; + const s = compareShopping(rows); + expect(s.plannedMinor).toBe(200 + 200 + 50); + expect(s.actualMinor).toBe(330 + 180 + 40 + 75); + expect(s.reasons).toEqual({ + price: 30, + quantity: 100, + substitute: -20, + skipped: -50, + extra: 75, + unplanned: 40, + }); + const sum = Object.values(s.reasons).reduce((a, b) => a + b, 0); + expect(sum).toBe(s.diffMinor); + expect(s.counts).toEqual({ same: 2, substitute: 1, skipped: 1, extra: 1 }); + }); + + it("kesirli paket: plan fiyatıyla beklenen yuvarlanır, toplam yine tam", () => { + const s = compareShopping([ + { + match: "same", + plan: plan("a", 1, 999), + actual: { productId: "a", packages: 1500, amountMinor: 1500 }, + }, + ]); + expect(s.reasons.price + s.reasons.quantity).toBe(s.diffMinor); + expect(s.reasons.quantity).toBe(1499 - 999); + }); +}); diff --git a/src/shared/shopping-compare.ts b/src/shared/shopping-compare.ts new file mode 100644 index 0000000..69d681a --- /dev/null +++ b/src/shared/shopping-compare.ts @@ -0,0 +1,115 @@ +/** + * Alışveriş sonrası karşılaştırma (Bölüm 6.12). Saf fonksiyonlar. + * + * Her liste kalemi için: aynı (planlanan ürün alındı), muadil (aynı ürün tipinde başka marka/paket), + * alınmadı; listede olmayanlar plansız ekstra. Planlanan tutar, tamamlama anındaki akıllı önerinin + * (önerilen dağılım) kalem maliyetidir; fiyatı bilinmeyen kalemin planı yoktur. + * + * Fark (gerçek − plan) nedenlere ayrıştırılır ve toplamları farka tam eşittir: + * - fiyat: aynı ürünün paket fiyatı farkı × alınan paket, + * - miktar: (alınan − planlanan paket) × planlanan paket fiyatı, + * - muadil: muadil kalemlerde gerçek − plan, + * - alınmayan: alınmayan planlı kalemlerin planı (eksi), + * - ekstra: plansız ekstraların tutarı, + * - fiyatsız: planı olmayan (fiyatı bilinmeyen) kalemlerin tutarı. + */ + +export const MATCH_KINDS = ["same", "substitute", "skipped", "extra"] as const; +export type MatchKind = (typeof MATCH_KINDS)[number]; + +/** Liste kalemi veya ekstra için gerçekleşen alım. Paket adedi binde bir. */ +export type ActualPurchase = { productId: string | null; packages: number; amountMinor: number }; + +/** Akıllı önerinin kalem için planı. */ +export type PlannedPurchase = { + productId: string; + packages: number; + priceMinor: number; + costMinor: number; +}; + +/** + * Kalemin eşleşmesi. Ürün kalemi: aynı ürün → aynı. Ürün tipi kalemi: planlanan markayla aynı + * ürün (ya da plan yoksa herhangi bir ürün) → aynı; başka marka/paket → muadil. Serbest metin + * kalemi alındıysa aynı. + */ +export function matchKind( + item: { kind: "type" | "product" | "text"; productId: string | null } | null, + plan: PlannedPurchase | null, + actual: ActualPurchase | null, +): MatchKind { + if (!item) return "extra"; + if (!actual) return "skipped"; + if (item.kind === "text") return "same"; + const expected = item.kind === "product" ? item.productId : (plan?.productId ?? null); + return expected === null || expected === actual.productId ? "same" : "substitute"; +} + +export type ComparisonRow = { + match: MatchKind; + plan: PlannedPurchase | null; + actual: ActualPurchase | null; +}; + +export type ComparisonReasons = { + price: number; + quantity: number; + substitute: number; + skipped: number; + extra: number; + unplanned: number; +}; + +export type ComparisonSummary = { + plannedMinor: number; + actualMinor: number; + diffMinor: number; + reasons: ComparisonReasons; + counts: Record; +}; + +export function compareShopping(rows: readonly ComparisonRow[]): ComparisonSummary { + const reasons: ComparisonReasons = { + price: 0, + quantity: 0, + substitute: 0, + skipped: 0, + extra: 0, + unplanned: 0, + }; + const counts: Record = { same: 0, substitute: 0, skipped: 0, extra: 0 }; + let plannedMinor = 0; + let actualMinor = 0; + for (const r of rows) { + counts[r.match]++; + if (r.plan) plannedMinor += r.plan.costMinor; + if (r.actual) actualMinor += r.actual.amountMinor; + switch (r.match) { + case "extra": + reasons.extra += r.actual?.amountMinor ?? 0; + break; + case "skipped": + reasons.skipped -= r.plan?.costMinor ?? 0; + break; + case "substitute": + if (r.plan) reasons.substitute += r.actual!.amountMinor - r.plan.costMinor; + else reasons.unplanned += r.actual!.amountMinor; + break; + case "same": { + const a = r.actual!; + if (!r.plan || a.productId !== r.plan.productId) { + // Plansız (fiyatı bilinmeyen) ya da serbest metin. + if (r.plan) reasons.substitute += a.amountMinor - r.plan.costMinor; + else reasons.unplanned += a.amountMinor; + break; + } + // Paket adedi binde bir: plan fiyatıyla beklenen = paket × fiyat. + const expectedAtPlanPrice = Math.round((a.packages * r.plan.priceMinor) / 1000); + reasons.price += a.amountMinor - expectedAtPlanPrice; + reasons.quantity += expectedAtPlanPrice - r.plan.costMinor; + break; + } + } + } + return { plannedMinor, actualMinor, diffMinor: actualMinor - plannedMinor, reasons, counts }; +} diff --git a/src/shared/shopping.ts b/src/shared/shopping.ts index 638fc6b..5400e46 100644 --- a/src/shared/shopping.ts +++ b/src/shared/shopping.ts @@ -39,3 +39,52 @@ export function suggestionHidden(dismissedAt: Date | null, lastPurchaseAt: Date if (!dismissedAt) return false; return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime(); } + +/** Alınmayan kalemler: başka aktif listeye taşı, yeni liste aç ya da bırak. */ +export const LEFTOVER_ACTIONS = ["move", "new", "drop"] as const; +export type LeftoverAction = (typeof LEFTOVER_ACTIONS)[number]; + +/** Tamamlanan listede saklanan görüntü (etiketler tamamlama anındaki hâliyle). */ +export type CompletionRow = { + itemId: string | null; + match: "same" | "substitute" | "skipped" | "extra"; + /** Kalem adı (liste kalemi ya da ekstra ürün). */ + label: string; + /** Alınan: ürün etiketi ve paketi; serbest metinde boş. */ + productId: string | null; + productLabel: string; + storeId: string | null; + storeLabel: string; + /** Paket adedi (binde bir). */ + packages: number; + /** Alınan miktar metni ("4 paket", markasızda "2,5 kg"); alınmadıysa boş. */ + quantityLabel?: string; + amountMinor: number | null; + plan: { + productId: string; + productLabel: string; + storeId: string; + storeLabel: string; + packages: number; + priceMinor: number; + costMinor: number; + } | null; +}; + +export type CompletionStore = { + storeId: string; + label: string; + accountId: string; + accountName: string; + transactionId: string; + amountMinor: number; + attachmentCount: number; +}; + +export type ShoppingCompletion = { + date: string; + completedBy: string; + rows: CompletionRow[]; + stores: CompletionStore[]; + movedTo: { listId: string; name: string } | null; +};