diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index a341e2e..d9cfdc3 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -360,6 +360,7 @@ export class TreasuryService { organizationId: actor.organizationId, accountId, voidedAt: null, + kind: { not: 'allocation' }, ...(from || to ? { occurredAt: { @@ -378,6 +379,7 @@ export class TreasuryService { organizationId: actor.organizationId, accountId, voidedAt: null, + kind: { not: 'allocation' }, occurredAt: { lt: from }, }, select: { direction: true, amount: true }, @@ -531,6 +533,8 @@ export class TreasuryService { settleAmount?: string | number | null; accountId?: string | null; settledAt?: string | null; + /** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */ + settlement?: boolean; }, ): Promise { const title = input.title.trim(); @@ -561,69 +565,18 @@ export class TreasuryService { } } const bookedAt = startOfDay(input.bookedAt) ?? new Date(); - const payExisting = - party.partyKind !== 'other' && settle.equals(amount) && settle.greaterThan(0); + const settlement = input.settlement === true; + if (settlement) { + if (party.partyKind === 'other') { + throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.'); + } + if (!settle.equals(amount) || !settle.greaterThan(0)) { + throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.'); + } + if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.'); + } + const payExisting = settlement; const doc = await this.prisma.$transaction(async (tx) => { - let amountLeft = amount; - let settleLeft = settle; - let touchedId = ''; - if (payExisting && input.accountId) { - const scope = await this.accountScope(actor); - const openDocs = await tx.financialDocument.findMany({ - where: { - organizationId: actor.organizationId, - kind: input.kind, - voidedAt: null, - status: { in: ['open', 'partial'] }, - partyKind: party.partyKind, - ...(party.personId ? { personId: party.personId } : {}), - ...(party.companyId ? { companyId: party.companyId } : {}), - ...(party.userId ? { userId: party.userId } : {}), - ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), - }, - include: { movements: { select: { amount: true, voidedAt: true } } }, - orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }], - }); - for (const row of openDocs) { - if (!settleLeft.greaterThan(0)) break; - const remaining = row.amount.minus(this.settledOf(row.movements)); - if (!remaining.greaterThan(0)) continue; - const slice = settleLeft.lessThan(remaining) ? settleLeft : remaining; - const status = slice.equals(remaining) ? 'settled' : 'partial'; - await tx.financialMovement.create({ - data: { - organizationId: actor.organizationId, - accountId: input.accountId, - documentId: row.id, - direction: input.kind === 'income' ? 'in' : 'out', - amount: slice, - kind: input.kind === 'income' ? 'receipt' : 'payment', - occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, - memo: title, - createdById: actor.userId, - }, - }); - await tx.financialDocument.update({ - where: { id: row.id }, - data: { status, accountId: input.accountId }, - }); - if (status === 'settled') { - await tx.financialRecurringOccurrence.updateMany({ - where: { documentId: row.id, status: 'pending' }, - data: { status: 'confirmed' }, - }); - } - touchedId = row.id; - settleLeft = settleLeft.minus(slice); - amountLeft = amountLeft.minus(slice); - } - } - if (!amountLeft.greaterThan(0)) { - return tx.financialDocument.findUniqueOrThrow({ - where: { id: touchedId }, - include: { category: true, account: true, movements: true }, - }); - } const reference = await this.nextDocumentReference( tx, actor.organizationId, @@ -636,7 +589,7 @@ export class TreasuryService { organizationId: actor.organizationId, kind: input.kind, title, - amount: amountLeft, + amount, bookedAt, dueAt: input.dueAt ? startOfDay(input.dueAt) : null, categoryId: input.categoryId || null, @@ -647,20 +600,20 @@ export class TreasuryService { partyName: party.partyName, accountId: input.accountId || null, method: input.method || null, - status: settleLeft.equals(0) ? 'open' : settleLeft.equals(amountLeft) ? 'settled' : 'partial', + status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial', reference, notes: input.notes?.trim() || null, createdById: actor.userId, }, }); - if (settleLeft.greaterThan(0) && input.accountId) { + if (settle.greaterThan(0) && input.accountId) { await tx.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: input.accountId, documentId: created.id, direction: input.kind === 'income' ? 'in' : 'out', - amount: settleLeft, + amount: settle, kind: input.kind === 'income' ? 'receipt' : 'payment', occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, memo: title, @@ -669,6 +622,45 @@ export class TreasuryService { }, }); } + if (payExisting && input.accountId) { + const leftover = await this.allocatePartyBalance(tx, actor, { + party, + sourceId: created.id, + kind: input.kind, + amount: settle, + accountId: input.accountId, + occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, + memo: title, + }); + if (leftover.greaterThan(0)) { + const opposite = input.kind === 'income' ? 'expense' : 'income'; + const oppositeRef = await this.nextDocumentReference( + tx, + actor.organizationId, + opposite, + input.bookedAt, + bookedAt, + ); + await tx.financialDocument.create({ + data: { + organizationId: actor.organizationId, + kind: opposite, + title, + amount: leftover, + bookedAt, + personId: party.personId, + companyId: party.companyId, + userId: party.userId, + partyKind: party.partyKind, + partyName: party.partyName, + status: 'open', + reference: oppositeRef, + notes: `source:${created.id}`, + createdById: actor.userId, + }, + }); + } + } return tx.financialDocument.findUniqueOrThrow({ where: { id: created.id }, include: { category: true, account: true, movements: true }, @@ -1115,16 +1107,51 @@ export class TreasuryService { }); if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.'); const now = new Date(); - await this.prisma.$transaction([ - this.prisma.financialMovement.updateMany({ + await this.prisma.$transaction(async (tx) => { + const allocations = await tx.financialMovement.findMany({ + where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null }, + select: { documentId: true }, + }); + await tx.financialMovement.updateMany({ where: { documentId: doc.id, voidedAt: null }, data: { voidedAt: now }, - }), - this.prisma.financialDocument.update({ + }); + await tx.financialMovement.updateMany({ + where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null }, + data: { voidedAt: now }, + }); + await tx.financialDocument.update({ where: { id: doc.id }, data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' }, - }), - ]); + }); + const excess = await tx.financialDocument.findMany({ + where: { + organizationId: actor.organizationId, + notes: `source:${doc.id}`, + voidedAt: null, + status: { in: ['open', 'partial'] }, + }, + include: { movements: { select: { voidedAt: true } } }, + }); + for (const child of excess) { + if (child.movements.some((row) => !row.voidedAt)) continue; + await tx.financialDocument.update({ + where: { id: child.id }, + data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' }, + }); + } + const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))]; + for (const documentId of affected) { + const row = await tx.financialDocument.findUnique({ + where: { id: documentId }, + include: { movements: { select: { amount: true, voidedAt: true } } }, + }); + if (!row || row.voidedAt || row.status === 'void') continue; + const settled = this.settledOf(row.movements); + const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial'; + await tx.financialDocument.update({ where: { id: row.id }, data: { status } }); + } + }); const fresh = await this.prisma.financialDocument.findUniqueOrThrow({ where: { id: doc.id }, include: { category: true, account: true, movements: true }, @@ -1329,6 +1356,76 @@ export class TreasuryService { }; } + /** مبلغ قطعی را از ماندهٔ باز همان طرف کم می‌کند؛ اول سندهای هم‌نوع، بعد طرف مقابل. روی موجودی حساب اثر ندارد. */ + private async allocatePartyBalance( + tx: Prisma.TransactionClient, + actor: Actor, + input: { + party: { partyKind: string; personId: string | null; companyId: string | null; userId: string | null }; + sourceId: string; + kind: 'income' | 'expense'; + amount: Prisma.Decimal; + accountId: string; + occurredAt: Date; + memo: string; + }, + ): Promise { + const scope = await this.accountScope(actor); + const openDocs = await tx.financialDocument.findMany({ + where: { + organizationId: actor.organizationId, + id: { not: input.sourceId }, + voidedAt: null, + status: { in: ['open', 'partial'] }, + partyKind: input.party.partyKind, + ...(input.party.personId ? { personId: input.party.personId } : {}), + ...(input.party.companyId ? { companyId: input.party.companyId } : {}), + ...(input.party.userId ? { userId: input.party.userId } : {}), + ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), + }, + include: { movements: { select: { amount: true, voidedAt: true } } }, + orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }], + }); + const ordered = [ + ...openDocs.filter((row) => row.kind === input.kind), + ...openDocs.filter((row) => row.kind !== input.kind), + ]; + let left = input.amount; + for (const row of ordered) { + if (!left.greaterThan(0)) break; + const remaining = row.amount.minus(this.settledOf(row.movements)); + if (!remaining.greaterThan(0)) continue; + const slice = left.lessThan(remaining) ? left : remaining; + const status = slice.equals(remaining) ? 'settled' : 'partial'; + await tx.financialMovement.create({ + data: { + organizationId: actor.organizationId, + accountId: input.accountId, + documentId: row.id, + direction: row.kind === 'income' ? 'in' : 'out', + amount: slice, + kind: 'allocation', + occurredAt: input.occurredAt, + memo: input.memo, + reference: `alloc:${input.sourceId}`, + createdById: actor.userId, + }, + }); + await tx.financialDocument.update({ + where: { id: row.id }, + data: { status }, + }); + if (status === 'settled') { + await tx.financialRecurringOccurrence.updateMany({ + where: { documentId: row.id, status: 'pending' }, + data: { status: 'confirmed' }, + }); + } + left = left.minus(slice); + } + return left; + } + private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) { return movements .filter((row) => !row.voidedAt) @@ -1337,7 +1434,7 @@ export class TreasuryService { private async accountBalance(accountId: string) { const rows = await this.prisma.financialMovement.findMany({ - where: { accountId, voidedAt: null }, + where: { accountId, voidedAt: null, kind: { not: 'allocation' } }, select: { direction: true, amount: true }, }); return rows.reduce( @@ -1412,7 +1509,7 @@ export class TreasuryService { const from = startOfDay(range?.from); const to = startOfDay(range?.to); const movements = await this.prisma.financialMovement.findMany({ - where: { accountId: account.id, voidedAt: null }, + where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } }, select: { direction: true, amount: true, kind: true, occurredAt: true }, }); let balance = new Prisma.Decimal(0); diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue index 2fb2bb7..1fc283f 100644 --- a/apps/web/src/components/treasury/TreasuryNav.vue +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -1,5 +1,5 @@