From 96befd3867866a230afa0656dfb5d18c4dc4be16 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 18 Oct 2026 19:59:24 +0330 Subject: [PATCH] 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 --- .../treasury/treasury-receivables.service.ts | 37 ++++++++++- apps/api/src/treasury/treasury.controller.ts | 38 +++++++++++ apps/api/src/treasury/treasury.module.ts | 2 + apps/api/src/treasury/treasury.service.ts | 54 ++++++++++++++- .../src/components/treasury/TreasuryNav.vue | 1 + apps/web/src/router/index.ts | 6 ++ apps/web/src/views/ChequesView.vue | 65 ++++++++++++++----- .../src/views/treasury/TreasuryAuditView.vue | 57 ++++++++++++++++ .../src/views/treasury/TreasuryDocsView.vue | 34 ++++++++++ .../treasury/TreasuryReceivablesView.vue | 11 ++++ packages/shared/src/index.ts | 1 + 11 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/views/treasury/TreasuryAuditView.vue diff --git a/apps/api/src/treasury/treasury-receivables.service.ts b/apps/api/src/treasury/treasury-receivables.service.ts index 785a3b4..7841657 100644 --- a/apps/api/src/treasury/treasury-receivables.service.ts +++ b/apps/api/src/treasury/treasury-receivables.service.ts @@ -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 { @@ -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 }, diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 8ca599b..903ab3f 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -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); diff --git a/apps/api/src/treasury/treasury.module.ts b/apps/api/src/treasury/treasury.module.ts index c48cca6..40d07f8 100644 --- a/apps/api/src/treasury/treasury.module.ts +++ b/apps/api/src/treasury/treasury.module.ts @@ -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], }) diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index c2ba22d..374b9be 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -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 ?? [], }; } } diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue index 65a61a8..e91cb69 100644 --- a/apps/web/src/components/treasury/TreasuryNav.vue +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -19,6 +19,7 @@ const links = computed(() => [ ...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []), { to: '/finance/recurring', label: 'تکرارشونده' }, { to: '/finance/reports', label: 'گزارش' }, + { to: '/finance/audit', label: 'سوابق' }, { to: '/finance/categories', label: 'دسته‌بندی‌ها' }, ]); diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 3c4413b..5d43af9 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -282,6 +282,12 @@ export const router = createRouter({ component: () => import('@/views/treasury/TreasuryReportsView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, }, + { + path: 'finance/audit', + name: 'finance-audit', + component: () => import('@/views/treasury/TreasuryAuditView.vue'), + meta: { roles: ['owner', 'admin'] as UserRole[] }, + }, { path: 'finance/categories', name: 'finance-categories', diff --git a/apps/web/src/views/ChequesView.vue b/apps/web/src/views/ChequesView.vue index 00d0af4..0565d78 100644 --- a/apps/web/src/views/ChequesView.vue +++ b/apps/web/src/views/ChequesView.vue @@ -27,6 +27,10 @@ const items = ref([]); const status = ref(''); const q = ref(''); const savingId = ref(''); +const clearOpen = ref(false); +const clearRow = ref(null); +const clearAccountId = ref(''); +const clearAccounts = ref>([]); const tab = ref('in'); const formOpen = ref(false); @@ -95,9 +99,26 @@ async function load() { } } +async function confirmClear() { + if (!clearRow.value || !clearAccountId.value) return; + savingId.value = clearRow.value.id; + error.value = ''; + try { + const { data } = await api.patch(`/cheques/${clearRow.value.id}`, { + status: 'cleared', + financialAccountId: clearAccountId.value, + }); + items.value = items.value.map((item) => (item.id === data.id ? data : item)); + clearOpen.value = false; + } catch { + error.value = 'وصول چک ناموفق بود.'; + } finally { + savingId.value = ''; + } +} + async function setStatus(row: ChequeDto, next: ChequeStatus) { if (row.status === next) return; - let financialAccountId: string | undefined; if (next === 'cleared' && row.status !== 'cleared') { const accountsRes = await api .get>('/treasury/accounts') @@ -114,30 +135,20 @@ async function setStatus(row: ChequeDto, next: ChequeStatus) { } return; } - const accounts = accountsRes.data; - const active = accounts.filter((item) => item.isActive); + const active = accountsRes.data.filter((item) => item.isActive); if (!active.length) { error.value = 'برای وصول چک ابتدا یک حساب صندوق بسازید.'; return; } - if (active.length === 1) { - financialAccountId = active[0]!.id; - } else { - const picked = window.prompt(`نام حساب صندوق را بنویسید: ${active.map((item) => item.name).join('، ')}`); - const match = active.find((item) => item.name.trim() === picked?.trim()); - if (!match) { - error.value = 'حساب صندوق انتخاب نشد.'; - return; - } - financialAccountId = match.id; - } + clearAccounts.value = active.map((item) => ({ id: item.id, name: item.name })); + clearAccountId.value = active[0]!.id; + clearRow.value = row; + clearOpen.value = true; + return; } savingId.value = row.id; try { - const { data } = await api.patch(`/cheques/${row.id}`, { - status: next, - ...(financialAccountId ? { financialAccountId } : {}), - }); + const { data } = await api.patch(`/cheques/${row.id}`, { status: next }); items.value = items.value.map((item) => (item.id === row.id ? data : item)); } catch { error.value = 'تغییر وضعیت چک ناموفق بود.'; @@ -326,6 +337,24 @@ onMounted(load); + +

چک {{ clearRow?.number }} در کدام حساب صندوق بنشیند؟

+ + + ثبت وصول + +
+ +import { onMounted, ref } from 'vue'; +import { api } from '@/lib/api'; +import { UiPageHeader } from '@/components/ui'; +import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; +import { formatJalaliNumeric } from '@/lib/jalali'; + +type Row = { + id: string; + action: string; + entityType: string; + createdAt: string; + before: unknown; + after: unknown; +}; + +const rows = ref([]); +const error = ref(''); +const actionLabel: Record = { + settle: 'تسویه سند', + cash_post: 'نشستن وجه در حساب', +}; + +onMounted(async () => { + try { + const { data } = await api.get('/treasury/audit'); + rows.value = data; + } catch { + error.value = 'سوابق بارگذاری نشد.'; + } +}); + + + + + diff --git a/apps/web/src/views/treasury/TreasuryDocsView.vue b/apps/web/src/views/treasury/TreasuryDocsView.vue index 1ea6de0..671fcb0 100644 --- a/apps/web/src/views/treasury/TreasuryDocsView.vue +++ b/apps/web/src/views/treasury/TreasuryDocsView.vue @@ -91,6 +91,29 @@ async function createDoc() { } } +async function downloadExcel() { + const { data } = await api.get('/treasury/documents/export', { + params: { kind: props.kind }, + responseType: 'blob', + }); + const url = URL.createObjectURL(data); + const link = document.createElement('a'); + link.href = url; + link.download = props.kind === 'income' ? 'incomes.xlsx' : 'expenses.xlsx'; + link.click(); + URL.revokeObjectURL(url); +} + +async function downloadAttachment(id: string, fileName: string) { + const { data } = await api.get(`/treasury/attachments/${id}`, { responseType: 'blob' }); + const url = URL.createObjectURL(data); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + async function upload(id: string, event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -99,6 +122,7 @@ async function upload(id: string, event: Event) { try { await api.post(`/treasury/documents/${id}/attachments`, body); ok.value = 'پیوست ذخیره شد.'; + await load(); } catch (e) { error.value = apiMessage(e); } @@ -161,6 +185,7 @@ onMounted(() => { ثبت + خروجی اکسل
@@ -176,6 +201,15 @@ onMounted(() => {
{{ toman(row.amount) }} +
ساخت اقساط + خروجی اکسل
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7c02688..4128823 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5664,6 +5664,7 @@ export interface FinancialDocumentDto { reference: string | null; notes: string | null; createdAt: string; + attachments?: Array<{ id: string; fileName: string }>; } export interface FinancialMovementDto {