diff --git a/apps/api/src/treasury/invoice-ledger.ts b/apps/api/src/treasury/invoice-ledger.ts new file mode 100644 index 0000000..5101380 --- /dev/null +++ b/apps/api/src/treasury/invoice-ledger.ts @@ -0,0 +1,212 @@ +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const; + +export type InvoicePartyPosition = { + key: string; + partyKind: 'person' | 'company'; + partyName: string; + /** فاکتورهای عادی به‌علاوهٔ ماندهٔ اول دورهٔ مثبت */ + billed: Prisma.Decimal; + /** برگشتی‌ها و اضافه‌دریافت، برای ستون تسویه‌شدهٔ بدهکار */ + offset: Prisma.Decimal; + /** مثبت = طلب ما، منفی = بدهی ما */ + net: Prisma.Decimal; +}; + +type Bucket = { + key: string; + partyKind: 'person' | 'company'; + partyName: string; + sales: Prisma.Decimal; + credits: Prisma.Decimal; + paid: Prisma.Decimal; + opening: Prisma.Decimal; +}; + +function zero() { + return new Prisma.Decimal(0); +} + +function round2(value: Prisma.Decimal) { + return value.toDecimalPlaces(2); +} + +/** همان فرمول ماندهٔ فاکتور: جمع ردیف‌ها − تخفیف + مالیات، برگشتی منفی. */ +export function signedInvoicePayable(invoice: { + kind: string; + discountAmount: Prisma.Decimal; + taxPercent: Prisma.Decimal; + lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>; +}) { + const subtotal = invoice.lineItems.reduce( + (sum, line) => sum.plus(line.quantity.mul(line.unitPrice)), + zero(), + ); + const discount = invoice.discountAmount ?? zero(); + const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0); + const tax = taxable.mul(invoice.taxPercent ?? 0).div(100); + const signed = round2(subtotal.minus(discount).plus(tax)); + return invoice.kind === 'credit' ? signed.neg() : signed; +} + +export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) { + const connections = await prisma.accountingConnection.count({ + where: { organizationId, enabled: true }, + }); + return connections === 0; +} + +/** + * ماندهٔ طرف‌حساب از فاکتور صادرشده و ماندهٔ اول دوره. + * سند درآمد جدا ساخته نمی‌شود تا همان فروش دو بار طلب نشود. + * اگر حسابداری بیرونی وصل باشد، خالی برمی‌گردد. + */ +export async function loadInvoicePartyPositions( + prisma: PrismaService, + organizationId: string, +): Promise { + if (!(await internalBooksEnabled(prisma, organizationId))) return []; + + const [invoices, openings] = await Promise.all([ + prisma.invoice.findMany({ + where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } }, + select: { + kind: true, + discountAmount: true, + taxPercent: true, + personId: true, + companyId: true, + person: { select: { firstName: true, lastName: true } }, + company: { select: { name: true } }, + lineItems: { select: { quantity: true, unitPrice: true } }, + payments: { select: { amount: true } }, + }, + }), + prisma.partyOpeningBalance.findMany({ + where: { organizationId }, + select: { + personId: true, + amount: true, + person: { select: { firstName: true, lastName: true } }, + }, + }), + ]); + + const buckets = new Map(); + const ensure = ( + key: string, + partyKind: 'person' | 'company', + partyName: string, + ) => { + const current = buckets.get(key); + if (current) return current; + const created: Bucket = { + key, + partyKind, + partyName, + sales: zero(), + credits: zero(), + paid: zero(), + opening: zero(), + }; + buckets.set(key, created); + return created; + }; + + for (const invoice of invoices) { + const personId = invoice.personId; + const companyId = invoice.companyId; + const party = personId + ? { + key: `person:${personId}`, + partyKind: 'person' as const, + partyName: + `${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() || + 'مشتری', + } + : companyId + ? { + key: `company:${companyId}`, + partyKind: 'company' as const, + partyName: invoice.company?.name?.trim() || 'شرکت', + } + : null; + if (!party) continue; + const bucket = ensure(party.key, party.partyKind, party.partyName); + const payable = signedInvoicePayable(invoice); + if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable); + else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs()); + const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero()); + bucket.paid = bucket.paid.plus(paid); + } + + for (const opening of openings) { + if (opening.amount.equals(0)) continue; + const bucket = ensure( + `person:${opening.personId}`, + 'person', + `${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری', + ); + bucket.opening = bucket.opening.plus(opening.amount); + } + + const positions: InvoicePartyPosition[] = []; + for (const bucket of buckets.values()) { + const net = round2( + bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid), + ); + if (net.equals(0)) continue; + const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0))); + const offset = round2(billed.minus(net)); + positions.push({ + key: bucket.key, + partyKind: bucket.partyKind, + partyName: bucket.partyName, + billed, + offset: Prisma.Decimal.max(offset, 0), + net, + }); + } + return positions; +} + +/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمی‌کند. */ +export async function invoiceSalesInPeriod( + prisma: PrismaService, + organizationId: string, + from: Date | null, + to: Date | null, +) { + const empty = { sales: '0.00', credits: '0.00', net: '0.00' }; + if (!(await internalBooksEnabled(prisma, organizationId))) return empty; + + const invoices = await prisma.invoice.findMany({ + where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } }, + select: { + kind: true, + discountAmount: true, + taxPercent: true, + issuedAt: true, + createdAt: true, + lineItems: { select: { quantity: true, unitPrice: true } }, + }, + }); + + let sales = zero(); + let credits = zero(); + for (const invoice of invoices) { + const when = invoice.issuedAt ?? invoice.createdAt; + if (from && when < from) continue; + if (to && when > to) continue; + const payable = signedInvoicePayable(invoice); + if (payable.greaterThan(0)) sales = sales.plus(payable); + else if (payable.lessThan(0)) credits = credits.plus(payable.abs()); + } + return { + sales: round2(sales).toFixed(2), + credits: round2(credits).toFixed(2), + net: round2(sales.minus(credits)).toFixed(2), + }; +} diff --git a/apps/api/src/treasury/treasury-ops.service.ts b/apps/api/src/treasury/treasury-ops.service.ts index 4c1f779..a2595fc 100644 --- a/apps/api/src/treasury/treasury-ops.service.ts +++ b/apps/api/src/treasury/treasury-ops.service.ts @@ -5,6 +5,7 @@ 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); @@ -36,9 +37,15 @@ export class TreasuryOpsService { async listPayroll(actor: Actor) { const rows = await this.prisma.financialPayroll.findMany({ where: { organizationId: actor.organizationId }, - include: { movements: true }, + include: { + movements: { + where: { voidedAt: null }, + include: { account: { select: { name: true } } }, + orderBy: { occurredAt: 'desc' }, + }, + }, orderBy: { dueDate: 'desc' }, - take: 100, + take: 500, }); const users = await this.prisma.user.findMany({ where: { id: { in: rows.map((row) => row.userId) } }, @@ -88,7 +95,13 @@ export class TreasuryOpsService { notes: input.notes?.trim() || null, createdById: actor.userId, }, - include: { movements: true }, + 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 ?? ''); @@ -327,6 +340,8 @@ export class TreasuryOpsService { 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], @@ -411,10 +426,15 @@ export class TreasuryOpsService { 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.minus(bookedExpense)), + operatingProfit: money( + bookedIncome.plus(invoiceSales.net).minus(bookedExpense), + ), cashIn: money(cashIn), cashOut: money(cashOut), cashNet: money(cashIn.minus(cashOut)), @@ -480,7 +500,13 @@ export class TreasuryOpsService { payable: Prisma.Decimal; dueDate: Date; status: string; - movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>; + movements: Array<{ + id: string; + amount: Prisma.Decimal; + voidedAt: Date | null; + occurredAt: Date; + account?: { name: string } | null; + }>; }, fullName: string, ) { @@ -500,6 +526,14 @@ export class TreasuryOpsService { 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(), + accountName: item.account?.name ?? '', + })), }; } diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index b83c03e..a4952e4 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -25,6 +25,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; import type { Actor } from '../common/record-scope'; +import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger'; import { TreasuryReceivablesService } from './treasury-receivables.service'; const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها']; @@ -753,7 +754,34 @@ export class TreasuryService { current.remaining = current.remaining.plus(rawRemaining); buckets.set(key, current); } + + const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId); + for (const party of invoiceParties) { + const onDebtorSide = party.net.greaterThan(0); + if (kind === 'income' && !onDebtorSide) continue; + if (kind === 'expense' && onDebtorSide) continue; + const remaining = party.net.abs(); + const amount = onDebtorSide ? party.billed : Prisma.Decimal.max(party.billed, remaining); + const settled = Prisma.Decimal.max(amount.minus(remaining), 0); + const current = buckets.get(party.key) ?? { + key: party.key, + partyKind: party.partyKind, + partyName: party.partyName, + amount: new Prisma.Decimal(0), + settled: new Prisma.Decimal(0), + remaining: new Prisma.Decimal(0), + }; + current.amount = current.amount.plus(amount); + current.settled = current.settled.plus(settled); + current.remaining = current.remaining.plus(remaining); + if (!current.partyName || current.partyName === 'بدون طرف حساب') { + current.partyName = party.partyName; + } + buckets.set(party.key, current); + } + return [...buckets.values()] + .filter((row) => row.remaining.greaterThan(0)) .sort((a, b) => b.remaining.comparedTo(a.remaining)) .map((row) => ({ key: row.key, @@ -1092,6 +1120,17 @@ export class TreasuryService { } const bookedIncome = dec(income._sum.amount); const bookedExpense = dec(expense._sum.amount); + const invoiceSales = await invoiceSalesInPeriod( + this.prisma, + actor.organizationId, + from, + to, + ); + const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId); + for (const party of invoiceParties) { + if (party.net.greaterThan(0)) receivables = receivables.plus(party.net); + else payables = payables.plus(party.net.abs()); + } const totalBalance = accounts.reduce( (sum, account) => sum.plus(account.balance), new Prisma.Decimal(0), @@ -1101,7 +1140,7 @@ export class TreasuryService { actualReceipts: money(dec(receipts._sum.amount)), bookedExpense: money(bookedExpense), actualPayments: money(dec(payments._sum.amount)), - operatingProfit: money(bookedIncome.minus(bookedExpense)), + operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)), receivables: money(receivables), payables: money(payables), totalBalance: money(totalBalance), diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 5a271fa..fd4130c 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -330,7 +330,8 @@ export const router = createRouter({ meta: { roles: ['owner', 'admin'] as UserRole[], treasuryTitle: 'حقوق و دستمزد', - treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.', + treasuryDescription: + 'تعیین حقوق، پرداخت یکجا یا چندمرحله‌ای، و گزارش دوره‌ها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.', }, }, { @@ -350,7 +351,8 @@ export const router = createRouter({ meta: { roles: ['owner', 'admin'] as UserRole[], treasuryTitle: 'گزارش مالی', - treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.', + treasuryDescription: + 'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یک‌جا نشان می‌دهند.', }, }, { diff --git a/apps/web/src/views/treasury/TreasuryHomeView.vue b/apps/web/src/views/treasury/TreasuryHomeView.vue index 2995869..1fe5a8b 100644 --- a/apps/web/src/views/treasury/TreasuryHomeView.vue +++ b/apps/web/src/views/treasury/TreasuryHomeView.vue @@ -28,9 +28,9 @@ const cards = [ { key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' }, { key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' }, { key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' }, - { key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده' }, - { key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'درآمد بدون دریافت کامل' }, - { key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' }, + { key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد و فروش فاکتور − هزینه' }, + { key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'اسناد باز و مانده فاکتور' }, + { key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه باز، حقوق و برگشتی' }, { key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' }, ] as const; diff --git a/apps/web/src/views/treasury/TreasuryPayrollView.vue b/apps/web/src/views/treasury/TreasuryPayrollView.vue index 01a4493..857d44f 100644 --- a/apps/web/src/views/treasury/TreasuryPayrollView.vue +++ b/apps/web/src/views/treasury/TreasuryPayrollView.vue @@ -1,41 +1,60 @@