bd5a20ce64
- 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>
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
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);
|
||
});
|
||
});
|