feat(shopping): alışverişi tamamla transaction'ı ve karşılaştırma motoru
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,12 @@ import { tr } from "@/i18n/tr";
|
|||||||
import { fail, type ActionResult } from "@/lib/action-result";
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
import { getHouseholdActionContext } from "@/lib/dal";
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
import {
|
import {
|
||||||
|
shoppingCompletionSchema,
|
||||||
shoppingItemFormSchema,
|
shoppingItemFormSchema,
|
||||||
shoppingItemUpdateSchema,
|
shoppingItemUpdateSchema,
|
||||||
shoppingListFormSchema,
|
shoppingListFormSchema,
|
||||||
} from "@/shared/schemas/shopping";
|
} from "@/shared/schemas/shopping";
|
||||||
|
import { completeShopping } from "./completion";
|
||||||
import {
|
import {
|
||||||
addShoppingItem,
|
addShoppingItem,
|
||||||
approveSuggestions,
|
approveSuggestions,
|
||||||
@@ -118,3 +120,9 @@ export async function dismissSuggestionAction(productTypeId: unknown) {
|
|||||||
if (!id.success) return fail(tr.common.notFound);
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data));
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ReturnType<typeof setupSeededDb>>;
|
||||||
|
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<string, unknown> = {}) =>
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, number>;
|
||||||
|
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<CompletionSheet | null> {
|
||||||
|
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<never>) {
|
||||||
|
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<ActionResult<{ transactions: number }>> {
|
||||||
|
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<string, ResolvedLine[]>();
|
||||||
|
for (const l of lines) byStore.set(l.storeId, [...(byStore.get(l.storeId) ?? []), l]);
|
||||||
|
const txDocs: { storeId: string; doc: Record<string, unknown>; 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<string, { amount: number; count: number }>();
|
||||||
|
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<string, PlannedPurchase & { storeId: string }>();
|
||||||
|
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<string, ResolvedLine[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,10 @@ import {
|
|||||||
suggestionHidden,
|
suggestionHidden,
|
||||||
type ShoppingItemKind,
|
type ShoppingItemKind,
|
||||||
type ShoppingItemSource,
|
type ShoppingItemSource,
|
||||||
|
type ShoppingCompletion,
|
||||||
type ShoppingListStatus,
|
type ShoppingListStatus,
|
||||||
} from "@/shared/shopping";
|
} from "@/shared/shopping";
|
||||||
|
import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
|
||||||
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
|
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
|
||||||
import { compareTr } from "@/shared/search";
|
import { compareTr } from "@/shared/search";
|
||||||
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
|
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
|
||||||
@@ -44,7 +46,7 @@ import type { StoreKind } from "@/shared/stores";
|
|||||||
|
|
||||||
const t = tr.shopping;
|
const t = tr.shopping;
|
||||||
|
|
||||||
type ItemLean = {
|
export type ItemLean = {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
kind: ShoppingItemKind;
|
kind: ShoppingItemKind;
|
||||||
productTypeId: Types.ObjectId | null;
|
productTypeId: Types.ObjectId | null;
|
||||||
@@ -59,13 +61,14 @@ type ItemLean = {
|
|||||||
addedAt: Date;
|
addedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ListLean = {
|
export type ListLean = {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
name: string;
|
name: string;
|
||||||
status: ShoppingListStatus;
|
status: ShoppingListStatus;
|
||||||
note: string;
|
note: string;
|
||||||
items: ItemLean[];
|
items: ItemLean[];
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
|
completion: ShoppingCompletion | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
@@ -154,6 +157,8 @@ export type ShoppingListDetail = {
|
|||||||
/** Kalem taşımak için diğer aktif listeler. */
|
/** Kalem taşımak için diğer aktif listeler. */
|
||||||
otherLists: { id: string; name: string }[];
|
otherLists: { id: string; name: string }[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
|
/** Tamamlanmış listede karşılaştırma raporu. */
|
||||||
|
report: { completion: ShoppingCompletion; summary: ComparisonSummary } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Yardımcılar ---
|
// --- Yardımcılar ---
|
||||||
@@ -401,7 +406,7 @@ function itemView(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPlan(
|
export async function buildPlan(
|
||||||
userId: string,
|
userId: string,
|
||||||
m: Membership,
|
m: Membership,
|
||||||
items: ItemLean[],
|
items: ItemLean[],
|
||||||
@@ -576,12 +581,27 @@ export async function getShoppingList(
|
|||||||
},
|
},
|
||||||
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
|
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
|
||||||
canWrite,
|
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 ---
|
// --- 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");
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
||||||
const list = await loadList(m, listId);
|
const list = await loadList(m, listId);
|
||||||
|
|||||||
@@ -1491,6 +1491,95 @@ export const tr = {
|
|||||||
notActive: "Bu liste tamamlandı; değiştirilemez.",
|
notActive: "Bu liste tamamlandı; değiştirilemez.",
|
||||||
sameList: "Kalem zaten bu listede.",
|
sameList: "Kalem zaten bu listede.",
|
||||||
suggestionGone: "Öneri artık geçerli değil (listede ya da stok yenilenmiş).",
|
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: {
|
dashboard: {
|
||||||
title: "Alışveriş",
|
title: "Alışveriş",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const shoppingListSchema = new Schema(
|
|||||||
note: { type: String, default: "", trim: true, maxlength: 300 },
|
note: { type: String, default: "", trim: true, maxlength: 300 },
|
||||||
items: { type: [shoppingItemSchema], default: [] },
|
items: { type: [shoppingItemSchema], default: [] },
|
||||||
completedAt: { type: Date, default: null },
|
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 },
|
deletedAt: { type: Date, default: null },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const stockMovementSchema = new Schema(
|
|||||||
initial: { type: Boolean, default: false },
|
initial: { type: Boolean, default: false },
|
||||||
/** Sayım oturumu (Faz 11b). */
|
/** Sayım oturumu (Faz 11b). */
|
||||||
countId: { type: Schema.Types.ObjectId, default: null },
|
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 },
|
deletedAt: { type: Date, default: null },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const transactionSchema = new Schema(
|
|||||||
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
|
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
|
||||||
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
||||||
isAdjustment: { type: Boolean, default: false },
|
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 },
|
deletedAt: { type: Date, default: null },
|
||||||
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
|
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { tr } from "../../i18n/tr";
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { parseMoney } from "../money";
|
||||||
import { parseQuantity } from "../quantity";
|
import { parseQuantity } from "../quantity";
|
||||||
|
|
||||||
const s = tr.shopping.validation;
|
const s = tr.shopping.validation;
|
||||||
@@ -72,3 +73,91 @@ export const shoppingItemUpdateSchema = z.object({
|
|||||||
export type ShoppingItemUpdate = z.output<typeof shoppingItemUpdateSchema>;
|
export type ShoppingItemUpdate = z.output<typeof shoppingItemUpdateSchema>;
|
||||||
|
|
||||||
export const shoppingItemRefSchema = z.object({ listId: id, itemId: id });
|
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<typeof shoppingCompletionSchema>;
|
||||||
|
export type ShoppingCompletionForm = z.output<typeof shoppingCompletionSchema>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MatchKind, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<MatchKind, number> = { 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 };
|
||||||
|
}
|
||||||
@@ -39,3 +39,52 @@ export function suggestionHidden(dismissedAt: Date | null, lastPurchaseAt: Date
|
|||||||
if (!dismissedAt) return false;
|
if (!dismissedAt) return false;
|
||||||
return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime();
|
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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user