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), }; }