213 lines
6.8 KiB
TypeScript
213 lines
6.8 KiB
TypeScript
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<InvoicePartyPosition[]> {
|
||
|
|
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<string, Bucket>();
|
||
|
|
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),
|
||
|
|
};
|
||
|
|
}
|