feat(tenders): add Tender Management module (SRS, backend, frontend)
- SRS document: docs/SRS_TENDER_MANAGEMENT.md - Prisma: Tender, TenderDirective models; Deal.sourceTenderId; Attachment.tenderId/tenderDirectiveId - Backend: tenders module (CRUD, duplicate check, directives, notifications, file upload, convert-to-deal) - Frontend: tenders list, detail, create/edit forms, directives, convert to deal, i18n (en/ar), dashboard card - Seed: tenders permissions for admin and sales positions - Auth: admin.service findFirst for email check (Prisma compatibility) Made-with: Cursor
This commit is contained in:
538
backend/src/modules/tenders/tenders.service.ts
Normal file
538
backend/src/modules/tenders/tenders.service.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
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<string> {
|
||||
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}`;
|
||||
}
|
||||
|
||||
async findPossibleDuplicates(data: CreateTenderData): Promise<any[]> {
|
||||
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.bondValue);
|
||||
|
||||
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<TenderWithDuplicates> {
|
||||
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);
|
||||
|
||||
const tender = await prisma.tender.create({
|
||||
data: {
|
||||
tenderNumber,
|
||||
issuingBodyName: data.issuingBodyName.trim(),
|
||||
title: data.title.trim(),
|
||||
termsValue: data.termsValue,
|
||||
bondValue: data.bondValue,
|
||||
announcementDate,
|
||||
closingDate,
|
||||
announcementLink: data.announcementLink?.trim() || null,
|
||||
source: data.source,
|
||||
sourceOther: data.sourceOther?.trim() || null,
|
||||
announcementType: data.announcementType,
|
||||
notes: data.notes?.trim() || null,
|
||||
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, possibleDuplicates: possibleDuplicates.length ? possibleDuplicates : 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) 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, 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 tender;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<CreateTenderData>, 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 = {};
|
||||
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) updateData.bondValue = data.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) updateData.notes = data.notes?.trim() || null;
|
||||
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 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<string, string> = {
|
||||
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');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private async generateDealNumber(): Promise<string> {
|
||||
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();
|
||||
Reference in New Issue
Block a user