import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; import { Prisma } from '@prisma/client'; import path from 'path'; import fs from 'fs' const TENDER_SOURCE_VALUES = [ 'GOVERNMENT_SITE', 'OFFICIAL_GAZETTE', 'PERSONAL', 'PARTNER', 'WHATSAPP_TELEGRAM', 'PORTAL', 'EMAIL', 'MANUAL', ] as const; const ANNOUNCEMENT_TYPE_VALUES = [ 'FIRST', 'RE_ANNOUNCEMENT_2', 'RE_ANNOUNCEMENT_3', 'RE_ANNOUNCEMENT_4', ] as const; const DIRECTIVE_TYPE_VALUES = [ 'BUY_TERMS', 'VISIT_CLIENT', 'MEET_COMMITTEE', 'PREPARE_TO_BID', ] as const; export interface CreateTenderData { issuingBodyName: string; title: string; tenderNumber: string; termsValue: number; bondValue: number; // new extra fields stored inside notes metadata initialBondValue?: number; finalBondValue?: number; finalBondRefundPeriod?: string; siteVisitRequired?: boolean; siteVisitLocation?: string; termsPickupProvince?: string; announcementDate: string; closingDate: string; announcementLink?: string; source: string; sourceOther?: string; announcementType: string; notes?: string; contactId?: string; } export interface CreateDirectiveData { type: string; notes?: string; assignedToEmployeeId: string; } export interface TenderWithDuplicates { tender: any; possibleDuplicates?: any[]; } class TendersService { async generateTenderNumber(): Promise { const year = new Date().getFullYear(); const count = await prisma.tender.count({ where: { createdAt: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`), }, }, }); const seq = String(count + 1).padStart(5, '0'); return `TND-${year}-${seq}`; } private readonly EXTRA_META_START = '[EXTRA_TENDER_META]'; private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]'; private getCompanyTodayDate(): Date { const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Riyadh', year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(new Date()); const year = parts.find((p) => p.type === 'year')?.value; const month = parts.find((p) => p.type === 'month')?.value; const day = parts.find((p) => p.type === 'day')?.value; return new Date(`${year}-${month}-${day}T00:00:00.000Z`); } private toDateOnly(value: Date | string | null | undefined): Date | null { if (!value) return null; const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return null; const year = date.getUTCFullYear(); const month = String(date.getUTCMonth() + 1).padStart(2, '0'); const day = String(date.getUTCDate()).padStart(2, '0'); return new Date(`${year}-${month}-${day}T00:00:00.000Z`); } private getEffectiveTenderStatus(tender: { status?: string | null; closingDate?: Date | string | null; }) { if (tender.status === 'ACTIVE') { const closingDate = this.toDateOnly(tender.closingDate); const today = this.getCompanyTodayDate(); if (closingDate && closingDate < today) { return 'EXPIRED'; } } return tender.status || 'ACTIVE'; } private extractTenderExtraMeta(notes?: string | null) { if (!notes) { return { cleanNotes: '', meta: {}, }; } const start = notes.indexOf(this.EXTRA_META_START); const end = notes.indexOf(this.EXTRA_META_END); if (start === -1 || end === -1 || end < start) { return { cleanNotes: notes, meta: {}, }; } const jsonPart = notes.slice(start + this.EXTRA_META_START.length, end).trim(); const before = notes.slice(0, start).trim(); const after = notes.slice(end + this.EXTRA_META_END.length).trim(); const cleanNotes = [before, after].filter(Boolean).join('\n').trim(); try { return { cleanNotes, meta: JSON.parse(jsonPart || '{}'), }; } catch { return { cleanNotes: notes, meta: {}, }; } } async delete(id: string, userId: string) { const tender = await prisma.tender.findUnique({ where: { id }, include: { attachments: true, directives: { include: { attachments: true, }, }, convertedDeal: { select: { id: true }, }, }, }); if (!tender) { throw new AppError(404, 'Tender not found'); } if (tender.convertedDeal) { throw new AppError(400, 'Cannot delete tender that has been converted to deal'); } for (const attachment of tender.attachments || []) { if (attachment.path && fs.existsSync(attachment.path)) { fs.unlinkSync(attachment.path); } } for (const directive of tender.directives || []) { for (const attachment of directive.attachments || []) { if (attachment.path && fs.existsSync(attachment.path)) { fs.unlinkSync(attachment.path); } } } await prisma.tender.delete({ where: { id }, }); await AuditLogger.log({ entityType: 'TENDER', entityId: id, action: 'DELETE', userId, changes: { deletedTenderNumber: tender.tenderNumber, deletedTitle: tender.title, }, }); return true; } private buildTenderNotes( plainNotes?: string | null, extra?: { initialBondValue?: number | null; finalBondValue?: number | null; finalBondRefundPeriod?: string | null; siteVisitRequired?: boolean; siteVisitLocation?: string | null; termsPickupProvince?: string | null; } ) { const cleanedNotes = plainNotes?.trim() || ''; const meta = { initialBondValue: extra?.initialBondValue ?? null, finalBondValue: extra?.finalBondValue ?? null, finalBondRefundPeriod: extra?.finalBondRefundPeriod?.trim() || null, siteVisitRequired: !!extra?.siteVisitRequired, siteVisitLocation: extra?.siteVisitLocation?.trim() || null, termsPickupProvince: extra?.termsPickupProvince?.trim() || null, }; const metaBlock = `${this.EXTRA_META_START}${JSON.stringify(meta)}${this.EXTRA_META_END}`; return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; } private mapTenderExtraFields(tender: T) { const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes); return { ...tender, status: this.getEffectiveTenderStatus(tender), notes: cleanNotes || null, initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0), finalBondValue: meta.finalBondValue ?? null, finalBondRefundPeriod: meta.finalBondRefundPeriod ?? null, siteVisitRequired: !!meta.siteVisitRequired, siteVisitLocation: meta.siteVisitLocation ?? null, termsPickupProvince: meta.termsPickupProvince ?? null, }; } async findPossibleDuplicates(data: CreateTenderData): Promise { const announcementDate = data.announcementDate ? new Date(data.announcementDate) : null; const closingDate = data.closingDate ? new Date(data.closingDate) : null; const termsValue = Number(data.termsValue); const bondValue = Number(data.initialBondValue ?? data.bondValue ?? 0); const where: Prisma.TenderWhereInput = { status: { not: 'CANCELLED' }, }; const orConditions: Prisma.TenderWhereInput[] = []; if (data.issuingBodyName?.trim()) { orConditions.push({ issuingBodyName: { contains: data.issuingBodyName.trim(), mode: 'insensitive' }, }); } if (data.title?.trim()) { orConditions.push({ title: { contains: data.title.trim(), mode: 'insensitive' }, }); } if (orConditions.length) { where.OR = orConditions; } if (announcementDate) { where.announcementDate = announcementDate; } if (closingDate) { where.closingDate = closingDate; } if (termsValue != null && !isNaN(termsValue)) { where.termsValue = termsValue; } if (bondValue != null && !isNaN(bondValue)) { where.bondValue = bondValue; } const tenders = await prisma.tender.findMany({ where, take: 10, include: { createdBy: { select: { id: true, email: true, username: true } }, }, orderBy: { createdAt: 'desc' }, }); return tenders; } async create(data: CreateTenderData, userId: string): Promise { const possibleDuplicates = await this.findPossibleDuplicates(data); const existing = await prisma.tender.findUnique({ where: { tenderNumber: data.tenderNumber.trim() }, }); if (existing) { throw new AppError(400, 'Tender number already exists - رقم المناقصة موجود مسبقاً'); } const tenderNumber = data.tenderNumber.trim(); const announcementDate = new Date(data.announcementDate); const closingDate = new Date(data.closingDate); if (data.siteVisitRequired && !data.siteVisitLocation?.trim()) { throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية'); } const finalNotes = this.buildTenderNotes(data.notes, { initialBondValue: data.initialBondValue ?? data.bondValue ?? 0, finalBondValue: data.finalBondValue ?? null, finalBondRefundPeriod: data.finalBondRefundPeriod ?? null, siteVisitRequired: !!data.siteVisitRequired, siteVisitLocation: data.siteVisitRequired ? data.siteVisitLocation ?? null : null, termsPickupProvince: data.termsPickupProvince ?? null, }); const tender = await prisma.tender.create({ data: { tenderNumber, issuingBodyName: data.issuingBodyName.trim(), title: data.title.trim(), termsValue: data.termsValue, bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0), announcementDate, closingDate, announcementLink: data.announcementLink?.trim() || null, source: data.source, sourceOther: data.sourceOther?.trim() || null, announcementType: data.announcementType, notes: finalNotes, contactId: data.contactId || null, createdById: userId, }, include: { createdBy: { select: { id: true, email: true, username: true } }, contact: { select: { id: true, name: true, email: true } }, }, }); await AuditLogger.log({ entityType: 'TENDER', entityId: tender.id, action: 'CREATE', userId, }); return { tender: this.mapTenderExtraFields(tender), possibleDuplicates: possibleDuplicates.length ? possibleDuplicates.map((t) => this.mapTenderExtraFields(t)) : undefined, }; } async findAll(filters: any, page: number, pageSize: number) { const skip = (page - 1) * pageSize; const where: Prisma.TenderWhereInput = {}; if (filters.search) { where.OR = [ { tenderNumber: { contains: filters.search, mode: 'insensitive' } }, { title: { contains: filters.search, mode: 'insensitive' } }, { issuingBodyName: { contains: filters.search, mode: 'insensitive' } }, ]; } if (filters.status === 'EXPIRED') { where.status = 'ACTIVE'; where.closingDate = { lt: this.getCompanyTodayDate() }; } else if (filters.status === 'ACTIVE') { where.status = 'ACTIVE'; where.closingDate = { gte: this.getCompanyTodayDate() }; } else if (filters.status) { where.status = filters.status; } if (filters.source) where.source = filters.source; if (filters.announcementType) where.announcementType = filters.announcementType; const total = await prisma.tender.count({ where }); const tenders = await prisma.tender.findMany({ where, skip, take: pageSize, include: { createdBy: { select: { id: true, email: true, username: true } }, contact: { select: { id: true, name: true } }, _count: { select: { directives: true } }, }, orderBy: { createdAt: 'desc' }, }); return { tenders: tenders.map((t) => this.mapTenderExtraFields(t)), total, page, pageSize, }; } async findById(id: string) { const tender = await prisma.tender.findUnique({ where: { id }, include: { createdBy: { select: { id: true, email: true, username: true, employee: { select: { firstName: true, lastName: true } } } }, contact: true, directives: { include: { assignedToEmployee: { select: { id: true, firstName: true, lastName: true, email: true, user: { select: { id: true } } } }, issuedBy: { select: { id: true, email: true, username: true } }, completedBy: { select: { id: true, email: true } }, attachments: true, }, orderBy: { createdAt: 'desc' }, }, attachments: true, }, }); if (!tender) throw new AppError(404, 'Tender not found'); return this.mapTenderExtraFields(tender); } async update(id: string, data: Partial, userId: string) { const existing = await prisma.tender.findUnique({ where: { id } }); if (!existing) throw new AppError(404, 'Tender not found'); if (existing.status === 'CONVERTED_TO_DEAL') { throw new AppError(400, 'Cannot update tender that has been converted to deal'); } const updateData: Prisma.TenderUpdateInput = {}; const existingMapped = this.mapTenderExtraFields(existing as any); const mergedExtra = { initialBondValue: data.initialBondValue !== undefined ? Number(data.initialBondValue) : existingMapped.initialBondValue ?? Number(existing.bondValue ?? 0), finalBondValue: data.finalBondValue !== undefined ? Number(data.finalBondValue) : existingMapped.finalBondValue ?? null, finalBondRefundPeriod: data.finalBondRefundPeriod !== undefined ? data.finalBondRefundPeriod : existingMapped.finalBondRefundPeriod ?? null, siteVisitRequired: data.siteVisitRequired !== undefined ? !!data.siteVisitRequired : !!existingMapped.siteVisitRequired, siteVisitLocation: data.siteVisitLocation !== undefined ? data.siteVisitLocation : existingMapped.siteVisitLocation ?? null, termsPickupProvince: data.termsPickupProvince !== undefined ? data.termsPickupProvince : existingMapped.termsPickupProvince ?? null, }; if (mergedExtra.siteVisitRequired && !mergedExtra.siteVisitLocation?.trim()) { throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية'); } if (data.title !== undefined) updateData.title = data.title.trim(); if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim(); if (data.termsValue !== undefined) updateData.termsValue = data.termsValue; if (data.bondValue !== undefined || data.initialBondValue !== undefined) { updateData.bondValue = Number(data.initialBondValue ?? data.bondValue ?? existing.bondValue); } if (data.announcementDate !== undefined) updateData.announcementDate = new Date(data.announcementDate); if (data.closingDate !== undefined) updateData.closingDate = new Date(data.closingDate); if (data.announcementLink !== undefined) updateData.announcementLink = data.announcementLink?.trim() || null; if (data.source !== undefined) updateData.source = data.source; if (data.sourceOther !== undefined) updateData.sourceOther = data.sourceOther?.trim() || null; if (data.announcementType !== undefined) updateData.announcementType = data.announcementType; if ( data.notes !== undefined || data.initialBondValue !== undefined || data.finalBondValue !== undefined || data.finalBondRefundPeriod !== undefined || data.siteVisitRequired !== undefined || data.siteVisitLocation !== undefined || data.termsPickupProvince !== undefined ) { updateData.notes = this.buildTenderNotes( data.notes !== undefined ? data.notes : existingMapped.notes, { initialBondValue: mergedExtra.initialBondValue, finalBondValue: mergedExtra.finalBondValue, finalBondRefundPeriod: mergedExtra.finalBondRefundPeriod, siteVisitRequired: mergedExtra.siteVisitRequired, siteVisitLocation: mergedExtra.siteVisitRequired ? mergedExtra.siteVisitLocation : null, termsPickupProvince: mergedExtra.termsPickupProvince, } ); } if (data.contactId !== undefined) { updateData.contact = data.contactId ? { connect: { id: data.contactId } } : { disconnect: true }; } const tender = await prisma.tender.update({ where: { id }, data: updateData, include: { createdBy: { select: { id: true, email: true, username: true } }, contact: { select: { id: true, name: true } }, }, }); await AuditLogger.log({ entityType: 'TENDER', entityId: id, action: 'UPDATE', userId, changes: { before: existing, after: data }, }); return this.mapTenderExtraFields(tender); } async createDirective(tenderId: string, data: CreateDirectiveData, userId: string) { const tender = await prisma.tender.findUnique({ where: { id: tenderId }, select: { id: true, title: true, tenderNumber: true }, }); if (!tender) throw new AppError(404, 'Tender not found'); const directive = await prisma.tenderDirective.create({ data: { tenderId, type: data.type, notes: data.notes?.trim() || null, assignedToEmployeeId: data.assignedToEmployeeId, issuedById: userId, }, include: { assignedToEmployee: { select: { id: true, firstName: true, lastName: true, user: { select: { id: true } } }, }, issuedBy: { select: { id: true, email: true, username: true } }, }, }); const assignedUser = directive.assignedToEmployee?.user; if (assignedUser?.id) { const typeLabel = this.getDirectiveTypeLabel(data.type); await prisma.notification.create({ data: { userId: assignedUser.id, type: 'TENDER_DIRECTIVE_ASSIGNED', title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`, message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`, entityType: 'TENDER_DIRECTIVE', entityId: directive.id, }, }); } await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', entityId: directive.id, action: 'CREATE', userId, }); return directive; } async updateDirective( directiveId: string, data: { status?: string; completionNotes?: string }, userId: string ) { const directive = await prisma.tenderDirective.findUnique({ where: { id: directiveId }, include: { tender: true }, }); if (!directive) throw new AppError(404, 'Directive not found'); const updateData: Prisma.TenderDirectiveUpdateInput = {}; if (data.status !== undefined) updateData.status = data.status; if (data.completionNotes !== undefined) updateData.completionNotes = data.completionNotes; if (data.status === 'COMPLETED') { updateData.completedAt = new Date(); updateData.completedBy = { connect: { id: userId } }; } const updated = await prisma.tenderDirective.update({ where: { id: directiveId }, data: updateData, include: { assignedToEmployee: { select: { id: true, firstName: true, lastName: true } }, issuedBy: { select: { id: true, email: true } }, }, }); await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', entityId: directiveId, action: 'UPDATE', userId, }); return updated; } async getHistory(tenderId: string) { const tender = await prisma.tender.findUnique({ where: { id: tenderId } }); if (!tender) throw new AppError(404, 'Tender not found'); return AuditLogger.getEntityHistory('TENDER', tenderId); } async getDirectiveHistory(directiveId: string) { const dir = await prisma.tenderDirective.findUnique({ where: { id: directiveId } }); if (!dir) throw new AppError(404, 'Directive not found'); return AuditLogger.getEntityHistory('TENDER_DIRECTIVE', directiveId); } getDirectiveTypeLabel(type: string): string { const labels: Record = { BUY_TERMS: 'شراء دفتر الشروط - Buy terms booklet', VISIT_CLIENT: 'زيارة الزبون - Visit client', MEET_COMMITTEE: 'التعرف على اللجنة المختصة - Meet committee', PREPARE_TO_BID: 'الاستعداد للدخول في المناقصة - Prepare to bid', }; return labels[type] || type; } getSourceValues() { return [...TENDER_SOURCE_VALUES]; } getAnnouncementTypeValues() { return [...ANNOUNCEMENT_TYPE_VALUES]; } getDirectiveTypeValues() { return [...DIRECTIVE_TYPE_VALUES]; } async convertToDeal( tenderId: string, data: { contactId: string; pipelineId: string; ownerId?: string }, userId: string ) { const tender = await prisma.tender.findUnique({ where: { id: tenderId }, include: { contact: true }, }); if (!tender) throw new AppError(404, 'Tender not found'); if (tender.status === 'CONVERTED_TO_DEAL') { throw new AppError(400, 'Tender already converted to deal'); } if (this.getEffectiveTenderStatus(tender) === 'EXPIRED') { throw new AppError(400, 'Cannot convert expired tender to deal'); } const pipeline = await prisma.pipeline.findUnique({ where: { id: data.pipelineId }, }); if (!pipeline) throw new AppError(404, 'Pipeline not found'); const stages = (pipeline.stages as { id?: string; name?: string }[]) || []; const firstStage = stages[0]?.id || stages[0]?.name || 'OPEN'; const dealNumber = await this.generateDealNumber(); const fiscalYear = new Date().getFullYear(); const estimatedValue = Number(tender.termsValue) || Number(tender.bondValue) || 0; const deal = await prisma.deal.create({ data: { dealNumber, name: tender.title, contactId: data.contactId, structure: 'B2G', pipelineId: data.pipelineId, stage: firstStage, estimatedValue, ownerId: data.ownerId || userId, fiscalYear, currency: 'SAR', sourceTenderId: tenderId, }, include: { contact: { select: { id: true, name: true, email: true } }, owner: { select: { id: true, email: true, username: true } }, pipeline: true, }, }); await prisma.tender.update({ where: { id: tenderId }, data: { status: 'CONVERTED_TO_DEAL' }, }); await AuditLogger.log({ entityType: 'TENDER', entityId: tenderId, action: 'UPDATE', userId, changes: { status: 'CONVERTED_TO_DEAL', dealId: deal.id }, }); await AuditLogger.log({ entityType: 'DEAL', entityId: deal.id, action: 'CREATE', userId, }); return deal; } async uploadTenderAttachment( tenderId: string, file: { path: string; originalname: string; mimetype: string; size: number }, userId: string, category?: string ) { const tender = await prisma.tender.findUnique({ where: { id: tenderId } }); if (!tender) throw new AppError(404, 'Tender not found'); const fileName = path.basename(file.path); const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER', entityId: tenderId, tenderId, fileName, originalName: file.originalname, mimeType: file.mimetype, size: file.size, path: file.path, category: category || 'ANNOUNCEMENT', uploadedBy: userId, }, }); await AuditLogger.log({ entityType: 'TENDER', entityId: tenderId, action: 'UPDATE', userId, changes: { attachmentUploaded: attachment.id }, }); return attachment; } async uploadDirectiveAttachment( directiveId: string, file: { path: string; originalname: string; mimetype: string; size: number }, userId: string, category?: string ) { const directive = await prisma.tenderDirective.findUnique({ where: { id: directiveId }, select: { id: true, tenderId: true }, }); if (!directive) throw new AppError(404, 'Directive not found'); const fileName = path.basename(file.path); const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER_DIRECTIVE', entityId: directiveId, tenderDirectiveId: directiveId, tenderId: directive.tenderId, fileName, originalName: file.originalname, mimeType: file.mimetype, size: file.size, path: file.path, category: category || 'TASK_FILE', uploadedBy: userId, }, }); await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', entityId: directiveId, action: 'UPDATE', userId, changes: { attachmentUploaded: attachment.id }, }); return attachment; } async getAttachmentFile(attachmentId: string): Promise { const attachment = await prisma.attachment.findUnique({ where: { id: attachmentId }, }) if (!attachment) throw new AppError(404, 'File not found') return attachment.path } async deleteAttachment(attachmentId: string): Promise { const attachment = await prisma.attachment.findUnique({ where: { id: attachmentId }, }) if (!attachment) throw new AppError(404, 'File not found') // حذف من الديسك if (attachment.path && fs.existsSync(attachment.path)) { fs.unlinkSync(attachment.path) } // حذف من DB await prisma.attachment.delete({ where: { id: attachmentId }, }) } private async generateDealNumber(): Promise { const year = new Date().getFullYear(); const prefix = `DEAL-${year}-`; const lastDeal = await prisma.deal.findFirst({ where: { dealNumber: { startsWith: prefix } }, orderBy: { createdAt: 'desc' }, select: { dealNumber: true }, }); let nextNumber = 1; if (lastDeal) { const part = lastDeal.dealNumber.split('-')[2]; nextNumber = (parseInt(part, 10) || 0) + 1; } return `${prefix}${nextNumber.toString().padStart(6, '0')}`; } } export const tendersService = new TendersService();