3acd71fd59
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
497 lines
14 KiB
TypeScript
497 lines
14 KiB
TypeScript
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;
|
||
}
|