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,87 @@
-- CreateTable
CREATE TABLE "tenders" (
"id" TEXT NOT NULL,
"tenderNumber" TEXT NOT NULL,
"issuingBodyName" TEXT NOT NULL,
"title" TEXT NOT NULL,
"termsValue" DECIMAL(15,2) NOT NULL,
"bondValue" DECIMAL(15,2) NOT NULL,
"announcementDate" DATE NOT NULL,
"closingDate" DATE NOT NULL,
"announcementLink" TEXT,
"source" TEXT NOT NULL,
"sourceOther" TEXT,
"announcementType" TEXT NOT NULL,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"contactId" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tender_directives" (
"id" TEXT NOT NULL,
"tenderId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"notes" TEXT,
"assignedToEmployeeId" TEXT NOT NULL,
"issuedById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"completedAt" TIMESTAMP(3),
"completionNotes" TEXT,
"completedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tender_directives_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "attachments" ADD COLUMN "tenderId" TEXT;
ALTER TABLE "attachments" ADD COLUMN "tenderDirectiveId" TEXT;
-- AlterTable
ALTER TABLE "deals" ADD COLUMN "sourceTenderId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "tenders_tenderNumber_key" ON "tenders"("tenderNumber");
-- CreateIndex
CREATE INDEX "tenders_tenderNumber_idx" ON "tenders"("tenderNumber");
CREATE INDEX "tenders_status_idx" ON "tenders"("status");
CREATE INDEX "tenders_createdById_idx" ON "tenders"("createdById");
CREATE INDEX "tenders_announcementDate_idx" ON "tenders"("announcementDate");
CREATE INDEX "tenders_closingDate_idx" ON "tenders"("closingDate");
-- CreateIndex
CREATE INDEX "tender_directives_tenderId_idx" ON "tender_directives"("tenderId");
CREATE INDEX "tender_directives_assignedToEmployeeId_idx" ON "tender_directives"("assignedToEmployeeId");
CREATE INDEX "tender_directives_status_idx" ON "tender_directives"("status");
-- CreateIndex
CREATE UNIQUE INDEX "deals_sourceTenderId_key" ON "deals"("sourceTenderId");
-- CreateIndex
CREATE INDEX "attachments_tenderId_idx" ON "attachments"("tenderId");
CREATE INDEX "attachments_tenderDirectiveId_idx" ON "attachments"("tenderDirectiveId");
-- AddForeignKey
ALTER TABLE "tenders" ADD CONSTRAINT "tenders_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "tenders" ADD CONSTRAINT "tenders_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tender_directives" ADD CONSTRAINT "tender_directives_tenderId_fkey" FOREIGN KEY ("tenderId") REFERENCES "tenders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "tender_directives" ADD CONSTRAINT "tender_directives_assignedToEmployeeId_fkey" FOREIGN KEY ("assignedToEmployeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "tender_directives" ADD CONSTRAINT "tender_directives_issuedById_fkey" FOREIGN KEY ("issuedById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "tender_directives" ADD CONSTRAINT "tender_directives_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deals" ADD CONSTRAINT "deals_sourceTenderId_fkey" FOREIGN KEY ("sourceTenderId") REFERENCES "tenders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_tenderId_fkey" FOREIGN KEY ("tenderId") REFERENCES "tenders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_tenderDirectiveId_fkey" FOREIGN KEY ("tenderDirectiveId") REFERENCES "tender_directives"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -70,7 +70,10 @@ model User {
projectMembers ProjectMember[]
campaigns Campaign[]
userRoles UserRole[]
tendersCreated Tender[]
tenderDirectivesIssued TenderDirective[]
tenderDirectivesCompleted TenderDirective[] @relation("TenderDirectiveCompletedBy")
@@map("users")
}
@@ -199,7 +202,8 @@ model Employee {
purchaseRequests PurchaseRequest[]
leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[]
tenderDirectivesAssigned TenderDirective[]
@@index([departmentId])
@@index([positionId])
@@index([status])
@@ -610,7 +614,8 @@ model Contact {
deals Deal[]
attachments Attachment[]
notes Note[]
tenders Tender[]
@@index([type])
@@index([status])
@@index([email])
@@ -705,10 +710,14 @@ model Deal {
// Status
status String @default("ACTIVE") // ACTIVE, WON, LOST, CANCELLED, ARCHIVED
// Source (when converted from Tender)
sourceTenderId String? @unique
sourceTender Tender? @relation(fields: [sourceTenderId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
quotes Quote[]
costSheets CostSheet[]
@@ -718,7 +727,7 @@ model Deal {
contracts Contract[]
invoices Invoice[]
commissions Commission[]
@@index([contactId])
@@index([ownerId])
@@index([pipelineId])
@@ -873,6 +882,66 @@ model Invoice {
@@map("invoices")
}
// ============================================
// TENDER MANAGEMENT - إدارة المناقصات
// ============================================
model Tender {
id String @id @default(uuid())
tenderNumber String @unique
issuingBodyName String
title String
termsValue Decimal @db.Decimal(15, 2)
bondValue Decimal @db.Decimal(15, 2)
announcementDate DateTime @db.Date
closingDate DateTime @db.Date
announcementLink String?
source String // GOVERNMENT_SITE, OFFICIAL_GAZETTE, PERSONAL, PARTNER, WHATSAPP_TELEGRAM, PORTAL, EMAIL, MANUAL
sourceOther String? // Free text when source is MANUAL or other
announcementType String // FIRST, RE_ANNOUNCEMENT_2, RE_ANNOUNCEMENT_3, RE_ANNOUNCEMENT_4
notes String?
status String @default("ACTIVE") // ACTIVE, CONVERTED_TO_DEAL, CANCELLED
contactId String? // Optional link to Contact (issuing body)
contact Contact? @relation(fields: [contactId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
directives TenderDirective[]
attachments Attachment[]
convertedDeal Deal?
@@index([tenderNumber])
@@index([status])
@@index([createdById])
@@index([announcementDate])
@@index([closingDate])
@@map("tenders")
}
model TenderDirective {
id String @id @default(uuid())
tenderId String
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
type String // BUY_TERMS, VISIT_CLIENT, MEET_COMMITTEE, PREPARE_TO_BID
notes String?
assignedToEmployeeId String
assignedToEmployee Employee @relation(fields: [assignedToEmployeeId], references: [id])
issuedById String
issuedBy User @relation(fields: [issuedById], references: [id])
status String @default("PENDING") // PENDING, IN_PROGRESS, COMPLETED, CANCELLED
completedAt DateTime?
completionNotes String?
completedById String?
completedBy User? @relation("TenderDirectiveCompletedBy", fields: [completedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attachments Attachment[]
@@index([tenderId])
@@index([assignedToEmployeeId])
@@index([status])
@@map("tender_directives")
}
// ============================================
// MODULE 3: INVENTORY & ASSETS
// ============================================
@@ -1420,11 +1489,11 @@ model Note {
model Attachment {
id String @id @default(uuid())
// Related Entity
entityType String
entityId String
// Relations
contactId String?
contact Contact? @relation(fields: [contactId], references: [id])
@@ -1434,7 +1503,11 @@ model Attachment {
project Project? @relation(fields: [projectId], references: [id])
taskId String?
task Task? @relation(fields: [taskId], references: [id])
tenderId String?
tender Tender? @relation(fields: [tenderId], references: [id], onDelete: Cascade)
tenderDirectiveId String?
tenderDirective TenderDirective? @relation(fields: [tenderDirectiveId], references: [id], onDelete: Cascade)
// File Info
fileName String
originalName String
@@ -1442,19 +1515,21 @@ model Attachment {
size Int
path String
url String?
// Metadata
description String?
category String?
uploadedBy String
uploadedAt DateTime @default(now())
@@index([entityType, entityId])
@@index([contactId])
@@index([dealId])
@@index([projectId])
@@index([taskId])
@@index([tenderId])
@@index([tenderDirectiveId])
@@map("attachments")
}

View File

@@ -30,7 +30,7 @@ async function main() {
},
});
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
const modules = ['contacts', 'crm', 'tenders', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
for (const module of modules) {
await prisma.positionPermission.create({
data: {
@@ -67,6 +67,8 @@ async function main() {
data: [
{ positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'tenders', resource: 'tenders', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'tenders', resource: 'directives', actions: ['read', 'create', 'update'] },
],
});

View File

@@ -148,7 +148,7 @@ class AdminService {
throw new AppError(400, 'هذا الموظف مرتبط بحساب مستخدم بالفعل - Employee already has a user account');
}
const emailExists = await prisma.user.findUnique({
const emailExists = await prisma.user.findFirst({
where: { email: data.email },
});
if (emailExists) {
@@ -206,7 +206,7 @@ class AdminService {
}
if (data.email && data.email !== existing.email) {
const emailExists = await prisma.user.findUnique({ where: { email: data.email } });
const emailExists = await prisma.user.findFirst({ where: { email: data.email } });
if (emailExists) {
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
}

View File

@@ -0,0 +1,232 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { tendersService } from './tenders.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class TendersController {
async checkDuplicates(req: AuthRequest, res: Response, next: NextFunction) {
try {
const duplicates = await tendersService.findPossibleDuplicates(req.body);
res.json(ResponseFormatter.success(duplicates));
} catch (error) {
next(error);
}
}
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await tendersService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(
result,
'تم إنشاء المناقصة بنجاح - Tender created successfully'
)
);
} catch (error) {
next(error);
}
}
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
const filters = {
search: req.query.search,
status: req.query.status,
source: req.query.source,
announcementType: req.query.announcementType,
};
const result = await tendersService.findAll(filters, page, pageSize);
res.json(
ResponseFormatter.paginated(
result.tenders,
result.total,
result.page,
result.pageSize
)
);
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const tender = await tendersService.findById(req.params.id);
res.json(ResponseFormatter.success(tender));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const tender = await tendersService.update(
req.params.id,
req.body,
req.user!.id
);
res.json(
ResponseFormatter.success(
tender,
'تم تحديث المناقصة بنجاح - Tender updated successfully'
)
);
} catch (error) {
next(error);
}
}
async createDirective(req: AuthRequest, res: Response, next: NextFunction) {
try {
const directive = await tendersService.createDirective(
req.params.id,
req.body,
req.user!.id
);
res.status(201).json(
ResponseFormatter.success(
directive,
'تم إصدار التوجيه بنجاح - Directive created successfully'
)
);
} catch (error) {
next(error);
}
}
async updateDirective(req: AuthRequest, res: Response, next: NextFunction) {
try {
const directive = await tendersService.updateDirective(
req.params.directiveId,
req.body,
req.user!.id
);
res.json(
ResponseFormatter.success(
directive,
'تم تحديث التوجيه بنجاح - Directive updated successfully'
)
);
} catch (error) {
next(error);
}
}
async getHistory(req: AuthRequest, res: Response, next: NextFunction) {
try {
const history = await tendersService.getHistory(req.params.id);
res.json(ResponseFormatter.success(history));
} catch (error) {
next(error);
}
}
async convertToDeal(req: AuthRequest, res: Response, next: NextFunction) {
try {
const deal = await tendersService.convertToDeal(
req.params.id,
{
contactId: req.body.contactId,
pipelineId: req.body.pipelineId,
ownerId: req.body.ownerId,
},
req.user!.id
);
res.status(201).json(
ResponseFormatter.success(
deal,
'تم تحويل المناقصة إلى فرصة بنجاح - Tender converted to deal successfully'
)
);
} catch (error) {
next(error);
}
}
async getSourceValues(_req: AuthRequest, res: Response, next: NextFunction) {
try {
const values = tendersService.getSourceValues();
res.json(ResponseFormatter.success(values));
} catch (error) {
next(error);
}
}
async getAnnouncementTypeValues(
_req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const values = tendersService.getAnnouncementTypeValues();
res.json(ResponseFormatter.success(values));
} catch (error) {
next(error);
}
}
async getDirectiveTypeValues(
_req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const values = tendersService.getDirectiveTypeValues();
res.json(ResponseFormatter.success(values));
} catch (error) {
next(error);
}
}
async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) {
try {
if (!req.file) {
return res.status(400).json(
ResponseFormatter.error('No file uploaded', 'Missing file')
);
}
const attachment = await tendersService.uploadTenderAttachment(
req.params.id,
req.file,
req.user!.id,
(req.body.category as string) || undefined
);
res.status(201).json(
ResponseFormatter.success(
attachment,
'تم رفع الملف بنجاح - File uploaded successfully'
)
);
} catch (error) {
next(error);
}
}
async uploadDirectiveAttachment(req: AuthRequest, res: Response, next: NextFunction) {
try {
if (!req.file) {
return res.status(400).json(
ResponseFormatter.error('No file uploaded', 'Missing file')
);
}
const attachment = await tendersService.uploadDirectiveAttachment(
req.params.directiveId,
req.file,
req.user!.id,
(req.body.category as string) || undefined
);
res.status(201).json(
ResponseFormatter.success(
attachment,
'تم رفع الملف بنجاح - File uploaded successfully'
)
);
} catch (error) {
next(error);
}
}
}
export const tendersController = new TendersController();

View File

@@ -0,0 +1,175 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { tendersController } from './tenders.controller';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
import { config } from '../../config';
const router = Router();
const uploadDir = path.join(config.upload.path, 'tenders');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, `${crypto.randomUUID()}-${safeName}`);
},
});
const upload = multer({
storage,
limits: { fileSize: config.upload.maxFileSize },
});
router.use(authenticate);
// Enum/lookup routes (no resource id) - place before /:id routes
router.get(
'/source-values',
authorize('tenders', 'tenders', 'read'),
tendersController.getSourceValues
);
router.get(
'/announcement-type-values',
authorize('tenders', 'tenders', 'read'),
tendersController.getAnnouncementTypeValues
);
router.get(
'/directive-type-values',
authorize('tenders', 'tenders', 'read'),
tendersController.getDirectiveTypeValues
);
router.post(
'/check-duplicates',
authorize('tenders', 'tenders', 'create'),
[
body('issuingBodyName').optional().trim(),
body('title').optional().trim(),
body('tenderNumber').optional().trim(),
body('termsValue').optional().isNumeric(),
body('bondValue').optional().isNumeric(),
body('announcementDate').optional().isISO8601(),
body('closingDate').optional().isISO8601(),
],
validate,
tendersController.checkDuplicates
);
// List & create tenders
router.get(
'/',
authorize('tenders', 'tenders', 'read'),
tendersController.findAll
);
router.post(
'/',
authorize('tenders', 'tenders', 'create'),
[
body('tenderNumber').notEmpty().trim(),
body('issuingBodyName').notEmpty().trim(),
body('title').notEmpty().trim(),
body('termsValue').isNumeric(),
body('bondValue').isNumeric(),
body('announcementDate').isISO8601(),
body('closingDate').isISO8601(),
body('source').notEmpty(),
body('announcementType').notEmpty(),
],
validate,
tendersController.create
);
// Tender by id
router.get(
'/:id',
authorize('tenders', 'tenders', 'read'),
param('id').isUUID(),
validate,
tendersController.findById
);
router.put(
'/:id',
authorize('tenders', 'tenders', 'update'),
param('id').isUUID(),
validate,
tendersController.update
);
// Tender history
router.get(
'/:id/history',
authorize('tenders', 'tenders', 'read'),
param('id').isUUID(),
validate,
tendersController.getHistory
);
// Convert to deal
router.post(
'/:id/convert-to-deal',
authorize('tenders', 'tenders', 'update'),
[
param('id').isUUID(),
body('contactId').isUUID(),
body('pipelineId').isUUID(),
body('ownerId').optional().isUUID(),
],
validate,
tendersController.convertToDeal
);
// Directives
router.post(
'/:id/directives',
authorize('tenders', 'directives', 'create'),
[
param('id').isUUID(),
body('type').isIn(['BUY_TERMS', 'VISIT_CLIENT', 'MEET_COMMITTEE', 'PREPARE_TO_BID']),
body('assignedToEmployeeId').isUUID(),
body('notes').optional().trim(),
],
validate,
tendersController.createDirective
);
// Update directive (e.g. complete task) - route with directiveId
router.put(
'/directives/:directiveId',
authorize('tenders', 'directives', 'update'),
[
param('directiveId').isUUID(),
body('status').optional().isIn(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']),
body('completionNotes').optional().trim(),
],
validate,
tendersController.updateDirective
);
// File uploads
router.post(
'/:id/attachments',
authorize('tenders', 'tenders', 'update'),
param('id').isUUID(),
validate,
upload.single('file'),
tendersController.uploadTenderAttachment
);
router.post(
'/directives/:directiveId/attachments',
authorize('tenders', 'directives', 'update'),
param('directiveId').isUUID(),
validate,
upload.single('file'),
tendersController.uploadDirectiveAttachment
);
export default router;

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

View File

@@ -8,6 +8,7 @@ import hrRoutes from '../modules/hr/hr.routes';
import inventoryRoutes from '../modules/inventory/inventory.routes';
import projectsRoutes from '../modules/projects/projects.routes';
import marketingRoutes from '../modules/marketing/marketing.routes';
import tendersRoutes from '../modules/tenders/tenders.routes';
const router = Router();
@@ -21,6 +22,7 @@ router.use('/hr', hrRoutes);
router.use('/inventory', inventoryRoutes);
router.use('/projects', projectsRoutes);
router.use('/marketing', marketingRoutes);
router.use('/tenders', tendersRoutes);
// API info
router.get('/', (req, res) => {
@@ -36,6 +38,7 @@ router.get('/', (req, res) => {
'Inventory & Assets',
'Tasks & Projects',
'Marketing',
'Tender Management',
],
});
});