feat(assets): varlık lotları, ortalama maliyet ve kâr/zarar servisi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 23:17:44 +03:00
parent baec70d324
commit 890ba9f521
14 changed files with 1415 additions and 3 deletions
+30 -2
View File
@@ -13,6 +13,7 @@ import { writeAudit } from "@/lib/audit";
import { getDb } from "@/lib/db/mongo";
import { toObjectId } from "@/lib/object-id";
import { AccountModel } from "@/models/account";
import { AssetModel } from "@/models/asset";
import { ContactModel } from "@/models/contact";
import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
@@ -70,7 +71,7 @@ export async function loadContext(
const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null;
await postDueInstallments(m.householdId);
const [accounts, categories, contacts] = await Promise.all([
const [accounts, categories, contacts, assets] = await Promise.all([
AccountModel.find({ householdId: m.householdId })
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
.lean(),
@@ -89,6 +90,9 @@ export async function loadContext(
ContactModel.find({ householdId: m.householdId })
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
.lean(),
AssetModel.find({ householdId: m.householdId })
.select({ name: 1, assetKey: 1, isPrivate: 1, ownerUserId: 1, deletedAt: 1 })
.lean(),
]);
const accountMap = new Map<string, AccountInfo>(
accounts.map((a) => [
@@ -143,6 +147,19 @@ export async function loadContext(
]),
);
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
const assetMap = new Map(
assets.map((a) => [
String(a._id),
{
id: a._id,
name: a.name,
assetKey: a.assetKey,
deleted: Boolean(a.deletedAt),
hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId,
},
]),
);
const hiddenAssetIds = [...assetMap.values()].filter((a) => a.hidden).map((a) => a.id);
return {
m,
accountMap,
@@ -150,13 +167,15 @@ export async function loadContext(
contactMap,
hiddenAccountIds,
hiddenContactIds,
assetMap,
hiddenAssetIds,
flatCategories: flat,
};
}
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara veya gizli carilere dokunan işlemleri dışlayan temel filtre. */
/** Gizli hesaplara, gizli carilere veya gizli varlıklara dokunan işlemleri dışlayan temel filtre. */
export function baseFilter(ctx: Ctx): Record<string, unknown> {
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
if (ctx.hiddenAccountIds.length) {
@@ -164,6 +183,7 @@ export function baseFilter(ctx: Ctx): Record<string, unknown> {
f.toAccountId = { $nin: ctx.hiddenAccountIds };
}
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
if (ctx.hiddenAssetIds.length) f.assetId = { $nin: ctx.hiddenAssetIds };
return f;
}
@@ -473,6 +493,7 @@ export async function updateTransaction(
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
if (current.contactId) return fail(t.contactEditHint);
if (current.assetId) return fail(t.assetLocked);
const built = buildDoc(ctx, input, current);
if (!built.ok) return built;
const d = built.data as {
@@ -534,6 +555,8 @@ export async function setTransactionDeleted(
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
// Varlık alım/satışı hareketin parçasıdır; hareket Birikim sayfasından silinir.
if (current.assetId) return fail(t.assetLocked);
const actor = new Types.ObjectId(actorId);
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
@@ -602,6 +625,8 @@ export type TransactionListItem = {
toAccount: { id: string; name: string } | null;
/** Cari transferi: kişi ve hareket türü. */
contact: { id: string; name: string; kind: ContactEntryKind } | null;
/** Varlık alımı/satışı: karşı taraf varlık. */
asset: { id: string; name: string } | null;
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
splitCount: number;
payeeName: string;
@@ -683,6 +708,9 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
kind: tx.contactKind,
}
: null,
asset: tx.assetId
? { id: String(tx.assetId), name: ctx.assetMap.get(String(tx.assetId))?.name ?? "—" }
: null,
installment: tx.installment?.planId
? {
planId: String(tx.installment.planId),