diff --git a/CHANGELOG.md b/CHANGELOG.md index ac19ea0..fb642d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +### Eklendi — Faz 9a: varlık lotları ve kâr/zarar + +- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir. +- Varlık hareketleri (`assetMovements`): alım, gelen hediye/miras, satış, hediye/çıkış; tarih, miktar, tutar veya birim fiyat (ikisi de boşsa fiyat tablosundan: alımda satış, satış ve hediyede alış fiyatı), hesap, saklama yeri, not. Geçmiş tarihli satış/çıkış o tarihteki miktarla sınırlıdır. +- Hesap bağlantısı: alım hesaptan varlığa, satış varlıktan hesaba transfer olarak kaydedilir (bakiye değişir, gelir/gider raporlarına girmez). İşlem listesinde varlık adıyla görünür; bağlı işlem tek başına düzenlenemez/silinemez, hareket silinince birlikte silinir. +- Hesap motoru (`shared/portfolio.ts`): ağırlıklı ortalama maliyet, satışta ortalama maliyetle gerçekleşmiş K/Z, çıkışta K/Z yazılmaz, gelen hediyenin maliyeti o günkü değer (yatırım sayılmaz); güncel değer alış fiyatıyla (Faz 0), gerçekleşmemiş K/Z TL ve %. +- `/birikim/:id`: özet (eldeki miktar, ortalama maliyet, maliyet, güncel değer, K/Z, yatırılan, satış geliri), hareket geçmişi (kalan miktar, satış K/Z'si), hareket silme (sonraki çıkışı eksiye düşürecekse reddedilir). +- "Yalnızca ben" varlık: varlık, hareketleri ve bağlı hesap işlemleri yalnızca sahibine görünür. +- Seed: aylık alımlı ve bir satışlı gram altın birikimi, düğün ziynetleri (hediye giriş ve çıkış), Emre'nin "yalnızca ben" dolar varlığı, Annemler'de fiyatsız bilezikler. +- `009` migration'ı: `assets`, `assetMovements` index'leri, işlemlerde `assetId`. Denetim kaydı: `asset.delete`, `asset.movement.delete`. +- Testler: ortalama maliyet, satış/çıkış, hediye, eksi miktar, oran biçimi (birim); alım/satış transferleri ve bakiye, fiyat tablosundan tutar, gerçekleşmiş K/Z, kilitli işlem, hareket silme, gizli varlık, izleyici ve başka hane (entegrasyon). + ## [0.8.0] - 2026-11-05 Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları). diff --git a/scripts/seed/assets.ts b/scripts/seed/assets.ts new file mode 100644 index 0000000..6816b89 --- /dev/null +++ b/scripts/seed/assets.ts @@ -0,0 +1,162 @@ +import type { Types } from "mongoose"; +import { addMovement, createAsset } from "@/features/assets/service"; +import { AccountModel } from "@/models/account"; +import { AssetModel } from "@/models/asset"; +import { AssetMovementModel } from "@/models/asset-movement"; +import { TransactionModel } from "@/models/transaction"; +import type { AssetKey } from "@/shared/assets"; +import { todayIstanbul } from "@/shared/dates"; +import type { MovementKind } from "@/shared/portfolio"; +import { addDays } from "@/shared/recurrence"; +import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset"; + +/** + * Örnek varlıklar (Faz 9a). Tutarı boş alımlar fiyat tablosundaki örnek fiyatlardan (seedInKind + * önce çalışır) hesaplanır. İdempotent: varlık `seedRef` ile bir kez oluşturulur. + */ + +type SeedMovement = { + kind: MovementKind; + daysAgo: number; + quantity: string; + amount?: string; + account?: string; + storage?: string; + note?: string; +}; + +type SeedAsset = { + ref: string; + household: string; + by: string; + assetKey: AssetKey; + name: string; + isPrivate?: boolean; + note?: string; + movements: SeedMovement[]; +}; + +const monthly = (from: number, to: number): SeedMovement[] => + Array.from({ length: to - from + 1 }, (_, i) => ({ + kind: "buy" as const, + daysAgo: (to - i) * 30 + 2, + quantity: "1", + account: "Ziraat Maaş", + storage: "Ev kasası", + })); + +const ITEMS: SeedAsset[] = [ + { + ref: "gram-altin", + household: "Çakır Ailesi", + by: "ayse", + assetKey: "gram_gold", + name: "Gram altın birikimi", + note: "Her ay maaştan 1 gr", + movements: [ + { + kind: "buy", + daysAgo: 178, + quantity: "4", + account: "Birikim (Vadeli)", + storage: "Ev kasası", + note: "İlk alım", + }, + ...monthly(1, 5), + { kind: "sell", daysAgo: 12, quantity: "2", account: "Birikim (Vadeli)", note: "Tatil için" }, + ], + }, + { + ref: "ziynet", + household: "Çakır Ailesi", + by: "admin", + assetKey: "quarter_gold", + name: "Düğün ziynetleri", + movements: [ + { + kind: "gift_in", + daysAgo: 175, + quantity: "6", + storage: "Banka kiralık kasa", + note: "Düğün takıları", + }, + { kind: "gift_out", daysAgo: 40, quantity: "1", note: "Yeğenin düğünü" }, + ], + }, + { + ref: "emre-dolar", + household: "Çakır Ailesi", + by: "emre", + assetKey: "usd", + name: "Dolar", + isPrivate: true, + movements: [ + { kind: "buy", daysAgo: 120, quantity: "200", account: "Ziraat Maaş", storage: "Cüzdan" }, + ], + }, + { + ref: "bilezik", + household: "Annemler", + by: "fatma", + assetKey: "bracelet_22k", + name: "Bilezikler", + note: "Fiyat tablosunda bilezik fiyatı yok (örnek: fiyatsız varlık)", + movements: [{ kind: "gift_in", daysAgo: 160, quantity: "40", amount: "120.000" }], + }, +]; + +export async function seedAssets( + householdIds: Map, + userIds: Map, +): Promise { + const today = todayIstanbul(); + let created = 0; + for (const x of ITEMS) { + const householdId = householdIds.get(x.household)!; + const seedRef = `asset:${x.ref}`; + if (await AssetModel.exists({ householdId, seedRef })) continue; + const by = userIds.get(x.by)!; + const hh = String(householdId); + const r = await createAsset( + by, + hh, + assetFormSchema.parse({ + assetKey: x.assetKey, + name: x.name, + note: x.note ?? "", + isPrivate: x.isPrivate ?? false, + }), + ); + if (!r.ok) throw new Error(`Seed varlığı oluşturulamadı (${x.ref}): ${r.error}`); + await AssetModel.updateOne({ _id: r.data.id }, { $set: { seedRef } }); + for (const [i, m] of x.movements.entries()) { + const account = m.account + ? String((await AccountModel.findOne({ householdId, name: m.account }).lean())!._id) + : ""; + const res = await addMovement( + by, + hh, + r.data.id, + movementFormSchema.parse({ + kind: m.kind, + date: addDays(today, -m.daysAgo), + quantity: m.quantity, + amount: m.amount ?? "", + accountId: account, + storage: m.storage ?? "", + note: m.note ?? "", + }), + ); + if (!res.ok) throw new Error(`Seed varlık hareketi eklenemedi (${x.ref}): ${res.error}`); + const ref = `${seedRef}:${i}`; + const mv = await AssetMovementModel.findOneAndUpdate( + { _id: res.data.id }, + { $set: { seedRef: ref } }, + ).lean(); + if (mv?.transactionId) + await TransactionModel.updateOne({ _id: mv.transactionId }, { $set: { seedRef: ref } }); + } + created++; + } + return created; +} diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index d2f5d5c..2c9b0ee 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -32,6 +32,7 @@ describe("seed", () => { expect(summary.created.categories).toBeGreaterThan(40); expect(summary.created.accounts).toBe(7); expect(summary.created.transactions).toBeGreaterThan(150); + expect(summary.created.assets).toBe(4); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); @@ -49,6 +50,7 @@ describe("seed", () => { installmentPlans: 0, contacts: 0, inKind: 0, + assets: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index cb7d2ae..6cff6c8 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -11,6 +11,7 @@ import { HouseholdMemberModel } from "@/models/household-member"; import { storedEmail } from "@/shared/account-email"; import { runMigrations } from "../migrations"; import { seedContacts } from "./contacts"; +import { seedAssets } from "./assets"; import { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; import { seedRecurring } from "./recurring"; @@ -51,6 +52,7 @@ export type SeedSummary = { installmentPlans: number; contacts: number; inKind: number; + assets: number; }; accounts: SeedAccountRow[]; }; @@ -71,6 +73,8 @@ const DOMAIN_COLLECTIONS = [ "contacts", "assetPrices", "inKindDebts", + "assets", + "assetMovements", ]; type AuthApi = ReturnType["api"]; @@ -124,6 +128,7 @@ export async function runSeed({ installmentPlans: 0, contacts: 0, inKind: 0, + assets: 0, }, accounts: [], }; @@ -302,6 +307,8 @@ export async function runSeed({ if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`); summary.created.inKind = await seedInKind(householdIds, userIdMap); if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`); + summary.created.assets = await seedAssets(householdIds, userIdMap); + if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`); // 7) Geliştirme hesapları tablosu const all = [