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:
@@ -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');
|
||||
}
|
||||
|
||||
232
backend/src/modules/tenders/tenders.controller.ts
Normal file
232
backend/src/modules/tenders/tenders.controller.ts
Normal 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();
|
||||
175
backend/src/modules/tenders/tenders.routes.ts
Normal file
175
backend/src/modules/tenders/tenders.routes.ts
Normal 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;
|
||||
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();
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user