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

@@ -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',
],
});
});