feat(assets): dönemsel rapor, net varlık ve aynî kayıt bağlantısı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:19:05 +03:00
parent 20e5a90df4
commit 9a60085d14
9 changed files with 806 additions and 19 deletions
+60 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import mongoose, { Types } from "mongoose";
import { hasHouseholdRole } from "@/features/households/roles";
import { priceOn, type PriceQuote } from "@/features/in-kind/service";
import { buyPriceLookup, priceOn, type PriceQuote } from "@/features/in-kind/prices";
import { loadContext, type Ctx } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
@@ -14,10 +14,14 @@ import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import {
holdingSummary,
periodReport,
runMovements,
sumReports,
type HoldingSummary,
type MovementKind,
type PeriodReport,
} from "@/shared/portfolio";
import { addDays, addMonths } from "@/shared/recurrence";
import { QUANTITY_SCALE } from "@/shared/quantity";
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
import { compareTr, foldTr } from "@/shared/search";
@@ -482,3 +486,58 @@ export async function deleteMovement(
});
return ok(undefined);
}
// --- Dönemsel rapor ---
export const REPORT_RANGES = ["3", "6", "12", "tum"] as const;
export type ReportRange = (typeof REPORT_RANGES)[number];
/**
* Seçilen aralıkta (son N ay veya ilk hareketten bugüne) yatırılan TL, dönem sonu değer,
* kâr/zarar ve aylık değer noktaları. `assetId` verilirse yalnızca o varlık, yoksa tüm görünür
* varlıkların toplamı.
*/
export async function getPeriodReport(
userId: string,
householdId: string,
range: ReportRange,
assetId?: string,
): Promise<(PeriodReport & { excluded: number }) | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
let docs: AssetDoc[];
if (assetId) {
const a = await loadAsset(ctx, assetId);
if (!a) return null;
docs = [a];
} else {
docs = (
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
).filter((a) => visibleAsset(ctx, a._id));
}
const movements = await loadMovements(
ctx.m.householdId,
docs.map((a) => a._id),
);
const to = todayIstanbul();
let from: string;
if (range === "tum") {
const first = [...movements.values()]
.flat()
.map((m) => m.date)
.sort()[0];
from = first && first < to ? first : addDays(to, -1);
} else from = addMonths(to, -Number(range));
const price = await buyPriceLookup(
ctx.m.householdId,
[...new Set(docs.map((a) => a.assetKey))],
to,
);
const reports = docs.map((a) =>
periodReport(movements.get(String(a._id)) ?? [], (date) => price(a.assetKey, date), from, to),
);
if (assetId) return { ...reports[0]!, excluded: 0 };
// Toplamda fiyatı eksik varlıklar (kâr hesaplanamayan) dışarıda bırakılır ve sayılır.
const priced = reports.filter((r) => r.profitMinor !== null);
return { ...sumReports(priced, from, to), excluded: reports.length - priced.length };
}