diff --git a/apps/api/prisma/migrations/20261108133000_payroll_amount_reasons/migration.sql b/apps/api/prisma/migrations/20261108133000_payroll_amount_reasons/migration.sql new file mode 100644 index 0000000..6492f70 --- /dev/null +++ b/apps/api/prisma/migrations/20261108133000_payroll_amount_reasons/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT; +ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT; +ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f4afa3a..d78e2bb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -2447,8 +2447,11 @@ model FinancialPayroll { period String baseAmount Decimal @db.Decimal(18, 2) benefits Decimal @default(0) @db.Decimal(18, 2) + benefitsReason String? bonus Decimal @default(0) @db.Decimal(18, 2) + bonusReason String? deductions Decimal @default(0) @db.Decimal(18, 2) + deductionsReason String? payable Decimal @db.Decimal(18, 2) dueDate DateTime notes String? diff --git a/apps/api/src/treasury/treasury-ops.service.ts b/apps/api/src/treasury/treasury-ops.service.ts index a2595fc..8d3faba 100644 --- a/apps/api/src/treasury/treasury-ops.service.ts +++ b/apps/api/src/treasury/treasury-ops.service.ts @@ -13,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) { function money(value: Prisma.Decimal | number | string) { return new Prisma.Decimal(value).toFixed(2); } +function reasonText(value?: string | null) { + const text = value?.trim() ?? ''; + return text ? text.slice(0, 500) : null; +} function parseDate(iso?: string | null) { if (!iso) return null; const d = new Date(iso); return Number.isNaN(d.getTime()) ? null : d; } +function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) { + if (paid.lessThanOrEqualTo(0)) return 'open'; + if (paid.greaterThanOrEqualTo(payable)) return 'settled'; + return 'partial'; +} function periodKey(date: Date) { return date.toISOString().slice(0, 10); } @@ -62,8 +71,11 @@ export class TreasuryOpsService { period: string; baseAmount: string | number; benefits?: string | number; + benefitsReason?: string | null; bonus?: string | number; + bonusReason?: string | null; deductions?: string | number; + deductionsReason?: string | null; dueDate?: string; notes?: string | null; }, @@ -88,8 +100,11 @@ export class TreasuryOpsService { period: input.period.trim(), baseAmount: base, benefits, + benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null, bonus, + bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null, deductions, + deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null, payable, dueDate: parseDate(input.dueDate) ?? new Date(), notes: input.notes?.trim() || null, @@ -137,7 +152,7 @@ export class TreasuryOpsService { if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.'); const balance = await this.balance(account.id); if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); - const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial'; + const status = payrollStatus(row.payable, paid.plus(amount)); await this.prisma.$transaction([ this.prisma.financialMovement.create({ data: { @@ -158,6 +173,123 @@ export class TreasuryOpsService { return this.listPayroll(actor); } + async updatePayroll( + actor: Actor, + id: string, + input: { + userId: string; + period: string; + baseAmount: string | number; + benefits?: string | number; + benefitsReason?: string | null; + bonus?: string | number; + bonusReason?: string | null; + deductions?: string | number; + deductionsReason?: string | null; + }, + ) { + const row = await this.prisma.financialPayroll.findFirst({ + where: { id, organizationId: actor.organizationId }, + include: { movements: true }, + }); + if (!row || row.voidedAt || row.status === 'void') { + throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.'); + } + const base = dec(input.baseAmount); + const benefits = dec(input.benefits ?? 0); + const bonus = dec(input.bonus ?? 0); + const deductions = dec(input.deductions ?? 0); + const payable = base.plus(benefits).plus(bonus).minus(deductions); + if (!input.userId || !input.period?.trim()) { + throw new BadRequestException('پرسنل و دوره حقوق لازم است.'); + } + if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.'); + const paid = this.paidOf(row.movements); + if (payable.lessThan(paid)) { + throw new BadRequestException('مبلغ قابل پرداخت از پرداخت‌های ثبت‌شده کمتر است.'); + } + const member = await this.prisma.membership.findFirst({ + where: { organizationId: actor.organizationId, userId: input.userId, isActive: true }, + }); + if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.'); + try { + await this.prisma.financialPayroll.update({ + where: { id: row.id }, + data: { + userId: input.userId, + period: input.period.trim(), + baseAmount: base, + benefits, + benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null, + bonus, + bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null, + deductions, + deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null, + payable, + status: payrollStatus(payable, paid), + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.'); + } + throw error; + } + return this.listPayroll(actor); + } + + async updatePayrollPayment( + actor: Actor, + payrollId: string, + movementId: string, + input: { amount: string | number; accountId: string; occurredAt?: string }, + ) { + const movement = await this.prisma.financialMovement.findFirst({ + where: { + id: movementId, + payrollId, + organizationId: actor.organizationId, + voidedAt: null, + }, + }); + const payroll = await this.prisma.financialPayroll.findFirst({ + where: { id: payrollId, organizationId: actor.organizationId }, + include: { movements: true }, + }); + if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') { + throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.'); + } + const amount = dec(input.amount); + const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id)); + if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) { + throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.'); + } + const account = await this.prisma.financialAccount.findFirst({ + where: { id: input.accountId, organizationId: actor.organizationId }, + }); + if (!account || (!account.isActive && account.id !== movement.accountId)) { + throw new BadRequestException('حساب پرداخت پیدا نشد.'); + } + const available = await this.balance(account.id); + const room = account.id === movement.accountId ? available.plus(movement.amount) : available; + if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); + await this.prisma.$transaction([ + this.prisma.financialMovement.update({ + where: { id: movement.id }, + data: { + accountId: account.id, + amount, + occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt, + }, + }), + this.prisma.financialPayroll.update({ + where: { id: payroll.id }, + data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) }, + }), + ]); + return this.listPayroll(actor); + } + async listRecurring(actor: Actor) { const rows = await this.prisma.financialRecurringItem.findMany({ where: { organizationId: actor.organizationId }, @@ -495,8 +627,11 @@ export class TreasuryOpsService { period: string; baseAmount: Prisma.Decimal; benefits: Prisma.Decimal; + benefitsReason?: string | null; bonus: Prisma.Decimal; + bonusReason?: string | null; deductions: Prisma.Decimal; + deductionsReason?: string | null; payable: Prisma.Decimal; dueDate: Date; status: string; @@ -504,6 +639,7 @@ export class TreasuryOpsService { id: string; amount: Prisma.Decimal; voidedAt: Date | null; + accountId: string; occurredAt: Date; account?: { name: string } | null; }>; @@ -519,8 +655,11 @@ export class TreasuryOpsService { period: row.period, baseAmount: money(row.baseAmount), benefits: money(row.benefits), + benefitsReason: row.benefitsReason?.trim() || null, bonus: money(row.bonus), + bonusReason: row.bonusReason?.trim() || null, deductions: money(row.deductions), + deductionsReason: row.deductionsReason?.trim() || null, payable: money(row.payable), paidAmount: money(paid), remaining: money(left.isNegative() ? 0 : left), @@ -532,6 +671,7 @@ export class TreasuryOpsService { id: item.id, amount: money(item.amount), occurredAt: item.occurredAt.toISOString(), + accountId: item.accountId, accountName: item.account?.name ?? '', })), }; diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 92955dc..994a98a 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -232,6 +232,25 @@ export class TreasuryController { return this.ops.createPayroll(user, body); } + @Patch('payroll/:id') + updatePayroll( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() body: Parameters[2], + ) { + return this.ops.updatePayroll(user, id, body); + } + + @Patch('payroll/:id/payments/:movementId') + updatePayrollPayment( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Param('movementId') movementId: string, + @Body() body: Parameters[3], + ) { + return this.ops.updatePayrollPayment(user, id, movementId, body); + } + @Post('payroll/:id/pay') payPayroll( @CurrentUser() user: Actor, diff --git a/apps/web/src/components/JalaliDateInput.vue b/apps/web/src/components/JalaliDateInput.vue index a087c5e..e15287e 100644 --- a/apps/web/src/components/JalaliDateInput.vue +++ b/apps/web/src/components/JalaliDateInput.vue @@ -30,6 +30,10 @@ const props = withDefaults( placeholder?: string; clearable?: boolean; icon?: 'calendar' | 'clock' | 'bell'; + /** اگر داده شود، متن باکس به‌جای تاریخ خام همین مقدار است. */ + displayText?: string; + /** نمایی که با باز شدن پنل دیده می‌شود. */ + startView?: 'days' | 'months' | 'years'; }>(), { mode: 'date', @@ -37,6 +41,8 @@ const props = withDefaults( disabled: false, clearable: true, icon: undefined, + displayText: '', + startView: 'days', }, ); @@ -87,6 +93,7 @@ const triggerIcon = computed(() => { }); const display = computed(() => { + if (props.displayText) return props.displayText; if (props.mode === 'time') { const match = /^(\d{1,2}):(\d{2})$/.exec(model.value); if (!match) return ''; @@ -368,7 +375,7 @@ function onWindowChange() { async function show() { if (props.disabled) return; syncDraftFrom(parsed.value); - calView.value = 'days'; + calView.value = props.startView; clockFace.value = 'hour'; syncViewport(); liftAboveOverlays(); diff --git a/apps/web/src/components/MoneyInput.vue b/apps/web/src/components/MoneyInput.vue index ef57bc4..97fd520 100644 --- a/apps/web/src/components/MoneyInput.vue +++ b/apps/web/src/components/MoneyInput.vue @@ -22,6 +22,7 @@ const props = withDefaults( /** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */ asNumber?: boolean; dir?: 'ltr' | 'rtl'; + addonAfter?: string; }>(), { required: false, @@ -30,6 +31,7 @@ const props = withDefaults( decimals: 2, asNumber: false, dir: 'ltr', + addonAfter: '', }, ); @@ -129,6 +131,7 @@ function normalizeSep(raw: string): string { :disabled="disabled" :readonly="readonly" :value="text" + :addon-after="addonAfter || undefined" style="width: 100%; font-variant-numeric: tabular-nums" @focus="onFocus" @blur="onBlur" diff --git a/apps/web/src/views/treasury/TreasuryAccountView.vue b/apps/web/src/views/treasury/TreasuryAccountView.vue index 7b102ed..b483496 100644 --- a/apps/web/src/views/treasury/TreasuryAccountView.vue +++ b/apps/web/src/views/treasury/TreasuryAccountView.vue @@ -168,7 +168,7 @@ onUnmounted(() => { - + diff --git a/apps/web/src/views/treasury/TreasuryAccountsView.vue b/apps/web/src/views/treasury/TreasuryAccountsView.vue index 66e05d6..1a5a823 100644 --- a/apps/web/src/views/treasury/TreasuryAccountsView.vue +++ b/apps/web/src/views/treasury/TreasuryAccountsView.vue @@ -315,7 +315,7 @@ onMounted(() => { - + @@ -400,7 +400,7 @@ onMounted(() => { name="amount" :rules="[{ required: true, message: 'مبلغ الزامی است' }]" > - + diff --git a/apps/web/src/views/treasury/TreasuryDocsView.vue b/apps/web/src/views/treasury/TreasuryDocsView.vue index 671fcb0..b998549 100644 --- a/apps/web/src/views/treasury/TreasuryDocsView.vue +++ b/apps/web/src/views/treasury/TreasuryDocsView.vue @@ -158,7 +158,7 @@ onMounted(() => {
- + - +