feat: add treasury audit, attachment download, and installment SMS
Let cheque clearance pick a cash account and export income, expense, and installment lists to Excel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
@@ -45,6 +47,7 @@ export class TreasuryReceivablesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
|
||||
@@ -211,6 +214,27 @@ export class TreasuryReceivablesService {
|
||||
return this.get(actor, installment.receivableId);
|
||||
}
|
||||
|
||||
async excel(actor: Actor) {
|
||||
const rows = await this.list(actor);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet('اقساط');
|
||||
sheet.addRow(['قرارداد', 'طرف حساب', 'قسط', 'سررسید', 'مبلغ', 'مانده', 'وضعیت']);
|
||||
for (const row of rows) {
|
||||
for (const item of row.installments) {
|
||||
sheet.addRow([
|
||||
row.title,
|
||||
row.partyName ?? '',
|
||||
item.position,
|
||||
item.dueDate.slice(0, 10),
|
||||
item.amount,
|
||||
item.remaining,
|
||||
item.status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return Buffer.from(await book.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
async upcoming(actor: Actor) {
|
||||
const rows = await this.prisma.financialInstallment.findMany({
|
||||
where: {
|
||||
@@ -264,18 +288,27 @@ export class TreasuryReceivablesService {
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
const title = dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید';
|
||||
const body = `${row.receivable.title} · قسط ${row.position} · مانده ${dto.remaining}`;
|
||||
for (const admin of admins) {
|
||||
await this.notifications.create({
|
||||
organizationId: row.receivable.organizationId,
|
||||
userId: admin.userId,
|
||||
type: 'treasury_installment',
|
||||
title: dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید',
|
||||
body: `${row.receivable.title} · قسط ${row.position}`,
|
||||
title,
|
||||
body,
|
||||
link: '/finance/receivables',
|
||||
entityType: 'financial_installment',
|
||||
entityId: row.id,
|
||||
dedupe: true,
|
||||
});
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: admin.userId },
|
||||
select: { mobile: true },
|
||||
});
|
||||
if (user?.mobile && this.sms.isConfigured()) {
|
||||
await this.sms.sendSmsDetailed(user.mobile, `${title}: ${body}`).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
await this.prisma.financialInstallment.update({
|
||||
where: { id: row.id },
|
||||
|
||||
@@ -95,6 +95,35 @@ export class TreasuryController {
|
||||
return this.treasury.createDocument(user, body);
|
||||
}
|
||||
|
||||
@Get('documents/export')
|
||||
async exportDocuments(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('kind') kind?: string,
|
||||
) {
|
||||
const buffer = await this.treasury.documentsExcel(
|
||||
user,
|
||||
kind === 'expense' ? 'expense' : 'income',
|
||||
);
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-documents.xlsx"',
|
||||
});
|
||||
}
|
||||
|
||||
@Get('attachments/:id')
|
||||
async downloadAttachment(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
const file = await this.treasury.openAttachment(user, id);
|
||||
return new StreamableFile(file.stream, {
|
||||
type: file.mimeType,
|
||||
disposition: `attachment; filename="${encodeURIComponent(file.fileName)}"`,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('audit')
|
||||
audit(@CurrentUser() user: Actor) {
|
||||
return this.treasury.listAudit(user);
|
||||
}
|
||||
|
||||
@Post('documents/:id/attachments')
|
||||
@UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 8_000_000 } }))
|
||||
addAttachment(
|
||||
@@ -114,6 +143,15 @@ export class TreasuryController {
|
||||
return this.treasury.settleDocument(user, id, body);
|
||||
}
|
||||
|
||||
@Get('receivables/export')
|
||||
async exportReceivables(@CurrentUser() user: Actor) {
|
||||
const buffer = await this.receivables.excel(user);
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-installments.xlsx"',
|
||||
});
|
||||
}
|
||||
|
||||
@Get('receivables')
|
||||
receivablesList(@CurrentUser() user: Actor) {
|
||||
return this.receivables.list(user);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { TreasuryController } from './treasury.controller';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
import { TreasuryBridgeService } from './treasury-bridge.service';
|
||||
@@ -17,6 +18,7 @@ import { TreasuryService } from './treasury.service';
|
||||
TreasuryOpsService,
|
||||
TreasuryBridgeService,
|
||||
TreasuryAccessGuard,
|
||||
SmsService,
|
||||
],
|
||||
exports: [TreasuryService, TreasuryBridgeService],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
@@ -365,7 +366,7 @@ export class TreasuryService {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { category: true, account: true, movements: true },
|
||||
include: { category: true, account: true, movements: true, attachments: { select: { id: true, fileName: true } } },
|
||||
orderBy: { bookedAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
@@ -555,6 +556,55 @@ export class TreasuryService {
|
||||
return this.toDocumentDto(fresh);
|
||||
}
|
||||
|
||||
async listAudit(actor: Actor) {
|
||||
const rows = await this.prisma.financialAuditLog.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
action: row.action,
|
||||
before: row.before,
|
||||
after: row.after,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async openAttachment(actor: Actor, id: string) {
|
||||
const row = await this.prisma.financialAttachment.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('پیوست پیدا نشد.');
|
||||
return {
|
||||
stream: this.files.openReadStream(row.storageKey),
|
||||
mimeType: row.mimeType,
|
||||
fileName: row.fileName,
|
||||
};
|
||||
}
|
||||
|
||||
async documentsExcel(actor: Actor, kind: 'income' | 'expense') {
|
||||
const rows = await this.listDocuments(actor, kind);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet(kind === 'income' ? 'درآمدها' : 'هزینهها');
|
||||
sheet.addRow(['عنوان', 'مبلغ', 'تسویه', 'مانده', 'وضعیت', 'دسته', 'طرف حساب', 'تاریخ']);
|
||||
for (const row of rows) {
|
||||
sheet.addRow([
|
||||
row.title,
|
||||
row.amount,
|
||||
row.settledAmount,
|
||||
row.remaining,
|
||||
row.status,
|
||||
row.categoryName ?? '',
|
||||
row.partyName ?? '',
|
||||
row.bookedAt.slice(0, 10),
|
||||
]);
|
||||
}
|
||||
return Buffer.from(await book.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
async addAttachment(actor: Actor, documentId: string, file: { originalname: string; mimetype: string; buffer: Buffer }) {
|
||||
const doc = await this.prisma.financialDocument.findFirst({
|
||||
where: { id: documentId, organizationId: actor.organizationId },
|
||||
@@ -774,6 +824,7 @@ 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 }>;
|
||||
}): FinancialDocumentDto {
|
||||
const settled = this.settledOf(row.movements);
|
||||
const rawRemaining = row.amount.minus(settled);
|
||||
@@ -798,6 +849,7 @@ export class TreasuryService {
|
||||
reference: row.reference,
|
||||
notes: row.notes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
attachments: row.attachments ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user