feat(treasury): list debtors and creditors on the finance report
Show open receivable and payable balances on separate report tabs, and include each list in an Excel export. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
@@ -314,7 +315,12 @@ export class TreasuryOpsService {
|
||||
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
|
||||
}
|
||||
|
||||
async reportExcel(actor: Actor, from?: string, to?: string) {
|
||||
async reportExcel(
|
||||
actor: Actor,
|
||||
from?: string,
|
||||
to?: string,
|
||||
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
|
||||
) {
|
||||
const data = await this.report(actor, from, to);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet('گزارش مالی');
|
||||
@@ -331,10 +337,41 @@ export class TreasuryOpsService {
|
||||
sheet.addRow([]);
|
||||
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
|
||||
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
|
||||
if (parties) {
|
||||
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافتشده');
|
||||
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداختشده');
|
||||
}
|
||||
const buffer = await book.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private addPartySheet(
|
||||
book: ExcelJS.Workbook,
|
||||
title: string,
|
||||
rows: TreasuryPartyBalanceDto[],
|
||||
settledLabel: string,
|
||||
) {
|
||||
const sheet = book.addWorksheet(title);
|
||||
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
|
||||
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
|
||||
let amount = new Prisma.Decimal(0);
|
||||
let settled = new Prisma.Decimal(0);
|
||||
let remaining = new Prisma.Decimal(0);
|
||||
for (const row of rows) {
|
||||
sheet.addRow([
|
||||
row.partyName,
|
||||
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
|
||||
row.amount,
|
||||
row.settledAmount,
|
||||
row.remaining,
|
||||
]);
|
||||
amount = amount.plus(row.amount);
|
||||
settled = settled.plus(row.settledAmount);
|
||||
remaining = remaining.plus(row.remaining);
|
||||
}
|
||||
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
|
||||
}
|
||||
|
||||
private async period(organizationId: string, from: Date, to: Date) {
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
|
||||
@@ -269,7 +269,11 @@ export class TreasuryController {
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
const buffer = await this.ops.reportExcel(user, from, to);
|
||||
const [debtors, creditors] = await Promise.all([
|
||||
this.treasury.partyBalances(user, 'income'),
|
||||
this.treasury.partyBalances(user, 'expense'),
|
||||
]);
|
||||
const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors });
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-report.xlsx"',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
@@ -9,16 +10,21 @@ import {
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Table as ATable,
|
||||
Tabs as ATabs,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ATabPane = ATabs.TabPane;
|
||||
|
||||
type Period = {
|
||||
bookedIncome: string;
|
||||
@@ -41,7 +47,17 @@ const current = ref<Period | null>(null);
|
||||
const previous = ref<Period | null>(null);
|
||||
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
|
||||
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
|
||||
const debtors = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const creditors = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const reportTab = ref('periods');
|
||||
const error = ref('');
|
||||
const balanceError = ref('');
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
user: 'کاربر',
|
||||
};
|
||||
|
||||
const lines = [
|
||||
['درآمد ثبتشده', 'bookedIncome'],
|
||||
@@ -81,6 +97,50 @@ async function load() {
|
||||
receivablesByParty.value = data.receivablesByParty;
|
||||
}
|
||||
|
||||
function sumRemaining(rows: TreasuryPartyBalanceDto[]) {
|
||||
return rows.reduce((sum, row) => sum + Number(row.remaining || 0), 0).toFixed(2);
|
||||
}
|
||||
|
||||
function exportParties(
|
||||
rows: TreasuryPartyBalanceDto[],
|
||||
title: string,
|
||||
settledLabel: string,
|
||||
filename: string,
|
||||
) {
|
||||
const body = rows.map((row) => [
|
||||
row.partyName,
|
||||
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
|
||||
Number(row.amount),
|
||||
Number(row.settledAmount),
|
||||
Number(row.remaining),
|
||||
]);
|
||||
const totals = rows.reduce(
|
||||
(sum, row) => ({
|
||||
amount: sum.amount + Number(row.amount || 0),
|
||||
settled: sum.settled + Number(row.settledAmount || 0),
|
||||
remaining: sum.remaining + Number(row.remaining || 0),
|
||||
}),
|
||||
{ amount: 0, settled: 0, remaining: 0 },
|
||||
);
|
||||
body.push(['جمع', '', totals.amount, totals.settled, totals.remaining]);
|
||||
const sheet = XLSX.utils.aoa_to_sheet([
|
||||
['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده'],
|
||||
...body,
|
||||
]);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, title);
|
||||
XLSX.writeFile(book, filename);
|
||||
}
|
||||
|
||||
async function loadBalances() {
|
||||
const [income, expense] = await Promise.all([
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'income' } }),
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'expense' } }),
|
||||
]);
|
||||
debtors.value = income.data;
|
||||
creditors.value = expense.data;
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
@@ -95,6 +155,14 @@ onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'گزارش بارگذاری نشد.';
|
||||
});
|
||||
void loadBalances().catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && (e.response?.status === 404 || e.response?.status === 403)) {
|
||||
debtors.value = [];
|
||||
creditors.value = [];
|
||||
return;
|
||||
}
|
||||
balanceError.value = 'مانده بدهکاران و بستانکاران بارگذاری نشد.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -108,9 +176,12 @@ onMounted(() => {
|
||||
</a-flex>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="balanceError" type="error" show-icon :message="balanceError" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">مقایسه دورهها</a-divider>
|
||||
<a-tabs v-model:activeKey="reportTab">
|
||||
<a-tab-pane key="periods" tab="مقایسه دورهها">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-table
|
||||
v-if="metricRows.length"
|
||||
:columns="metricColumns"
|
||||
@@ -168,6 +239,39 @@ onMounted(() => {
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="debtors" tab="بدهکاران">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text type="secondary">
|
||||
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
|
||||
</a-typography-text>
|
||||
<a-button @click="exportParties(debtors, 'بدهکاران', 'دریافتشده', 'debtors.xlsx')">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<TreasuryPartyBalanceTable :rows="debtors" settled-label="دریافتشده" />
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="creditors" tab="بستانکاران">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text type="secondary">
|
||||
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
|
||||
</a-typography-text>
|
||||
<a-button @click="exportParties(creditors, 'بستانکاران', 'پرداختشده', 'creditors.xlsx')">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<TreasuryPartyBalanceTable :rows="creditors" settled-label="پرداختشده" />
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user