feat(finance): show invoice balances in treasury and split payroll

Issued invoices feed debtors, creditors, and period profit without a second income document. Payroll is split into setup, staged payment, and a filtered report.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-08 14:20:00 +03:30
co-authored by Cursor
parent 33f33f9521
commit 6ebd45f2cb
7 changed files with 647 additions and 167 deletions
+212
View File
@@ -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<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),
};
}
+39 -5
View File
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared'; import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { invoiceSalesInPeriod } from './invoice-ledger';
function dec(value: Prisma.Decimal | number | string | null | undefined) { function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0); return new Prisma.Decimal(value ?? 0);
@@ -36,9 +37,15 @@ export class TreasuryOpsService {
async listPayroll(actor: Actor) { async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({ const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' }, orderBy: { dueDate: 'desc' },
take: 100, take: 500,
}); });
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } }, where: { id: { in: rows.map((row) => row.userId) } },
@@ -88,7 +95,13 @@ export class TreasuryOpsService {
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, 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 } }); const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? ''); return this.payrollDto(row, user?.fullName ?? '');
@@ -327,6 +340,8 @@ export class TreasuryOpsService {
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']); sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [ const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome], ['درآمد ثبت‌شده', 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.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit], ['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn], ['ورود وجه', data.current.cashIn, data.previous.cashIn],
@@ -411,10 +426,15 @@ export class TreasuryOpsService {
amount: money(amount), amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1), share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
})); }));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return { return {
bookedIncome: money(bookedIncome), bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
),
cashIn: money(cashIn), cashIn: money(cashIn),
cashOut: money(cashOut), cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)), cashNet: money(cashIn.minus(cashOut)),
@@ -480,7 +500,13 @@ export class TreasuryOpsService {
payable: Prisma.Decimal; payable: Prisma.Decimal;
dueDate: Date; dueDate: Date;
status: string; 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, fullName: string,
) { ) {
@@ -500,6 +526,14 @@ export class TreasuryOpsService {
remaining: money(left.isNegative() ? 0 : left), remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(), dueDate: row.dueDate.toISOString(),
status: row.status, 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 ?? '',
})),
}; };
} }
+40 -1
View File
@@ -25,6 +25,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها']; const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
@@ -753,7 +754,34 @@ export class TreasuryService {
current.remaining = current.remaining.plus(rawRemaining); current.remaining = current.remaining.plus(rawRemaining);
buckets.set(key, current); 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()] return [...buckets.values()]
.filter((row) => row.remaining.greaterThan(0))
.sort((a, b) => b.remaining.comparedTo(a.remaining)) .sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({ .map((row) => ({
key: row.key, key: row.key,
@@ -1092,6 +1120,17 @@ export class TreasuryService {
} }
const bookedIncome = dec(income._sum.amount); const bookedIncome = dec(income._sum.amount);
const bookedExpense = dec(expense._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( const totalBalance = accounts.reduce(
(sum, account) => sum.plus(account.balance), (sum, account) => sum.plus(account.balance),
new Prisma.Decimal(0), new Prisma.Decimal(0),
@@ -1101,7 +1140,7 @@ export class TreasuryService {
actualReceipts: money(dec(receipts._sum.amount)), actualReceipts: money(dec(receipts._sum.amount)),
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
actualPayments: money(dec(payments._sum.amount)), actualPayments: money(dec(payments._sum.amount)),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
receivables: money(receivables), receivables: money(receivables),
payables: money(payables), payables: money(payables),
totalBalance: money(totalBalance), totalBalance: money(totalBalance),
+4 -2
View File
@@ -330,7 +330,8 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد', treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.', treasuryDescription:
'تعیین حقوق، پرداخت یکجا یا چندمرحله‌ای، و گزارش دوره‌ها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.',
}, },
}, },
{ {
@@ -350,7 +351,8 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'گزارش مالی', treasuryTitle: 'گزارش مالی',
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.', treasuryDescription:
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یک‌جا نشان می‌دهند.',
}, },
}, },
{ {
@@ -28,9 +28,9 @@ const cards = [
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' }, { key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
{ key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' }, { key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' }, { key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده' }, { key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد و فروش فاکتور − هزینه' },
{ key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'درآمد بدون دریافت کامل' }, { key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'اسناد باز و مانده فاکتور' },
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' }, { key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه باز، حقوق و برگشتی' },
{ key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' }, { key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' },
] as const; ] as const;
@@ -1,41 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
Button as AButton, Button as AButton,
Card as ACard, Card as ACard,
Col as ACol, Col as ACol,
Divider as ADivider,
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
Form as AForm, Form as AForm,
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
List as AList,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Table as ATable,
Typography, Typography,
message, message,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared'; import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; type PayrollPayment = {
const ASelectOption = ASelect.Option; id: string;
amount: string;
occurredAt: string;
accountName: string;
};
type PayrollRow = { type PayrollRow = {
id: string; id: string;
userId: string;
fullName: string; fullName: string;
period: string; period: string;
baseAmount: string;
benefits: string;
bonus: string;
deductions: string;
payable: string; payable: string;
paidAmount: string;
remaining: string; remaining: string;
status: string; status: string;
payments: PayrollPayment[];
};
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
partial: 'پرداخت جزئی',
settled: 'تسویه‌شده',
void: 'باطل',
}; };
const rows = ref<PayrollRow[]>([]); const rows = ref<PayrollRow[]>([]);
@@ -53,6 +72,85 @@ const form = ref({
deductions: '', deductions: '',
}); });
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined }); const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
const filterUserId = ref<string | undefined>(undefined);
const filterPeriod = ref('');
const filterStatus = ref('all');
const filterMin = ref('');
const filterMax = ref('');
const statusOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'open', label: 'پرداخت‌نشده' },
{ value: 'partial', label: 'پرداخت جزئی' },
{ value: 'settled', label: 'تسویه‌شده' },
{ value: 'void', label: 'باطل' },
];
const userOptions = computed(() =>
users.value.map((user) => ({ value: user.id, label: user.fullName })),
);
const payablePreview = computed(() => {
const base = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0;
const benefits = parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0;
const bonus = parseMoneyNumber(String(form.value.bonus ?? ''), 0) ?? 0;
const deductions = parseMoneyNumber(String(form.value.deductions ?? ''), 0) ?? 0;
return base + benefits + bonus - deductions;
});
const openRows = computed(() =>
rows.value.filter((row) => row.status === 'open' || row.status === 'partial'),
);
const payOptions = computed(() =>
openRows.value.map((row) => ({
value: row.id,
label: `${row.fullName} · ${row.period} · مانده ${toman(row.remaining)}`,
})),
);
const selectedPay = computed(() => rows.value.find((row) => row.id === pay.value.id) ?? null);
const filtered = computed(() => {
const period = filterPeriod.value.trim();
const min = parseMoneyNumber(String(filterMin.value ?? ''), 0);
const max = parseMoneyNumber(String(filterMax.value ?? ''), 0);
return rows.value.filter((row) => {
if (filterUserId.value && row.userId !== filterUserId.value) return false;
if (period && !row.period.includes(period)) return false;
if (filterStatus.value !== 'all' && row.status !== filterStatus.value) return false;
const payable = Number(row.payable);
if (min != null && filterMin.value !== '' && payable < min) return false;
if (max != null && filterMax.value !== '' && payable > max) return false;
return true;
});
});
const moneyKeys = ['baseAmount', 'benefits', 'bonus', 'deductions', 'payable', 'paidAmount', 'remaining'] as const;
const reportColumns: TableColumnsType<PayrollRow> = [
{ title: 'پرسنل', dataIndex: 'fullName', key: 'fullName' },
{ title: 'دوره', dataIndex: 'period', key: 'period' },
{ title: 'پایه', key: 'baseAmount' },
{ title: 'مزایا', key: 'benefits' },
{ title: 'پاداش', key: 'bonus' },
{ title: 'کسورات', key: 'deductions' },
{ title: 'قابل پرداخت', key: 'payable' },
{ title: 'پرداخت‌شده', key: 'paidAmount' },
{ title: 'مانده', key: 'remaining' },
{ title: 'وضعیت', key: 'status' },
{ title: 'عملیات', key: 'action' },
];
const paymentColumns: TableColumnsType<PayrollPayment> = [
{
title: 'تاریخ',
key: 'occurredAt',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.occurredAt)),
},
{ title: 'حساب', dataIndex: 'accountName', key: 'accountName' },
{ title: 'مبلغ', key: 'amount', customRender: ({ record }) => toman(record.amount) },
];
function apiMessage(e: unknown) { function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.'; if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
@@ -70,7 +168,7 @@ async function load() {
api.get<OrgUserDto[]>('/users'), api.get<OrgUserDto[]>('/users'),
api.get<FinancialAccountDto[]>('/treasury/accounts'), api.get<FinancialAccountDto[]>('/treasury/accounts'),
]); ]);
rows.value = payroll.data; rows.value = payroll.data.map((row) => ({ ...row, payments: row.payments ?? [] }));
users.value = people.data.filter((row) => row.isActive !== false); users.value = people.data.filter((row) => row.isActive !== false);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
} }
@@ -97,7 +195,7 @@ async function createRow() {
bonus: '', bonus: '',
deductions: '', deductions: '',
}; };
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمی‌کند.'; ok.value = 'حقوق ثبت شد. تا وقتی پرداخت نکنید موجودی حساب عوض نمی‌شود.';
await load(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
@@ -106,24 +204,44 @@ async function createRow() {
} }
} }
function selectPay(id: string) {
const row = rows.value.find((item) => item.id === id);
if (!row) {
pay.value = { id: '', amount: '', accountId: pay.value.accountId };
return;
}
pay.value = {
id: row.id,
amount: row.remaining,
accountId: pay.value.accountId ?? accounts.value[0]?.id,
};
}
async function payRow() { async function payRow() {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
saving.value = true; saving.value = true;
const accountId = pay.value.accountId;
const keepId = pay.value.id;
try { try {
const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0); const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0);
if (amount == null || amount <= 0) { if (amount == null || amount <= 0) {
error.value = 'مبلغ پرداخت معتبر نیست.'; error.value = 'مبلغ پرداخت معتبر نیست.';
return; return;
} }
await api.post(`/treasury/payroll/${pay.value.id}/pay`, { await api.post(`/treasury/payroll/${keepId}/pay`, {
amount, amount,
accountId: pay.value.accountId, accountId,
clientKey: crypto.randomUUID(), clientKey: crypto.randomUUID(),
}); });
ok.value = 'پرداخت حقوق از حساب ثبت شد.'; ok.value = 'پرداخت حقوق از حساب ثبت شد. اگر مانده مانده باشد، مرحلهٔ بعد را همین‌جا بزنید.';
pay.value = { id: '', amount: '', accountId: undefined };
await load(); await load();
const still = rows.value.find(
(row) => row.id === keepId && row.status !== 'settled' && row.status !== 'void',
);
pay.value = still
? { id: still.id, amount: still.remaining, accountId }
: { id: '', amount: '', accountId };
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
} finally { } finally {
@@ -131,14 +249,6 @@ async function payRow() {
} }
} }
function startPay(row: PayrollRow) {
pay.value = {
id: row.id,
amount: row.remaining,
accountId: accounts.value[0]?.id,
};
}
onMounted(() => { onMounted(() => {
void load().catch(() => { void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.'; error.value = 'بارگذاری حقوق ناموفق بود.';
@@ -148,143 +258,216 @@ onMounted(() => {
<template> <template>
<a-flex vertical :gap="16"> <a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" /> <a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" /> <a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false"> <a-card title="تعیین حقوق" :bordered="false">
<a-divider orientation="right">ثبت دوره</a-divider> <a-form layout="vertical" :model="form" @finish="createRow" @finishFailed="onFinishFailed">
<a-form <a-row :gutter="[16, 0]">
layout="vertical" <a-col :xs="24" :md="12">
:model="form" <a-form-item
@finish="createRow" label="پرسنل"
@finishFailed="onFinishFailed" name="userId"
> :rules="[{ required: true, message: 'پرسنل الزامی است' }]"
<a-row :gutter="[16, 0]"> >
<a-col :xs="24" :md="12"> <a-select
<a-form-item v-model:value="form.userId"
label="پرسنل" show-search
name="userId" option-filter-prop="label"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]" allow-clear
> style="width: 100%"
<a-select show-search option-filter-prop="label" placeholder="انتخاب"
v-model:value="form.userId" :options="userOptions"
allow-clear />
style="width: 100%" </a-form-item>
placeholder="انتخاب" </a-col>
> <a-col :xs="24" :md="12">
<a-select-option v-for="user in users" :key="user.id" :value="user.id" :label="user.fullName"> <a-form-item
{{ user.fullName }} label="دوره"
</a-select-option> name="period"
</a-select> :rules="[{ required: true, message: 'دوره الزامی است' }]"
</a-form-item> >
</a-col> <a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
<a-col :xs="24" :md="8"> </a-form-item>
<a-form-item </a-col>
label="دوره" <a-col :xs="24" :md="8">
name="period" <a-form-item
:rules="[{ required: true, message: 'دوره الزامی است' }]" label="پایه"
> name="baseAmount"
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" /> :rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
</a-form-item> >
</a-col> <MoneyInput v-model="form.baseAmount" :decimals="0" />
<a-col :xs="24" :md="8"> </a-form-item>
<a-form-item </a-col>
label="پایه" <a-col :xs="24" :md="8">
name="baseAmount" <a-form-item label="مزایا" name="benefits">
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]" <MoneyInput v-model="form.benefits" :decimals="0" />
> </a-form-item>
<MoneyInput v-model="form.baseAmount" :decimals="0" /> </a-col>
</a-form-item> <a-col :xs="24" :md="8">
</a-col> <a-form-item label="پاداش" name="bonus">
<a-col :xs="24" :md="8"> <MoneyInput v-model="form.bonus" :decimals="0" />
<a-form-item label="مزایا" name="benefits"> </a-form-item>
<MoneyInput v-model="form.benefits" :decimals="0" /> </a-col>
</a-form-item> <a-col :xs="24" :md="8">
</a-col> <a-form-item label="کسورات" name="deductions">
<a-col :xs="24" :md="8"> <MoneyInput v-model="form.deductions" :decimals="0" />
<a-form-item label="پاداش" name="bonus"> </a-form-item>
<MoneyInput v-model="form.bonus" :decimals="0" /> </a-col>
</a-form-item> <a-col :xs="24" :md="16">
</a-col> <a-form-item label="قابل پرداخت">
<a-col :xs="24" :md="8"> <a-typography-text strong>{{ toman(payablePreview) }}</a-typography-text>
<a-form-item label="کسورات" name="deductions"> </a-form-item>
<MoneyInput v-model="form.deductions" :decimals="0" /> </a-col>
</a-form-item> </a-row>
</a-col> <a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
</a-row> </a-form>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button> </a-card>
</a-form>
<template v-if="pay.id"> <a-card title="پرداخت" :bordered="false">
<a-divider orientation="right">پرداخت حقوق</a-divider> <a-flex vertical :gap="16">
<a-form <a-typography-text type="secondary">
layout="vertical" می‌توانید کل مانده را یکجا بدهید، یا هر بار بخشی را تا تمام شدن مانده ثبت کنید.
:model="pay" </a-typography-text>
@finish="payRow" <a-empty v-if="!openRows.length" description="حقوق باز برای پرداخت نیست." />
@finishFailed="onFinishFailed" <a-form
> v-else
<a-row :gutter="[16, 0]"> layout="vertical"
<a-col :xs="24" :md="8"> :model="pay"
<a-form-item @finish="payRow"
label="مبلغ" @finishFailed="onFinishFailed"
name="amount" >
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" <a-row :gutter="[16, 0]">
> <a-col :xs="24" :md="12">
<MoneyInput v-model="pay.amount" :decimals="0" /> <a-form-item
</a-form-item> label="دورهٔ حقوق"
</a-col> name="id"
<a-col :xs="24" :md="8"> :rules="[{ required: true, message: 'دوره الزامی است' }]"
<a-form-item >
label="حساب" <a-select
name="accountId" :value="pay.id || undefined"
:rules="[{ required: true, message: 'حساب الزامی است' }]" show-search
> option-filter-prop="label"
<a-select show-search option-filter-prop="label" style="width: 100%"
v-model:value="pay.accountId" placeholder="انتخاب پرسنل و دوره"
allow-clear :options="payOptions"
style="width: 100%" @update:value="selectPay(String($event ?? ''))"
placeholder="انتخاب حساب" />
> </a-form-item>
<a-select-option </a-col>
v-for="account in accounts" <a-col :xs="24" :md="6">
:key="account.id" <a-form-item
:value="account.id" label="مبلغ این مرحله"
:label="account.name" name="amount"
> :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
{{ account.name }} >
</a-select-option> <MoneyInput v-model="pay.amount" :decimals="0" />
</a-select> </a-form-item>
</a-form-item> </a-col>
</a-col> <a-col :xs="24" :md="6">
</a-row> <a-form-item
<a-button type="primary" html-type="submit" :loading="saving">ثبت پرداخت</a-button> label="حساب"
</a-form> name="accountId"
</template> :rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
v-model:value="pay.accountId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
:options="accounts.map((account) => ({ value: account.id, label: account.name }))"
/>
</a-form-item>
</a-col>
</a-row>
<a-flex v-if="selectedPay" :gap="16" wrap="wrap">
<a-typography-text>قابل پرداخت {{ toman(selectedPay.payable) }}</a-typography-text>
<a-typography-text>پرداخت‌شده {{ toman(selectedPay.paidAmount) }}</a-typography-text>
<a-typography-text strong>مانده {{ toman(selectedPay.remaining) }}</a-typography-text>
</a-flex>
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!pay.id">
ثبت پرداخت
</a-button>
</a-form>
<a-divider orientation="right">فهرست</a-divider> <a-table
<a-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." /> v-if="selectedPay?.payments.length"
<a-list v-else item-layout="horizontal" :data-source="rows"> :columns="paymentColumns"
<template #renderItem="{ item }"> :data-source="selectedPay.payments"
<a-list-item> :pagination="false"
<template #actions> row-key="id"
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text> />
<a-button </a-flex>
v-if="item.status !== 'settled' && item.status !== 'void'" </a-card>
type="link"
@click="startPay(item)" <a-card title="گزارش حقوق" :bordered="false">
> <a-form layout="vertical">
پرداخت <a-row :gutter="[16, 0]">
</a-button> <a-col :xs="24" :md="8">
</template> <a-form-item label="پرسنل">
<a-list-item-meta> <a-select
<template #title>{{ item.fullName }}</template> v-model:value="filterUserId"
<template #description> allow-clear
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }} show-search
</template> option-filter-prop="label"
</a-list-item-meta> style="width: 100%"
</a-list-item> placeholder="همه"
:options="userOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره">
<a-input v-model:value="filterPeriod" placeholder="مثلاً ۱۴۰۵-۰۷" allow-clear />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="وضعیت">
<a-select v-model:value="filterStatus" style="width: 100%" :options="statusOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حداقل قابل پرداخت">
<MoneyInput v-model="filterMin" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حداکثر قابل پرداخت">
<MoneyInput v-model="filterMax" :decimals="0" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-table
:columns="reportColumns"
:data-source="filtered"
row-key="id"
:scroll="{ x: 'max-content' }"
:locale="{ emptyText: 'رکوردی با این فیلتر نیست.' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
{{ statusLabel[record.status] ?? record.status }}
</template> </template>
</a-list> <template v-else-if="column.key === 'action'">
</a-card> <a-button
v-if="record.status === 'open' || record.status === 'partial'"
type="link"
@click="selectPay(record.id)"
>
پرداخت
</a-button>
</template>
<template v-else-if="moneyKeys.includes(column.key as (typeof moneyKeys)[number])">
{{ toman(record[column.key as (typeof moneyKeys)[number]]) }}
</template>
<template v-else>
{{ column.dataIndex ? record[column.dataIndex as 'fullName' | 'period'] : '' }}
</template>
</template>
</a-table>
</a-card>
</a-flex> </a-flex>
</template> </template>
@@ -28,6 +28,8 @@ const ATabPane = ATabs.TabPane;
type Period = { type Period = {
bookedIncome: string; bookedIncome: string;
invoiceSales: string;
invoiceCredits: string;
bookedExpense: string; bookedExpense: string;
operatingProfit: string; operatingProfit: string;
cashIn: string; cashIn: string;
@@ -61,6 +63,8 @@ const kindLabel: Record<string, string> = {
const lines = [ const lines = [
['درآمد ثبت‌شده', 'bookedIncome'], ['درآمد ثبت‌شده', 'bookedIncome'],
['فروش فاکتور', 'invoiceSales'],
['فاکتور برگشتی', 'invoiceCredits'],
['هزینه ثبت‌شده', 'bookedExpense'], ['هزینه ثبت‌شده', 'bookedExpense'],
['سود عملیاتی', 'operatingProfit'], ['سود عملیاتی', 'operatingProfit'],
['ورود وجه', 'cashIn'], ['ورود وجه', 'cashIn'],
@@ -79,8 +83,8 @@ const metricRows = computed<MetricRow[]>(() => {
return lines.map(([label, key]) => ({ return lines.map(([label, key]) => ({
key, key,
label, label,
current: toman(current.value![key]), current: toman(current.value![key] || '0'),
previous: toman(previous.value![key]), previous: toman(previous.value![key] || '0'),
})); }));
}); });
@@ -244,6 +248,9 @@ onMounted(() => {
<a-tab-pane key="debtors" tab="بدهکاران"> <a-tab-pane key="debtors" tab="بدهکاران">
<a-flex vertical :gap="16"> <a-flex vertical :gap="16">
<a-typography-text type="secondary">
ماندهٔ فاکتور صادرشده و اسناد دریافتِ باز. صدور فاکتور سند درآمد جدا نمی‌سازد.
</a-typography-text>
<a-flex justify="space-between" align="center"> <a-flex justify="space-between" align="center">
<a-typography-text type="secondary"> <a-typography-text type="secondary">
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }} {{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
@@ -259,6 +266,9 @@ onMounted(() => {
<a-tab-pane key="creditors" tab="بستانکاران"> <a-tab-pane key="creditors" tab="بستانکاران">
<a-flex vertical :gap="16"> <a-flex vertical :gap="16">
<a-typography-text type="secondary">
ماندهٔ فاکتور برگشتی و اسناد هزینهٔ باز. برگشتی سند هزینهٔ جدا نمی‌سازد.
</a-typography-text>
<a-flex justify="space-between" align="center"> <a-flex justify="space-between" align="center">
<a-typography-text type="secondary"> <a-typography-text type="secondary">
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }} {{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}