Let salary and payments be corrected after they are saved, pick a Jalali period as a month range, and show toman on finance amounts. Co-authored-by: Cursor <cursoragent@cursor.com>
691 lines
26 KiB
TypeScript
691 lines
26 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
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';
|
|
import { invoiceSalesInPeriod } from './invoice-ledger';
|
|
|
|
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
|
return new Prisma.Decimal(value ?? 0);
|
|
}
|
|
function money(value: Prisma.Decimal | number | string) {
|
|
return new Prisma.Decimal(value).toFixed(2);
|
|
}
|
|
function reasonText(value?: string | null) {
|
|
const text = value?.trim() ?? '';
|
|
return text ? text.slice(0, 500) : null;
|
|
}
|
|
function parseDate(iso?: string | null) {
|
|
if (!iso) return null;
|
|
const d = new Date(iso);
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
}
|
|
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
|
|
if (paid.lessThanOrEqualTo(0)) return 'open';
|
|
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
|
|
return 'partial';
|
|
}
|
|
function periodKey(date: Date) {
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
function addInterval(date: Date, interval: string) {
|
|
const next = new Date(date);
|
|
if (interval === 'weekly') next.setDate(next.getDate() + 7);
|
|
else if (interval === 'yearly') next.setFullYear(next.getFullYear() + 1);
|
|
else next.setMonth(next.getMonth() + 1);
|
|
return next;
|
|
}
|
|
|
|
@Injectable()
|
|
export class TreasuryOpsService {
|
|
private readonly logger = new Logger(TreasuryOpsService.name);
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async listPayroll(actor: Actor) {
|
|
const rows = await this.prisma.financialPayroll.findMany({
|
|
where: { organizationId: actor.organizationId },
|
|
include: {
|
|
movements: {
|
|
where: { voidedAt: null },
|
|
include: { account: { select: { name: true } } },
|
|
orderBy: { occurredAt: 'desc' },
|
|
},
|
|
},
|
|
orderBy: { dueDate: 'desc' },
|
|
take: 500,
|
|
});
|
|
const users = await this.prisma.user.findMany({
|
|
where: { id: { in: rows.map((row) => row.userId) } },
|
|
select: { id: true, fullName: true },
|
|
});
|
|
const names = new Map(users.map((user) => [user.id, user.fullName]));
|
|
return rows.map((row) => this.payrollDto(row, names.get(row.userId) ?? ''));
|
|
}
|
|
|
|
async createPayroll(
|
|
actor: Actor,
|
|
input: {
|
|
userId: string;
|
|
period: string;
|
|
baseAmount: string | number;
|
|
benefits?: string | number;
|
|
benefitsReason?: string | null;
|
|
bonus?: string | number;
|
|
bonusReason?: string | null;
|
|
deductions?: string | number;
|
|
deductionsReason?: string | null;
|
|
dueDate?: string;
|
|
notes?: string | null;
|
|
},
|
|
) {
|
|
const base = dec(input.baseAmount);
|
|
const benefits = dec(input.benefits ?? 0);
|
|
const bonus = dec(input.bonus ?? 0);
|
|
const deductions = dec(input.deductions ?? 0);
|
|
const payable = base.plus(benefits).plus(bonus).minus(deductions);
|
|
if (!input.userId || !input.period?.trim()) {
|
|
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
|
|
}
|
|
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگتر از صفر باشد.');
|
|
const member = await this.prisma.membership.findFirst({
|
|
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
|
|
});
|
|
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
|
|
const row = await this.prisma.financialPayroll.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
userId: input.userId,
|
|
period: input.period.trim(),
|
|
baseAmount: base,
|
|
benefits,
|
|
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
|
|
bonus,
|
|
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
|
|
deductions,
|
|
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
|
|
payable,
|
|
dueDate: parseDate(input.dueDate) ?? new Date(),
|
|
notes: input.notes?.trim() || null,
|
|
createdById: actor.userId,
|
|
},
|
|
include: {
|
|
movements: {
|
|
where: { voidedAt: null },
|
|
include: { account: { select: { name: true } } },
|
|
orderBy: { occurredAt: 'desc' },
|
|
},
|
|
},
|
|
});
|
|
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
|
|
return this.payrollDto(row, user?.fullName ?? '');
|
|
}
|
|
|
|
async payPayroll(
|
|
actor: Actor,
|
|
id: string,
|
|
input: { amount: string | number; accountId: string; occurredAt?: string; clientKey?: string | null },
|
|
) {
|
|
const row = await this.prisma.financialPayroll.findFirst({
|
|
where: { id, organizationId: actor.organizationId },
|
|
include: { movements: true },
|
|
});
|
|
if (!row || row.voidedAt || row.status === 'void' || row.status === 'settled') {
|
|
throw new NotFoundException('رکورد حقوق قابل پرداخت نیست.');
|
|
}
|
|
const amount = dec(input.amount);
|
|
const paid = this.paidOf(row.movements);
|
|
const remaining = row.payable.minus(paid);
|
|
if (!amount.greaterThan(0) || amount.greaterThan(remaining)) {
|
|
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
|
|
}
|
|
if (input.clientKey) {
|
|
const dup = await this.prisma.financialMovement.findFirst({
|
|
where: { payrollId: id, reference: input.clientKey, voidedAt: null },
|
|
});
|
|
if (dup) throw new BadRequestException('این پرداخت قبلاً ثبت شده است.');
|
|
}
|
|
const account = await this.prisma.financialAccount.findFirst({
|
|
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
|
});
|
|
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
|
const balance = await this.balance(account.id);
|
|
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
|
const status = payrollStatus(row.payable, paid.plus(amount));
|
|
await this.prisma.$transaction([
|
|
this.prisma.financialMovement.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
accountId: account.id,
|
|
payrollId: row.id,
|
|
direction: 'out',
|
|
amount,
|
|
kind: 'payment',
|
|
occurredAt: parseDate(input.occurredAt) ?? new Date(),
|
|
memo: `حقوق ${row.period}`,
|
|
reference: input.clientKey || null,
|
|
createdById: actor.userId,
|
|
},
|
|
}),
|
|
this.prisma.financialPayroll.update({ where: { id: row.id }, data: { status } }),
|
|
]);
|
|
return this.listPayroll(actor);
|
|
}
|
|
|
|
async updatePayroll(
|
|
actor: Actor,
|
|
id: string,
|
|
input: {
|
|
userId: string;
|
|
period: string;
|
|
baseAmount: string | number;
|
|
benefits?: string | number;
|
|
benefitsReason?: string | null;
|
|
bonus?: string | number;
|
|
bonusReason?: string | null;
|
|
deductions?: string | number;
|
|
deductionsReason?: string | null;
|
|
},
|
|
) {
|
|
const row = await this.prisma.financialPayroll.findFirst({
|
|
where: { id, organizationId: actor.organizationId },
|
|
include: { movements: true },
|
|
});
|
|
if (!row || row.voidedAt || row.status === 'void') {
|
|
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
|
|
}
|
|
const base = dec(input.baseAmount);
|
|
const benefits = dec(input.benefits ?? 0);
|
|
const bonus = dec(input.bonus ?? 0);
|
|
const deductions = dec(input.deductions ?? 0);
|
|
const payable = base.plus(benefits).plus(bonus).minus(deductions);
|
|
if (!input.userId || !input.period?.trim()) {
|
|
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
|
|
}
|
|
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگتر از صفر باشد.');
|
|
const paid = this.paidOf(row.movements);
|
|
if (payable.lessThan(paid)) {
|
|
throw new BadRequestException('مبلغ قابل پرداخت از پرداختهای ثبتشده کمتر است.');
|
|
}
|
|
const member = await this.prisma.membership.findFirst({
|
|
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
|
|
});
|
|
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
|
|
try {
|
|
await this.prisma.financialPayroll.update({
|
|
where: { id: row.id },
|
|
data: {
|
|
userId: input.userId,
|
|
period: input.period.trim(),
|
|
baseAmount: base,
|
|
benefits,
|
|
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
|
|
bonus,
|
|
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
|
|
deductions,
|
|
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
|
|
payable,
|
|
status: payrollStatus(payable, paid),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
|
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
|
|
}
|
|
throw error;
|
|
}
|
|
return this.listPayroll(actor);
|
|
}
|
|
|
|
async updatePayrollPayment(
|
|
actor: Actor,
|
|
payrollId: string,
|
|
movementId: string,
|
|
input: { amount: string | number; accountId: string; occurredAt?: string },
|
|
) {
|
|
const movement = await this.prisma.financialMovement.findFirst({
|
|
where: {
|
|
id: movementId,
|
|
payrollId,
|
|
organizationId: actor.organizationId,
|
|
voidedAt: null,
|
|
},
|
|
});
|
|
const payroll = await this.prisma.financialPayroll.findFirst({
|
|
where: { id: payrollId, organizationId: actor.organizationId },
|
|
include: { movements: true },
|
|
});
|
|
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
|
|
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
|
|
}
|
|
const amount = dec(input.amount);
|
|
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
|
|
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
|
|
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
|
|
}
|
|
const account = await this.prisma.financialAccount.findFirst({
|
|
where: { id: input.accountId, organizationId: actor.organizationId },
|
|
});
|
|
if (!account || (!account.isActive && account.id !== movement.accountId)) {
|
|
throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
|
}
|
|
const available = await this.balance(account.id);
|
|
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
|
|
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
|
await this.prisma.$transaction([
|
|
this.prisma.financialMovement.update({
|
|
where: { id: movement.id },
|
|
data: {
|
|
accountId: account.id,
|
|
amount,
|
|
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
|
|
},
|
|
}),
|
|
this.prisma.financialPayroll.update({
|
|
where: { id: payroll.id },
|
|
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
|
|
}),
|
|
]);
|
|
return this.listPayroll(actor);
|
|
}
|
|
|
|
async listRecurring(actor: Actor) {
|
|
const rows = await this.prisma.financialRecurringItem.findMany({
|
|
where: { organizationId: actor.organizationId },
|
|
orderBy: { nextDue: 'asc' },
|
|
});
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
title: row.title,
|
|
kind: row.kind,
|
|
amount: money(row.amount),
|
|
interval: row.interval,
|
|
nextDue: row.nextDue.toISOString(),
|
|
isActive: row.isActive,
|
|
}));
|
|
}
|
|
|
|
async createRecurring(
|
|
actor: Actor,
|
|
input: {
|
|
title: string;
|
|
kind: 'income' | 'expense';
|
|
amount: string | number;
|
|
interval: 'weekly' | 'monthly' | 'yearly';
|
|
startDate?: string;
|
|
endDate?: string | null;
|
|
accountId?: string | null;
|
|
categoryId?: string | null;
|
|
},
|
|
) {
|
|
const amount = dec(input.amount);
|
|
if (!input.title?.trim() || !amount.greaterThan(0)) {
|
|
throw new BadRequestException('عنوان و مبلغ تکرارشونده لازم است.');
|
|
}
|
|
const start = parseDate(input.startDate) ?? new Date();
|
|
const row = await this.prisma.financialRecurringItem.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
title: input.title.trim(),
|
|
kind: input.kind,
|
|
amount,
|
|
interval: input.interval,
|
|
startDate: start,
|
|
endDate: parseDate(input.endDate),
|
|
nextDue: start,
|
|
accountId: input.accountId || null,
|
|
categoryId: input.categoryId || null,
|
|
createdById: actor.userId,
|
|
},
|
|
});
|
|
return { id: row.id };
|
|
}
|
|
|
|
@Cron('30 8 * * *')
|
|
async spawnRecurring() {
|
|
const now = new Date();
|
|
const items = await this.prisma.financialRecurringItem.findMany({
|
|
where: { isActive: true, nextDue: { lte: now } },
|
|
take: 100,
|
|
});
|
|
for (const item of items) {
|
|
if (item.endDate && item.nextDue > item.endDate) continue;
|
|
const key = periodKey(item.nextDue);
|
|
const exists = await this.prisma.financialRecurringOccurrence.findUnique({
|
|
where: { itemId_periodKey: { itemId: item.id, periodKey: key } },
|
|
});
|
|
if (!exists) {
|
|
const doc = await this.prisma.financialDocument.create({
|
|
data: {
|
|
organizationId: item.organizationId,
|
|
kind: item.kind,
|
|
title: item.title,
|
|
amount: item.amount,
|
|
bookedAt: item.nextDue,
|
|
dueAt: item.nextDue,
|
|
categoryId: item.categoryId,
|
|
accountId: item.accountId,
|
|
status: 'open',
|
|
notes: 'ساختهشده از الگوی تکرارشونده؛ تا تأیید، موجودی حساب تغییر نمیکند.',
|
|
},
|
|
});
|
|
await this.prisma.financialRecurringOccurrence.create({
|
|
data: {
|
|
itemId: item.id,
|
|
periodKey: key,
|
|
dueDate: item.nextDue,
|
|
documentId: doc.id,
|
|
},
|
|
});
|
|
}
|
|
await this.prisma.financialRecurringItem.update({
|
|
where: { id: item.id },
|
|
data: { nextDue: addInterval(item.nextDue, item.interval) },
|
|
});
|
|
}
|
|
if (items.length) this.logger.log(`recurring finance items: ${items.length}`);
|
|
}
|
|
|
|
async reconcile(
|
|
actor: Actor,
|
|
input: { accountId: string; statedBalance: string | number; reconciledAt?: string; notes?: string | null },
|
|
) {
|
|
const account = await this.prisma.financialAccount.findFirst({
|
|
where: { id: input.accountId, organizationId: actor.organizationId },
|
|
});
|
|
if (!account) throw new NotFoundException('حساب پیدا نشد.');
|
|
const systemBalance = await this.balance(account.id);
|
|
const stated = dec(input.statedBalance);
|
|
const row = await this.prisma.financialReconciliation.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
accountId: account.id,
|
|
reconciledAt: parseDate(input.reconciledAt) ?? new Date(),
|
|
statedBalance: stated,
|
|
systemBalance,
|
|
difference: stated.minus(systemBalance),
|
|
notes: input.notes?.trim() || null,
|
|
createdById: actor.userId,
|
|
},
|
|
});
|
|
return {
|
|
id: row.id,
|
|
statedBalance: money(row.statedBalance),
|
|
systemBalance: money(row.systemBalance),
|
|
difference: money(row.difference),
|
|
};
|
|
}
|
|
|
|
async adjust(
|
|
actor: Actor,
|
|
input: { accountId: string; amount: string | number; direction: 'in' | 'out'; reason: string },
|
|
) {
|
|
const reason = input.reason?.trim();
|
|
if (!reason || reason.length < 3) throw new BadRequestException('علت اصلاح موجودی لازم است.');
|
|
const amount = dec(input.amount);
|
|
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ اصلاح باید بزرگتر از صفر باشد.');
|
|
const account = await this.prisma.financialAccount.findFirst({
|
|
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
|
});
|
|
if (!account) throw new NotFoundException('حساب پیدا نشد.');
|
|
if (input.direction === 'out') {
|
|
const balance = await this.balance(account.id);
|
|
if (balance.lessThan(amount)) throw new BadRequestException('موجودی برای این اصلاح کافی نیست.');
|
|
}
|
|
await this.prisma.financialMovement.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
accountId: account.id,
|
|
direction: input.direction,
|
|
amount,
|
|
kind: 'adjustment',
|
|
occurredAt: new Date(),
|
|
memo: `اصلاحیه: ${reason}`,
|
|
createdById: actor.userId,
|
|
},
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
async report(actor: Actor, fromIso?: string, toIso?: string) {
|
|
const to = parseDate(toIso) ?? new Date();
|
|
const from = parseDate(fromIso) ?? new Date(to.getFullYear(), to.getMonth(), 1);
|
|
const span = to.getTime() - from.getTime();
|
|
const prevTo = new Date(from.getTime() - 1);
|
|
const prevFrom = new Date(prevTo.getTime() - span);
|
|
const current = await this.period(actor.organizationId, from, to);
|
|
const previous = await this.period(actor.organizationId, prevFrom, prevTo);
|
|
const breakdown = await this.breakdown(actor.organizationId);
|
|
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
|
|
}
|
|
|
|
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('گزارش مالی');
|
|
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
|
|
const rows: Array<[string, string, string]> = [
|
|
['درآمد ثبتشده', data.current.bookedIncome, data.previous.bookedIncome],
|
|
['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales],
|
|
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
|
|
['هزینه ثبتشده', data.current.bookedExpense, data.previous.bookedExpense],
|
|
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
|
|
['ورود وجه', data.current.cashIn, data.previous.cashIn],
|
|
['خروج وجه', data.current.cashOut, data.previous.cashOut],
|
|
['خالص جریان نقدی', data.current.cashNet, data.previous.cashNet],
|
|
];
|
|
rows.forEach((row) => sheet.addRow(row));
|
|
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: {
|
|
organizationId,
|
|
status: { not: 'void' },
|
|
bookedAt: { gte: from, lte: to },
|
|
},
|
|
include: { category: true },
|
|
});
|
|
const cash = await this.prisma.financialMovement.findMany({
|
|
where: {
|
|
organizationId,
|
|
voidedAt: null,
|
|
occurredAt: { gte: from, lte: to },
|
|
kind: { in: ['receipt', 'payment'] },
|
|
},
|
|
});
|
|
let bookedIncome = new Prisma.Decimal(0);
|
|
let bookedExpense = new Prisma.Decimal(0);
|
|
const byCat = new Map<string, Prisma.Decimal>();
|
|
for (const doc of docs) {
|
|
if (doc.kind === 'income') bookedIncome = bookedIncome.plus(doc.amount);
|
|
else {
|
|
bookedExpense = bookedExpense.plus(doc.amount);
|
|
const name = doc.category?.name ?? 'بدون دسته';
|
|
byCat.set(name, (byCat.get(name) ?? new Prisma.Decimal(0)).plus(doc.amount));
|
|
}
|
|
}
|
|
let cashIn = new Prisma.Decimal(0);
|
|
let cashOut = new Prisma.Decimal(0);
|
|
for (const row of cash) {
|
|
if (row.direction === 'in') cashIn = cashIn.plus(row.amount);
|
|
else cashOut = cashOut.plus(row.amount);
|
|
}
|
|
const expenseCategories = [...byCat.entries()].map(([name, amount]) => ({
|
|
name,
|
|
amount: money(amount),
|
|
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
|
|
}));
|
|
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
|
|
return {
|
|
bookedIncome: money(bookedIncome),
|
|
invoiceSales: invoiceSales.sales,
|
|
invoiceCredits: invoiceSales.credits,
|
|
bookedExpense: money(bookedExpense),
|
|
operatingProfit: money(
|
|
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
|
|
),
|
|
cashIn: money(cashIn),
|
|
cashOut: money(cashOut),
|
|
cashNet: money(cashIn.minus(cashOut)),
|
|
expenseCategories,
|
|
};
|
|
}
|
|
|
|
private async breakdown(organizationId: string) {
|
|
const [docs, payroll, contracts] = await Promise.all([
|
|
this.prisma.financialDocument.findMany({
|
|
where: { organizationId, kind: 'expense', status: { in: ['open', 'partial'] } },
|
|
include: { movements: true },
|
|
}),
|
|
this.prisma.financialPayroll.findMany({
|
|
where: { organizationId, status: { in: ['open', 'partial'] } },
|
|
include: { movements: true },
|
|
}),
|
|
this.prisma.financialReceivable.findMany({
|
|
where: { organizationId, status: 'open' },
|
|
include: { movements: true },
|
|
}),
|
|
]);
|
|
const payablesByMonth = new Map<string, Prisma.Decimal>();
|
|
const addMonth = (date: Date, amount: Prisma.Decimal) => {
|
|
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
payablesByMonth.set(key, (payablesByMonth.get(key) ?? new Prisma.Decimal(0)).plus(amount));
|
|
};
|
|
for (const doc of docs) {
|
|
const left = doc.amount.minus(this.paidOf(doc.movements));
|
|
if (left.greaterThan(0)) addMonth(doc.dueAt ?? doc.bookedAt, left);
|
|
}
|
|
for (const row of payroll) {
|
|
const left = row.payable.minus(this.paidOf(row.movements));
|
|
if (left.greaterThan(0)) addMonth(row.dueDate, left);
|
|
}
|
|
const receivablesByParty = new Map<string, Prisma.Decimal>();
|
|
for (const row of contracts) {
|
|
const paid = this.paidOf(row.movements);
|
|
const left = row.totalAmount.minus(paid);
|
|
if (!left.greaterThan(0)) continue;
|
|
const name = row.partyName?.trim() || 'بدون نام';
|
|
receivablesByParty.set(name, (receivablesByParty.get(name) ?? new Prisma.Decimal(0)).plus(left));
|
|
}
|
|
return {
|
|
payablesByMonth: [...payablesByMonth.entries()].map(([month, amount]) => ({ month, amount: money(amount) })),
|
|
receivablesByParty: [...receivablesByParty.entries()].map(([name, amount]) => ({ name, amount: money(amount) })),
|
|
};
|
|
}
|
|
|
|
private paidOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
|
|
return movements.filter((row) => !row.voidedAt).reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
|
|
}
|
|
|
|
private payrollDto(
|
|
row: {
|
|
id: string;
|
|
userId: string;
|
|
period: string;
|
|
baseAmount: Prisma.Decimal;
|
|
benefits: Prisma.Decimal;
|
|
benefitsReason?: string | null;
|
|
bonus: Prisma.Decimal;
|
|
bonusReason?: string | null;
|
|
deductions: Prisma.Decimal;
|
|
deductionsReason?: string | null;
|
|
payable: Prisma.Decimal;
|
|
dueDate: Date;
|
|
status: string;
|
|
movements: Array<{
|
|
id: string;
|
|
amount: Prisma.Decimal;
|
|
voidedAt: Date | null;
|
|
accountId: string;
|
|
occurredAt: Date;
|
|
account?: { name: string } | null;
|
|
}>;
|
|
},
|
|
fullName: string,
|
|
) {
|
|
const paid = this.paidOf(row.movements);
|
|
const left = row.payable.minus(paid);
|
|
return {
|
|
id: row.id,
|
|
userId: row.userId,
|
|
fullName,
|
|
period: row.period,
|
|
baseAmount: money(row.baseAmount),
|
|
benefits: money(row.benefits),
|
|
benefitsReason: row.benefitsReason?.trim() || null,
|
|
bonus: money(row.bonus),
|
|
bonusReason: row.bonusReason?.trim() || null,
|
|
deductions: money(row.deductions),
|
|
deductionsReason: row.deductionsReason?.trim() || null,
|
|
payable: money(row.payable),
|
|
paidAmount: money(paid),
|
|
remaining: money(left.isNegative() ? 0 : left),
|
|
dueDate: row.dueDate.toISOString(),
|
|
status: row.status,
|
|
payments: row.movements
|
|
.filter((item) => !item.voidedAt)
|
|
.map((item) => ({
|
|
id: item.id,
|
|
amount: money(item.amount),
|
|
occurredAt: item.occurredAt.toISOString(),
|
|
accountId: item.accountId,
|
|
accountName: item.account?.name ?? '',
|
|
})),
|
|
};
|
|
}
|
|
|
|
private async balance(accountId: string) {
|
|
const rows = await this.prisma.financialMovement.findMany({
|
|
where: { accountId, voidedAt: null },
|
|
select: { direction: true, amount: true },
|
|
});
|
|
return rows.reduce(
|
|
(sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)),
|
|
new Prisma.Decimal(0),
|
|
);
|
|
}
|
|
}
|