Add treasury document photos, table list, and manage permission.
Income/expense lists are table-or-card with detail, edit, void, and delete; edit/delete require owner, admin, or the new finance manage permission. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -181,6 +181,33 @@ export class TreasuryController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('documents/:id')
|
||||
getDocument(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.treasury.getDocument(user, id);
|
||||
}
|
||||
|
||||
@Patch('documents/:id')
|
||||
updateDocument(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: {
|
||||
title?: string;
|
||||
notes?: string | null;
|
||||
categoryId?: string | null;
|
||||
method?: string | null;
|
||||
bookedAt?: string;
|
||||
amount?: string | number;
|
||||
},
|
||||
) {
|
||||
return this.treasury.updateDocument(user, id, body);
|
||||
}
|
||||
|
||||
@Delete('documents/:id')
|
||||
deleteDocument(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.treasury.deleteDocument(user, id);
|
||||
}
|
||||
|
||||
@Get('attachments/:id')
|
||||
async downloadAttachment(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
const file = await this.treasury.openAttachment(user, id);
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { parseUserTreasuryAccess } from '@mento/shared';
|
||||
import {
|
||||
canManageTreasuryDocuments,
|
||||
parseUserTreasuryAccess,
|
||||
permissionsWithTreasuryOverride,
|
||||
} from '@mento/shared';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
@@ -506,13 +510,87 @@ export class TreasuryService {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { category: true, account: true, movements: true, attachments: { select: { id: true, fileName: true } } },
|
||||
include: {
|
||||
category: true,
|
||||
account: true,
|
||||
movements: true,
|
||||
attachments: { select: { id: true, fileName: true, mimeType: true } },
|
||||
},
|
||||
orderBy: { bookedAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((row) => this.toDocumentDto(row));
|
||||
}
|
||||
|
||||
async getDocument(actor: Actor, id: string): Promise<FinancialDocumentDto> {
|
||||
const doc = await this.requireScopedDocument(actor, id);
|
||||
return this.toDocumentDto(doc);
|
||||
}
|
||||
|
||||
async updateDocument(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: {
|
||||
title?: string;
|
||||
notes?: string | null;
|
||||
categoryId?: string | null;
|
||||
method?: string | null;
|
||||
bookedAt?: string;
|
||||
amount?: string | number;
|
||||
},
|
||||
): Promise<FinancialDocumentDto> {
|
||||
await this.assertDocumentsManage(actor);
|
||||
const doc = await this.requireScopedDocument(actor, id);
|
||||
if (doc.status === 'void' || doc.voidedAt) {
|
||||
throw new BadRequestException('سند باطلشده قابل ویرایش نیست.');
|
||||
}
|
||||
const data: Prisma.FinancialDocumentUncheckedUpdateInput = {};
|
||||
if (input.title !== undefined) {
|
||||
const title = input.title.trim();
|
||||
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
|
||||
data.title = title;
|
||||
}
|
||||
if (input.notes !== undefined) data.notes = input.notes?.trim() || null;
|
||||
if (input.categoryId !== undefined) data.categoryId = input.categoryId || null;
|
||||
if (input.method !== undefined) data.method = input.method || null;
|
||||
if (input.bookedAt !== undefined) {
|
||||
const bookedAt = startOfDay(input.bookedAt);
|
||||
if (!bookedAt) throw new BadRequestException('تاریخ نامعتبر است.');
|
||||
data.bookedAt = bookedAt;
|
||||
}
|
||||
if (input.amount !== undefined) {
|
||||
const active = doc.movements.filter((row) => !row.voidedAt);
|
||||
if (active.length > 0 || doc.status !== 'open') {
|
||||
throw new BadRequestException('مبلغ فقط برای سند باز بدون دریافت/پرداخت قابل تغییر است.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
data.amount = amount;
|
||||
}
|
||||
await this.prisma.financialDocument.update({ where: { id: doc.id }, data });
|
||||
return this.getDocument(actor, id);
|
||||
}
|
||||
|
||||
async deleteDocument(actor: Actor, id: string): Promise<{ ok: true }> {
|
||||
await this.assertDocumentsManage(actor);
|
||||
const doc = await this.requireScopedDocument(actor, id);
|
||||
const active = doc.movements.filter((row) => !row.voidedAt);
|
||||
if (doc.status !== 'void' && active.length > 0) {
|
||||
throw new BadRequestException('ابتدا سند را ابطال کنید، سپس حذف کنید.');
|
||||
}
|
||||
const attachments = await this.prisma.financialAttachment.findMany({
|
||||
where: { documentId: doc.id, organizationId: actor.organizationId },
|
||||
select: { id: true, storageKey: true },
|
||||
});
|
||||
for (const file of attachments) {
|
||||
const bytes = await this.files.sizeOf(file.storageKey);
|
||||
await this.files.remove(file.storageKey);
|
||||
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
|
||||
}
|
||||
await this.prisma.financialDocument.delete({ where: { id: doc.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async createDocument(
|
||||
actor: Actor,
|
||||
input: {
|
||||
@@ -1443,6 +1521,47 @@ export class TreasuryService {
|
||||
);
|
||||
}
|
||||
|
||||
private async actorPermissions(actor: Actor): Promise<string[]> {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: {
|
||||
orgRole: { select: { permissions: true } },
|
||||
treasuryAccessOverride: true,
|
||||
},
|
||||
});
|
||||
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.map(String)
|
||||
: [];
|
||||
return permissionsWithTreasuryOverride(
|
||||
rolePermissions,
|
||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||
);
|
||||
}
|
||||
|
||||
private async assertDocumentsManage(actor: Actor) {
|
||||
if (canManageTreasuryDocuments(actor.role, await this.actorPermissions(actor))) return;
|
||||
throw new ForbiddenException('اجازه ویرایش یا حذف اسناد را ندارید.');
|
||||
}
|
||||
|
||||
private async requireScopedDocument(actor: Actor, id: string) {
|
||||
const scope = await this.accountScope(actor);
|
||||
const doc = await this.prisma.financialDocument.findFirst({
|
||||
where: {
|
||||
id,
|
||||
organizationId: actor.organizationId,
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
account: true,
|
||||
movements: true,
|
||||
attachments: { select: { id: true, fileName: true, mimeType: true } },
|
||||
},
|
||||
});
|
||||
if (!doc) throw new NotFoundException('سند پیدا نشد.');
|
||||
return doc;
|
||||
}
|
||||
|
||||
private async accountScope(actor: Actor): Promise<string[] | null> {
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return null;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
@@ -1566,7 +1685,8 @@ export class TreasuryService {
|
||||
category?: { name: string } | null;
|
||||
account?: { name: string } | null;
|
||||
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
||||
attachments?: Array<{ id: string; fileName: string }>;
|
||||
attachments?: Array<{ id: string; fileName: string; mimeType?: string }>;
|
||||
voidedAt?: Date | null;
|
||||
}): FinancialDocumentDto {
|
||||
const settled = this.settledOf(row.movements);
|
||||
const rawRemaining = row.amount.minus(settled);
|
||||
|
||||
Reference in New Issue
Block a user