import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CreateQuoteRequest, DealLineItemInput, PaginatedResponse, QuoteDto, UpdateQuoteRequest, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { type Actor, ownerWhere } from '../common/record-scope'; type QuoteActor = Pick; const include = { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, deal: { select: { id: true, title: true } }, owner: { select: { id: true, fullName: true } }, lineItems: { orderBy: { position: 'asc' as const } }, } satisfies Prisma.QuoteInclude; type QuoteRow = Prisma.QuoteGetPayload<{ include: typeof include }>; @Injectable() export class QuotesService { constructor(private readonly prisma: PrismaService) {} async list( organizationId: string, actor: QuoteActor, opts: { q?: string; status?: string; dealId?: string; personId?: string; companyId?: string; from?: string; to?: string; page?: number; limit?: number; }, ): Promise> { const page = opts.page ?? 1; const limit = opts.limit ?? 50; const dateFilter = buildDateRangeFilter(opts.from, opts.to); const where: Prisma.QuoteWhereInput = { organizationId, ...ownerWhere(actor), ...(opts.status ? { status: opts.status as never } : {}), ...(opts.dealId ? { dealId: opts.dealId } : {}), ...(opts.personId ? { personId: opts.personId } : {}), ...(opts.companyId ? { companyId: opts.companyId } : {}), ...(dateFilter ? dateFilter : {}), ...(opts.q?.trim() ? { OR: [ { title: { contains: opts.q.trim(), mode: 'insensitive' } }, { number: { contains: opts.q.trim(), mode: 'insensitive' } }, { notes: { contains: opts.q.trim(), mode: 'insensitive' } }, ], } : {}), }; const [rows, total] = await this.prisma.$transaction([ this.prisma.quote.findMany({ where, include, orderBy: { updatedAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), this.prisma.quote.count({ where }), ]); return { items: rows.map((row) => this.toDto(row)), total, page, limit, }; } async get( organizationId: string, id: string, actor: QuoteActor, ): Promise { const row = await this.prisma.quote.findFirst({ where: { id, organizationId, ...ownerWhere(actor) }, include, }); if (!row) throw new NotFoundException('پیش‌فاکتور پیدا نشد'); return this.toDto(row); } async create( organizationId: string, userId: string, dto: CreateQuoteRequest, ): Promise { let title = dto.title.trim(); let personId = emptyToNull(dto.personId ?? null); let companyId = emptyToNull(dto.companyId ?? null); let dealId = emptyToNull(dto.dealId ?? null); let lineItems = dto.lineItems ?? []; let currency = (dto.currency ?? 'IRR').trim() || 'IRR'; let notes = emptyToNull(dto.notes ?? null); const fromDealId = emptyToNull(dto.fromDealId ?? null); if (fromDealId) { const deal = await this.prisma.deal.findFirst({ where: { id: fromDealId, organizationId }, include: { lineItems: { orderBy: { position: 'asc' } } }, }); if (!deal) throw new BadRequestException('معامله مبدأ معتبر نیست'); dealId = deal.id; personId = personId ?? deal.personId; companyId = companyId ?? deal.companyId; if (!title || title === 'پیش‌فاکتور') { title = `پیش‌فاکتور — ${deal.title}`; } currency = deal.currency || currency; notes = notes ?? deal.notes; if (!lineItems.length && deal.lineItems.length) { lineItems = deal.lineItems.map((item) => ({ productId: item.productId, name: item.name, quantity: Number(item.quantity), unitPrice: Number(item.unitPrice), currency: item.currency, })); } } await this.assertLinks(organizationId, personId, companyId, dealId); if (lineItems.length) { await this.assertProducts(organizationId, lineItems); } const issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null; let validUntil = dto.validUntil ? new Date(dto.validUntil) : null; if ( dto.validityDays != null && Number.isFinite(dto.validityDays) && dto.validityDays > 0 && !validUntil ) { const base = issuedAt ?? new Date(); validUntil = new Date(base); validUntil.setDate(validUntil.getDate() + Math.floor(dto.validityDays)); } const number = await this.nextNumber(organizationId); const row = await this.prisma.quote.create({ data: { organizationId, number, title, status: (dto.status as never) || 'draft', currency, notes, discountAmount: new Prisma.Decimal(dto.discountAmount ?? 0), taxPercent: new Prisma.Decimal(dto.taxPercent ?? 0), issuedAt, validUntil, personId, companyId, dealId, ownerId: userId, ...(lineItems.length ? { lineItems: { create: lineItems.map((item, index) => ({ productId: emptyToNull(item.productId ?? null), name: item.name.trim(), quantity: new Prisma.Decimal(item.quantity), unitPrice: new Prisma.Decimal(item.unitPrice), currency: (item.currency ?? currency).trim() || 'IRR', position: index, })), }, } : {}), }, include, }); return this.toDto(row); } async update( organizationId: string, id: string, actor: QuoteActor, dto: UpdateQuoteRequest, ): Promise { await this.ensureExists(organizationId, id, actor); if ( 'personId' in dto || 'companyId' in dto || 'dealId' in dto ) { await this.assertLinks( organizationId, emptyToNull(dto.personId ?? null), emptyToNull(dto.companyId ?? null), emptyToNull(dto.dealId ?? null), ); } if (dto.lineItems?.length) { await this.assertProducts(organizationId, dto.lineItems); } const data: Prisma.QuoteUncheckedUpdateInput = {}; if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.status !== undefined) data.status = dto.status as never; if (dto.currency !== undefined) { data.currency = dto.currency.trim() || 'IRR'; } if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null); if (dto.discountAmount !== undefined) { data.discountAmount = new Prisma.Decimal(dto.discountAmount); } if (dto.taxPercent !== undefined) { data.taxPercent = new Prisma.Decimal(dto.taxPercent); } if ('issuedAt' in dto) { data.issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null; } if ('validUntil' in dto) { data.validUntil = dto.validUntil ? new Date(dto.validUntil) : null; } if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if (dto.lineItems) { const currency = (typeof data.currency === 'string' && data.currency) || ( await this.prisma.quote.findFirst({ where: { id }, select: { currency: true }, }) )?.currency || 'IRR'; await this.prisma.$transaction([ this.prisma.quoteLineItem.deleteMany({ where: { quoteId: id } }), this.prisma.quote.update({ where: { id }, data: { ...data, lineItems: { create: dto.lineItems.map((item, index) => ({ productId: emptyToNull(item.productId ?? null), name: item.name.trim(), quantity: new Prisma.Decimal(item.quantity), unitPrice: new Prisma.Decimal(item.unitPrice), currency: (item.currency ?? currency).trim() || 'IRR', position: index, })), }, }, }), ]); } else if (Object.keys(data).length) { await this.prisma.quote.update({ where: { id }, data }); } return this.get(organizationId, id, actor); } async remove( organizationId: string, id: string, actor: QuoteActor, ): Promise { await this.ensureExists(organizationId, id, actor); await this.prisma.quote.delete({ where: { id } }); } private async nextNumber(organizationId: string): Promise { const count = await this.prisma.quote.count({ where: { organizationId } }); return `PF-${String(count + 1).padStart(4, '0')}`; } private async ensureExists( organizationId: string, id: string, actor: QuoteActor, ) { const found = await this.prisma.quote.findFirst({ where: { id, organizationId, ...ownerWhere(actor) }, select: { id: true }, }); if (!found) throw new NotFoundException('پیش‌فاکتور پیدا نشد'); } private async assertLinks( organizationId: string, personId: string | null, companyId: string | null, dealId: string | null, ) { if (personId) { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, select: { id: true }, }); if (!person) throw new BadRequestException('شخص معتبر نیست'); } if (companyId) { const company = await this.prisma.company.findFirst({ where: { id: companyId, organizationId }, select: { id: true }, }); if (!company) throw new BadRequestException('شرکت معتبر نیست'); } if (dealId) { const deal = await this.prisma.deal.findFirst({ where: { id: dealId, organizationId }, select: { id: true }, }); if (!deal) throw new BadRequestException('معامله معتبر نیست'); } } private async assertProducts( organizationId: string, lines: DealLineItemInput[], ) { const ids = [ ...new Set( lines .map((l) => emptyToNull(l.productId ?? null)) .filter((id): id is string => Boolean(id)), ), ]; if (!ids.length) return; const count = await this.prisma.product.count({ where: { organizationId, id: { in: ids } }, }); if (count !== ids.length) { throw new BadRequestException('یکی از محصولات معتبر نیست'); } } private toDto(row: QuoteRow): QuoteDto { const lineItems = row.lineItems.map((item) => { const quantity = Number(item.quantity); const unitPrice = Number(item.unitPrice); return { id: item.id, productId: item.productId, name: item.name, quantity, unitPrice, currency: item.currency, position: item.position, lineTotal: quantity * unitPrice, }; }); const subtotal = lineItems.reduce((sum, item) => sum + item.lineTotal, 0); const discountAmount = Number(row.discountAmount ?? 0); const taxPercent = Number(row.taxPercent ?? 0); const taxAmount = roundMoney( Math.max(0, subtotal - discountAmount) * (taxPercent / 100), ); const payableAmount = roundMoney(subtotal - discountAmount + taxAmount); return { id: row.id, number: row.number, title: row.title, status: row.status, currency: row.currency, notes: row.notes, discountAmount, taxPercent, issuedAt: row.issuedAt?.toISOString() ?? null, validUntil: row.validUntil?.toISOString() ?? null, totalAmount: subtotal, subtotal, taxAmount, payableAmount, person: row.person ? { id: row.person.id, fullName: `${row.person.firstName} ${row.person.lastName}`.trim(), } : null, company: row.company, deal: row.deal, owner: row.owner, lineItems, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } } function emptyToNull(value?: string | null): string | null { if (value == null) return null; const trimmed = value.trim(); return trimmed.length ? trimmed : null; } function roundMoney(value: number): number { return Math.round((value + Number.EPSILON) * 100) / 100; } function buildDateRangeFilter( from?: string, to?: string, ): Prisma.QuoteWhereInput | null { if (!from && !to) return null; const range: Prisma.DateTimeFilter = {}; if (from) range.gte = new Date(from); if (to) range.lte = new Date(to); return { OR: [ { issuedAt: range }, { AND: [{ issuedAt: null }, { createdAt: range }] }, ], }; }