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
+27 -2
View File
@@ -3,10 +3,17 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
import { addMovement, createAsset, deleteAsset, deleteMovement, updateAsset } from "./service";
import {
addMovement,
createAsset,
deleteAsset,
deleteMovement,
listAssets,
updateAsset,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
@@ -60,3 +67,21 @@ export async function deleteMovementAction(movementId: unknown) {
if (!id.success) return fail(tr.common.notFound);
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
}
export type AssetOption = { id: string; name: string; assetKey: string; quantity: number };
/** Aynî ödeme diyaloğu için görünür varlıklar (ad, tür, eldeki miktar). */
export async function getAssetOptionsAction(): Promise<ActionResult<AssetOption[]>> {
const ctx = await getHouseholdActionContext("viewer");
if (!ctx) return fail(tr.common.unauthorized);
const data = await listAssets(ctx.user.id, ctx.household.id);
if (!data) return fail(tr.common.unauthorized);
return ok(
data.items.map((a) => ({
id: a.id,
name: a.name,
assetKey: a.assetKey,
quantity: a.summary.quantity,
})),
);
}
+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 };
}