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:
Talal Sharabi
2026-03-11 16:57:40 +04:00
parent 18c13cdf7c
commit 4c139429e2
14 changed files with 2623 additions and 17 deletions

View 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();