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:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یکجا نشان میدهند.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,41 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Table as ATable,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type PayrollPayment = {
|
||||
id: string;
|
||||
amount: string;
|
||||
occurredAt: string;
|
||||
accountName: string;
|
||||
};
|
||||
|
||||
type PayrollRow = {
|
||||
id: string;
|
||||
userId: string;
|
||||
fullName: string;
|
||||
period: string;
|
||||
baseAmount: string;
|
||||
benefits: string;
|
||||
bonus: string;
|
||||
deductions: string;
|
||||
payable: string;
|
||||
paidAmount: string;
|
||||
remaining: string;
|
||||
status: string;
|
||||
payments: PayrollPayment[];
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'پرداختنشده',
|
||||
partial: 'پرداخت جزئی',
|
||||
settled: 'تسویهشده',
|
||||
void: 'باطل',
|
||||
};
|
||||
|
||||
const rows = ref<PayrollRow[]>([]);
|
||||
@@ -53,6 +72,85 @@ const form = ref({
|
||||
deductions: '',
|
||||
});
|
||||
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) {
|
||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||
@@ -70,7 +168,7 @@ async function load() {
|
||||
api.get<OrgUserDto[]>('/users'),
|
||||
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);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
@@ -97,7 +195,7 @@ async function createRow() {
|
||||
bonus: '',
|
||||
deductions: '',
|
||||
};
|
||||
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمیکند.';
|
||||
ok.value = 'حقوق ثبت شد. تا وقتی پرداخت نکنید موجودی حساب عوض نمیشود.';
|
||||
await load();
|
||||
} catch (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() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
saving.value = true;
|
||||
const accountId = pay.value.accountId;
|
||||
const keepId = pay.value.id;
|
||||
try {
|
||||
const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0);
|
||||
if (amount == null || amount <= 0) {
|
||||
error.value = 'مبلغ پرداخت معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
|
||||
await api.post(`/treasury/payroll/${keepId}/pay`, {
|
||||
amount,
|
||||
accountId: pay.value.accountId,
|
||||
accountId,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
|
||||
pay.value = { id: '', amount: '', accountId: undefined };
|
||||
ok.value = 'پرداخت حقوق از حساب ثبت شد. اگر مانده مانده باشد، مرحلهٔ بعد را همینجا بزنید.';
|
||||
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) {
|
||||
error.value = apiMessage(e);
|
||||
} 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(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری حقوق ناموفق بود.';
|
||||
@@ -148,143 +258,216 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<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="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">ثبت دوره</a-divider>
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createRow"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="پرسنل"
|
||||
name="userId"
|
||||
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
|
||||
>
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.userId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب"
|
||||
>
|
||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id" :label="user.fullName">
|
||||
{{ user.fullName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item
|
||||
label="دوره"
|
||||
name="period"
|
||||
:rules="[{ required: true, message: 'دوره الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item
|
||||
label="پایه"
|
||||
name="baseAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.baseAmount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مزایا" name="benefits">
|
||||
<MoneyInput v-model="form.benefits" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پاداش" name="bonus">
|
||||
<MoneyInput v-model="form.bonus" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="کسورات" name="deductions">
|
||||
<MoneyInput v-model="form.deductions" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
|
||||
</a-form>
|
||||
<a-card title="تعیین حقوق" :bordered="false">
|
||||
<a-form layout="vertical" :model="form" @finish="createRow" @finishFailed="onFinishFailed">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="پرسنل"
|
||||
name="userId"
|
||||
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="form.userId"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب"
|
||||
:options="userOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="دوره"
|
||||
name="period"
|
||||
:rules="[{ required: true, message: 'دوره الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item
|
||||
label="پایه"
|
||||
name="baseAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.baseAmount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مزایا" name="benefits">
|
||||
<MoneyInput v-model="form.benefits" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پاداش" name="bonus">
|
||||
<MoneyInput v-model="form.bonus" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="کسورات" name="deductions">
|
||||
<MoneyInput v-model="form.deductions" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-form-item label="قابل پرداخت">
|
||||
<a-typography-text strong>{{ toman(payablePreview) }}</a-typography-text>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<template v-if="pay.id">
|
||||
<a-divider orientation="right">پرداخت حقوق</a-divider>
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="pay"
|
||||
@finish="payRow"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item
|
||||
label="مبلغ"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="pay.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item
|
||||
label="حساب"
|
||||
name="accountId"
|
||||
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
||||
>
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="pay.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
:label="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت پرداخت</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
<a-card title="پرداخت" :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-text type="secondary">
|
||||
میتوانید کل مانده را یکجا بدهید، یا هر بار بخشی را تا تمام شدن مانده ثبت کنید.
|
||||
</a-typography-text>
|
||||
<a-empty v-if="!openRows.length" description="حقوق باز برای پرداخت نیست." />
|
||||
<a-form
|
||||
v-else
|
||||
layout="vertical"
|
||||
:model="pay"
|
||||
@finish="payRow"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="دورهٔ حقوق"
|
||||
name="id"
|
||||
:rules="[{ required: true, message: 'دوره الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
:value="pay.id || undefined"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب پرسنل و دوره"
|
||||
:options="payOptions"
|
||||
@update:value="selectPay(String($event ?? ''))"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item
|
||||
label="مبلغ این مرحله"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="pay.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item
|
||||
label="حساب"
|
||||
name="accountId"
|
||||
: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-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text>
|
||||
<a-button
|
||||
v-if="item.status !== 'settled' && item.status !== 'void'"
|
||||
type="link"
|
||||
@click="startPay(item)"
|
||||
>
|
||||
پرداخت
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.fullName }}</template>
|
||||
<template #description>
|
||||
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
<a-table
|
||||
v-if="selectedPay?.payments.length"
|
||||
:columns="paymentColumns"
|
||||
:data-source="selectedPay.payments"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-card title="گزارش حقوق" :bordered="false">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پرسنل">
|
||||
<a-select
|
||||
v-model:value="filterUserId"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
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>
|
||||
</a-list>
|
||||
</a-card>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -28,6 +28,8 @@ const ATabPane = ATabs.TabPane;
|
||||
|
||||
type Period = {
|
||||
bookedIncome: string;
|
||||
invoiceSales: string;
|
||||
invoiceCredits: string;
|
||||
bookedExpense: string;
|
||||
operatingProfit: string;
|
||||
cashIn: string;
|
||||
@@ -61,6 +63,8 @@ const kindLabel: Record<string, string> = {
|
||||
|
||||
const lines = [
|
||||
['درآمد ثبتشده', 'bookedIncome'],
|
||||
['فروش فاکتور', 'invoiceSales'],
|
||||
['فاکتور برگشتی', 'invoiceCredits'],
|
||||
['هزینه ثبتشده', 'bookedExpense'],
|
||||
['سود عملیاتی', 'operatingProfit'],
|
||||
['ورود وجه', 'cashIn'],
|
||||
@@ -79,8 +83,8 @@ const metricRows = computed<MetricRow[]>(() => {
|
||||
return lines.map(([label, key]) => ({
|
||||
key,
|
||||
label,
|
||||
current: toman(current.value![key]),
|
||||
previous: toman(previous.value![key]),
|
||||
current: toman(current.value![key] || '0'),
|
||||
previous: toman(previous.value![key] || '0'),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -244,6 +248,9 @@ onMounted(() => {
|
||||
|
||||
<a-tab-pane key="debtors" tab="بدهکاران">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-text type="secondary">
|
||||
ماندهٔ فاکتور صادرشده و اسناد دریافتِ باز. صدور فاکتور سند درآمد جدا نمیسازد.
|
||||
</a-typography-text>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text type="secondary">
|
||||
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
|
||||
@@ -259,6 +266,9 @@ onMounted(() => {
|
||||
|
||||
<a-tab-pane key="creditors" tab="بستانکاران">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-text type="secondary">
|
||||
ماندهٔ فاکتور برگشتی و اسناد هزینهٔ باز. برگشتی سند هزینهٔ جدا نمیسازد.
|
||||
</a-typography-text>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text type="secondary">
|
||||
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
|
||||
|
||||
Reference in New Issue
Block a user