feat(seed): son 6 ayın örnek işlemleri ve 003 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 03:23:30 +03:00
parent dcfa2f8c8d
commit 3acd71fd59
6 changed files with 530 additions and 5 deletions
+2
View File
@@ -31,6 +31,7 @@ describe("seed", () => {
});
expect(summary.created.categories).toBeGreaterThan(40);
expect(summary.created.accounts).toBe(7);
expect(summary.created.transactions).toBeGreaterThan(150);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
});
@@ -43,6 +44,7 @@ describe("seed", () => {
memberships: 0,
categories: 0,
accounts: 0,
transactions: 0,
});
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
+16 -2
View File
@@ -9,6 +9,7 @@ import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import { storedEmail } from "@/shared/account-email";
import { runMigrations } from "../migrations";
import { seedTransactions } from "./transactions";
import {
ADMIN_USERNAME,
SEED_ACCOUNTS,
@@ -40,6 +41,7 @@ export type SeedSummary = {
memberships: number;
categories: number;
accounts: number;
transactions: number;
};
accounts: SeedAccountRow[];
};
@@ -93,7 +95,14 @@ export async function runSeed({
const db = getDb();
await connectMongoose();
const summary: SeedSummary = {
created: { users: 0, households: 0, memberships: 0, categories: 0, accounts: 0 },
created: {
users: 0,
households: 0,
memberships: 0,
categories: 0,
accounts: 0,
transactions: 0,
},
accounts: [],
};
@@ -246,7 +255,12 @@ export async function runSeed({
if (summary.created.categories) log(`✓ Varsayılan kategoriler: ${summary.created.categories}`);
if (summary.created.accounts) log(`✓ Örnek hesaplar: ${summary.created.accounts}`);
// 6) Geliştirme hesapları tablosu
// 6) Son 6 ayın örnek işlemleri (faker.seed(42), idempotent)
const userIdMap = new Map([...ids.entries()]);
summary.created.transactions = await seedTransactions(householdIds, userIdMap);
if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`);
// 7) Geliştirme hesapları tablosu
const all = [
{ username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const },
...SEED_USERS,
+496
View File
@@ -0,0 +1,496 @@
import { faker } from "@faker-js/faker";
import { Types } from "mongoose";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import { allocate } from "@/shared/money";
import { foldTr } from "@/shared/search";
import type { TransactionType } from "@/shared/transactions";
/**
* Son 6 ayın gerçekçi işlemleri (faker.seed(42) ile tekrarlanabilir).
* Her işlemin sabit `seedRef`'i vardır: tekrar çalıştırmada kopya oluşmaz.
*/
type Ctx = {
householdId: Types.ObjectId;
account: (name: string) => Types.ObjectId;
category: (key: string) => Types.ObjectId;
user: (username: string) => Types.ObjectId;
};
type SeedTx = {
ref: string;
type: TransactionType;
ymd: string;
amountMinor: number;
account: string;
toAccount?: string;
category?: string;
splits?: { category: string; weight: number; note?: string }[];
payee?: string;
description?: string;
tags?: string[];
by: string;
};
const tl = (min: number, max: number) => faker.number.int({ min: min * 100, max: max * 100 });
const pick = <T>(xs: readonly T[]) => faker.helpers.arrayElement(xs);
/** Ayın gün sayısına göre güvenli gün (31 → ayın son günü). */
function ymdOf(year: number, month: number, day: number) {
const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${year}-${String(month).padStart(2, "0")}-${String(Math.min(day, last)).padStart(2, "0")}`;
}
function months(count: number): { y: number; m: number; key: string }[] {
const [ty, tm] = todayIstanbul().split("-").map(Number) as [number, number];
return Array.from({ length: count }, (_, i) => {
const d = new Date(Date.UTC(ty, tm - 1 - (count - 1 - i), 1));
const y = d.getUTCFullYear();
const m = d.getUTCMonth() + 1;
return { y, m, key: `${y}-${String(m).padStart(2, "0")}` };
});
}
function cakirAiles(): SeedTx[] {
const out: SeedTx[] = [];
const marketPayees = ["Migros", "BİM", "A101", "Şok", "CarrefourSA"] as const;
for (const { y, m, key } of months(6)) {
const winter = [11, 12, 1, 2, 3].includes(m);
const d = (day: number) => ymdOf(y, m, day);
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
out.push({ ...x, ref: `cakir:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
add({
type: "income",
ymd: d(15),
amountMinor: 52_000_00,
account: "Ziraat Maaş",
category: "maas",
payee: "Anadolu Yazılım A.Ş.",
by: "admin",
});
add({
type: "income",
ymd: d(1),
amountMinor: 4_500_00,
account: "Ayşe — Pluxee",
category: "yemek-karti",
payee: "Pluxee",
by: "ayse",
});
add({
type: "expense",
ymd: d(3),
amountMinor: 17_500_00,
account: "Ziraat Maaş",
category: "kira",
payee: "Ev sahibi",
description: "Kira",
by: "admin",
});
add({
type: "expense",
ymd: d(5),
amountMinor: 1_250_00,
account: "Ziraat Maaş",
category: "aidat",
payee: "Site yönetimi",
by: "admin",
});
add({
type: "expense",
ymd: d(10),
amountMinor: tl(750, 1_600),
account: "Ziraat Maaş",
category: "faturalar.elektrik",
payee: "Enerjisa",
by: "ayse",
});
add({
type: "expense",
ymd: d(12),
amountMinor: tl(300, 620),
account: "Ziraat Maaş",
category: "faturalar.su",
payee: "İSKİ",
by: "ayse",
});
add({
type: "expense",
ymd: d(14),
amountMinor: winter ? tl(1_800, 3_400) : tl(350, 700),
account: "Ziraat Maaş",
category: "faturalar.dogalgaz",
payee: "İGDAŞ",
by: "ayse",
});
add({
type: "expense",
ymd: d(20),
amountMinor: 499_90,
account: "Garanti Bonus",
category: "faturalar.internet",
payee: "Türk Telekom",
by: "admin",
});
add({
type: "expense",
ymd: d(22),
amountMinor: 714_00,
account: "Garanti Bonus",
category: "faturalar.telefon",
payee: "Turkcell",
description: "2 hat",
by: "admin",
});
add({
type: "expense",
ymd: d(8),
amountMinor: 229_99,
account: "Garanti Bonus",
category: "abonelikler",
payee: "Netflix",
by: "admin",
});
add({
type: "expense",
ymd: d(9),
amountMinor: 99_99,
account: "Garanti Bonus",
category: "abonelikler",
payee: "Spotify",
by: "emre",
});
// Transferler: nakit çekme, kart ödemesi, birikim.
add({
type: "transfer",
ymd: d(2),
amountMinor: 2_000_00,
account: "Ziraat Maaş",
toAccount: "Cüzdan",
description: "ATM",
by: "admin",
});
add({
type: "transfer",
ymd: d(9),
amountMinor: tl(11_000, 15_000),
account: "Ziraat Maaş",
toAccount: "Garanti Bonus",
description: "Kart borcu ödemesi",
by: "admin",
});
add({
type: "transfer",
ymd: d(16),
amountMinor: 5_000_00,
account: "Ziraat Maaş",
toAccount: "Birikim (Vadeli)",
description: "Aylık birikim",
by: "ayse",
});
// Market: haftada 1–2; bir kısmı bölünmüş.
const marketCount = faker.number.int({ min: 6, max: 8 });
for (let i = 0; i < marketCount; i++) {
const payee = pick(marketPayees);
const amountMinor = tl(280, 2_600);
const split = amountMinor > 1_200_00 && faker.datatype.boolean({ probability: 0.5 });
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor,
account: pick(["Garanti Bonus", "Garanti Bonus", "Cüzdan"]),
...(split
? {
splits: [
{ category: "market.gida", weight: faker.number.int({ min: 55, max: 75 }) },
{
category: "market.temizlik",
weight: faker.number.int({ min: 10, max: 25 }),
note: "Deterjan",
},
{
category: "market.kisisel-bakim",
weight: faker.number.int({ min: 5, max: 15 }),
},
],
}
: { category: "market.gida" }),
payee,
tags: faker.datatype.boolean({ probability: 0.15 }) ? ["indirim"] : [],
by: pick(["ayse", "ayse", "admin", "emre"]),
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 2, max: 3 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(1_200, 2_300),
account: "Garanti Bonus",
category: "ulasim.yakit",
payee: pick(["Shell", "Opet", "BP"]),
by: "admin",
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 4, max: 6 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(110, 340),
account: "Ayşe — Pluxee",
category: "eglence.kafe",
payee: pick(["Starbucks", "Kahve Dünyası", "Simit Sarayı"]),
by: "ayse",
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 2, max: 4 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(450, 1_600),
account: pick(["Garanti Bonus", "Ayşe — Pluxee"]),
category: "eglence.disarida-yemek",
payee: pick(["Köfteci Yusuf", "Nusr-Et", "Big Chefs", "Yemeksepeti"]),
by: pick(["ayse", "admin"]),
},
i,
);
}
for (let i = 0; i < 4; i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(60, 220),
account: "Cüzdan",
category: "ulasim.toplu-tasima",
payee: "İstanbulkart",
by: "emre",
},
i,
);
}
if (faker.datatype.boolean({ probability: 0.6 })) {
add({
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(140, 650),
account: "Cüzdan",
category: "saglik.ilac",
payee: "Eczane",
by: "ayse",
});
}
if (faker.datatype.boolean({ probability: 0.5 })) {
add({
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(600, 3_200),
account: "Garanti Bonus",
category: "giyim",
payee: pick(["LC Waikiki", "Trendyol", "Koton"]),
by: "ayse",
});
}
}
return out;
}
function annemler(): SeedTx[] {
const out: SeedTx[] = [];
for (const { y, m, key } of months(6)) {
const winter = [11, 12, 1, 2, 3].includes(m);
const d = (day: number) => ymdOf(y, m, day);
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
out.push({ ...x, ref: `annemler:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
add({
type: "income",
ymd: d(17),
amountMinor: 24_350_00,
account: "Vakıfbank Emekli",
category: "maas",
payee: "SGK",
description: "Emekli maaşı",
by: "fatma",
});
add({
type: "expense",
ymd: d(11),
amountMinor: tl(450, 900),
account: "Vakıfbank Emekli",
category: "faturalar.elektrik",
payee: "Enerjisa",
by: "mehmet",
});
add({
type: "expense",
ymd: d(13),
amountMinor: winter ? tl(1_400, 2_600) : tl(250, 500),
account: "Vakıfbank Emekli",
category: "faturalar.dogalgaz",
payee: "İGDAŞ",
by: "mehmet",
});
add({
type: "transfer",
ymd: d(18),
amountMinor: 4_000_00,
account: "Vakıfbank Emekli",
toAccount: "Nakit",
description: "ATM",
by: "fatma",
});
for (let i = 0; i < 5; i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(200, 1_100),
account: pick(["Nakit", "Vakıfbank Emekli"]),
category: "market.gida",
payee: pick(["BİM", "Şok", "Pazar"]),
by: pick(["fatma", "mehmet"]),
},
i,
);
}
}
return out;
}
export async function seedTransactions(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
faker.seed(42);
let inserted = 0;
const plan: [string, SeedTx[]][] = [
["Çakır Ailesi", cakirAiles()],
["Annemler", annemler()],
];
const today = todayIstanbul();
for (const [householdName, txs] of plan) {
const householdId = householdIds.get(householdName)!;
const [accounts, categories] = await Promise.all([
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
.select({ defaultKey: 1 })
.lean(),
]);
const ctx: Ctx = {
householdId,
account: (name) => accounts.find((a) => a.name === name)!._id,
category: (key) => categories.find((c) => c.defaultKey === key)!._id,
user: (username) => new Types.ObjectId(userIds.get(username)!),
};
const payeeStats = new Map<
string,
{
name: string;
type: TransactionType;
account: Types.ObjectId;
category: Types.ObjectId | null;
count: number;
last: Date;
}
>();
for (const x of txs) {
if (x.ymd > today) continue; // Gelecek tarihli seed işlemi yok.
const date = istanbulDayStart(x.ymd)!;
const splits = x.splits
? allocate(
x.amountMinor,
x.splits.map((s) => s.weight),
).map((amountMinor, i) => ({
categoryId: ctx.category(x.splits![i]!.category),
amountMinor,
note: x.splits![i]!.note ?? "",
}))
: [];
const categoryId = x.category ? ctx.category(x.category) : null;
const accountId = ctx.account(x.account);
const by = ctx.user(x.by);
const doc = {
householdId,
type: x.type,
status: "posted",
date,
accountId,
toAccountId: x.toAccount ? ctx.account(x.toAccount) : null,
amountMinor: x.amountMinor,
categoryId: splits.length ? null : categoryId,
splits,
effectiveCategoryIds: splits.length
? [...new Set(splits.map((s) => String(s.categoryId)))].map(
(id) => new Types.ObjectId(id),
)
: categoryId
? [categoryId]
: [],
payeeName: x.payee ?? "",
description: x.description ?? "",
searchText: foldTr(
[x.payee ?? "", x.description ?? "", ...splits.map((s) => s.note)].join(" "),
),
tags: x.tags ?? [],
seedRef: x.ref,
createdBy: by,
updatedBy: by,
};
const res = await TransactionModel.updateOne(
{ householdId, seedRef: x.ref },
{ $setOnInsert: doc },
{ upsert: true },
);
if (res.upsertedCount) inserted++;
if (x.payee) {
const key = foldTr(x.payee);
const prev = payeeStats.get(key);
const latest = !prev || date >= prev.last;
payeeStats.set(key, {
name: x.payee,
type: latest ? x.type : prev!.type,
account: latest ? accountId : prev!.account,
category: latest ? (splits.length ? null : categoryId) : prev!.category,
count: (prev?.count ?? 0) + 1,
last: latest ? date : prev!.last,
});
}
}
for (const [nameKey, p] of payeeStats) {
await PayeeModel.updateOne(
{ householdId, nameKey },
{
$setOnInsert: { householdId, nameKey, name: p.name },
$set: {
lastType: p.type,
lastAccountId: p.account,
lastCategoryId: p.category,
useCount: p.count,
lastUsedAt: p.last,
},
},
{ upsert: true },
);
}
}
return inserted;
}