858 lines
26 KiB
TypeScript
858 lines
26 KiB
TypeScript
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';
|
|
import fs from 'fs'
|
|
|
|
|
|
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;
|
|
|
|
// new extra fields stored inside notes metadata
|
|
initialBondValue?: number;
|
|
finalBondValue?: number;
|
|
finalBondRefundPeriod?: string;
|
|
siteVisitRequired?: boolean;
|
|
siteVisitLocation?: string;
|
|
termsPickupProvince?: string;
|
|
|
|
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}`;
|
|
}
|
|
|
|
private readonly EXTRA_META_START = '[EXTRA_TENDER_META]';
|
|
private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]';
|
|
private getCompanyTodayDate(): Date {
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: 'Asia/Riyadh',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).formatToParts(new Date());
|
|
|
|
const year = parts.find((p) => p.type === 'year')?.value;
|
|
const month = parts.find((p) => p.type === 'month')?.value;
|
|
const day = parts.find((p) => p.type === 'day')?.value;
|
|
|
|
return new Date(`${year}-${month}-${day}T00:00:00.000Z`);
|
|
}
|
|
|
|
private toDateOnly(value: Date | string | null | undefined): Date | null {
|
|
if (!value) return null;
|
|
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
|
|
const year = date.getUTCFullYear();
|
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
|
|
return new Date(`${year}-${month}-${day}T00:00:00.000Z`);
|
|
}
|
|
|
|
private getEffectiveTenderStatus(tender: {
|
|
status?: string | null;
|
|
closingDate?: Date | string | null;
|
|
}) {
|
|
if (tender.status === 'ACTIVE') {
|
|
const closingDate = this.toDateOnly(tender.closingDate);
|
|
const today = this.getCompanyTodayDate();
|
|
|
|
if (closingDate && closingDate < today) {
|
|
return 'EXPIRED';
|
|
}
|
|
}
|
|
|
|
return tender.status || 'ACTIVE';
|
|
}
|
|
|
|
private extractTenderExtraMeta(notes?: string | null) {
|
|
if (!notes) {
|
|
return {
|
|
cleanNotes: '',
|
|
meta: {},
|
|
};
|
|
}
|
|
|
|
const start = notes.indexOf(this.EXTRA_META_START);
|
|
const end = notes.indexOf(this.EXTRA_META_END);
|
|
|
|
if (start === -1 || end === -1 || end < start) {
|
|
return {
|
|
cleanNotes: notes,
|
|
meta: {},
|
|
};
|
|
}
|
|
|
|
const jsonPart = notes.slice(start + this.EXTRA_META_START.length, end).trim();
|
|
const before = notes.slice(0, start).trim();
|
|
const after = notes.slice(end + this.EXTRA_META_END.length).trim();
|
|
const cleanNotes = [before, after].filter(Boolean).join('\n').trim();
|
|
|
|
try {
|
|
return {
|
|
cleanNotes,
|
|
meta: JSON.parse(jsonPart || '{}'),
|
|
};
|
|
} catch {
|
|
return {
|
|
cleanNotes: notes,
|
|
meta: {},
|
|
};
|
|
}
|
|
}
|
|
|
|
async delete(id: string, userId: string) {
|
|
const tender = await prisma.tender.findUnique({
|
|
where: { id },
|
|
include: {
|
|
attachments: true,
|
|
directives: {
|
|
include: {
|
|
attachments: true,
|
|
},
|
|
},
|
|
convertedDeal: {
|
|
select: { id: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!tender) {
|
|
throw new AppError(404, 'Tender not found');
|
|
}
|
|
|
|
if (tender.convertedDeal) {
|
|
throw new AppError(400, 'Cannot delete tender that has been converted to deal');
|
|
}
|
|
|
|
for (const attachment of tender.attachments || []) {
|
|
if (attachment.path && fs.existsSync(attachment.path)) {
|
|
fs.unlinkSync(attachment.path);
|
|
}
|
|
}
|
|
|
|
for (const directive of tender.directives || []) {
|
|
for (const attachment of directive.attachments || []) {
|
|
if (attachment.path && fs.existsSync(attachment.path)) {
|
|
fs.unlinkSync(attachment.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
await prisma.tender.delete({
|
|
where: { id },
|
|
});
|
|
|
|
await AuditLogger.log({
|
|
entityType: 'TENDER',
|
|
entityId: id,
|
|
action: 'DELETE',
|
|
userId,
|
|
changes: {
|
|
deletedTenderNumber: tender.tenderNumber,
|
|
deletedTitle: tender.title,
|
|
},
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
private buildTenderNotes(
|
|
plainNotes?: string | null,
|
|
extra?: {
|
|
initialBondValue?: number | null;
|
|
finalBondValue?: number | null;
|
|
finalBondRefundPeriod?: string | null;
|
|
siteVisitRequired?: boolean;
|
|
siteVisitLocation?: string | null;
|
|
termsPickupProvince?: string | null;
|
|
}
|
|
) {
|
|
const cleanedNotes = plainNotes?.trim() || '';
|
|
|
|
const meta = {
|
|
initialBondValue: extra?.initialBondValue ?? null,
|
|
finalBondValue: extra?.finalBondValue ?? null,
|
|
finalBondRefundPeriod: extra?.finalBondRefundPeriod?.trim() || null,
|
|
siteVisitRequired: !!extra?.siteVisitRequired,
|
|
siteVisitLocation: extra?.siteVisitLocation?.trim() || null,
|
|
termsPickupProvince: extra?.termsPickupProvince?.trim() || null,
|
|
};
|
|
|
|
const metaBlock = `${this.EXTRA_META_START}${JSON.stringify(meta)}${this.EXTRA_META_END}`;
|
|
|
|
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
|
|
}
|
|
|
|
private mapTenderExtraFields<T extends {
|
|
notes?: string | null;
|
|
bondValue?: any;
|
|
status?: string | null;
|
|
closingDate?: Date | string | null;
|
|
}>(tender: T) {
|
|
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
|
|
|
|
return {
|
|
...tender,
|
|
status: this.getEffectiveTenderStatus(tender),
|
|
notes: cleanNotes || null,
|
|
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
|
|
finalBondValue: meta.finalBondValue ?? null,
|
|
finalBondRefundPeriod: meta.finalBondRefundPeriod ?? null,
|
|
siteVisitRequired: !!meta.siteVisitRequired,
|
|
siteVisitLocation: meta.siteVisitLocation ?? null,
|
|
termsPickupProvince: meta.termsPickupProvince ?? null,
|
|
};
|
|
}
|
|
|
|
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.initialBondValue ?? data.bondValue ?? 0);
|
|
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);
|
|
|
|
if (data.siteVisitRequired && !data.siteVisitLocation?.trim()) {
|
|
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
|
|
}
|
|
|
|
const finalNotes = this.buildTenderNotes(data.notes, {
|
|
initialBondValue: data.initialBondValue ?? data.bondValue ?? 0,
|
|
finalBondValue: data.finalBondValue ?? null,
|
|
finalBondRefundPeriod: data.finalBondRefundPeriod ?? null,
|
|
siteVisitRequired: !!data.siteVisitRequired,
|
|
siteVisitLocation: data.siteVisitRequired ? data.siteVisitLocation ?? null : null,
|
|
termsPickupProvince: data.termsPickupProvince ?? null,
|
|
});
|
|
|
|
const tender = await prisma.tender.create({
|
|
data: {
|
|
tenderNumber,
|
|
issuingBodyName: data.issuingBodyName.trim(),
|
|
title: data.title.trim(),
|
|
termsValue: data.termsValue,
|
|
bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0),
|
|
announcementDate,
|
|
closingDate,
|
|
announcementLink: data.announcementLink?.trim() || null,
|
|
source: data.source,
|
|
sourceOther: data.sourceOther?.trim() || null,
|
|
announcementType: data.announcementType,
|
|
notes: finalNotes,
|
|
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: this.mapTenderExtraFields(tender),
|
|
possibleDuplicates: possibleDuplicates.length ? possibleDuplicates.map((t) => this.mapTenderExtraFields(t)) : 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 === 'EXPIRED') {
|
|
where.status = 'ACTIVE';
|
|
where.closingDate = { lt: this.getCompanyTodayDate() };
|
|
} else if (filters.status === 'ACTIVE') {
|
|
where.status = 'ACTIVE';
|
|
where.closingDate = { gte: this.getCompanyTodayDate() };
|
|
} else 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: tenders.map((t) => this.mapTenderExtraFields(t)),
|
|
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 this.mapTenderExtraFields(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 = {};
|
|
const existingMapped = this.mapTenderExtraFields(existing as any);
|
|
|
|
const mergedExtra = {
|
|
initialBondValue:
|
|
data.initialBondValue !== undefined
|
|
? Number(data.initialBondValue)
|
|
: existingMapped.initialBondValue ?? Number(existing.bondValue ?? 0),
|
|
|
|
finalBondValue:
|
|
data.finalBondValue !== undefined
|
|
? Number(data.finalBondValue)
|
|
: existingMapped.finalBondValue ?? null,
|
|
|
|
finalBondRefundPeriod:
|
|
data.finalBondRefundPeriod !== undefined
|
|
? data.finalBondRefundPeriod
|
|
: existingMapped.finalBondRefundPeriod ?? null,
|
|
|
|
siteVisitRequired:
|
|
data.siteVisitRequired !== undefined
|
|
? !!data.siteVisitRequired
|
|
: !!existingMapped.siteVisitRequired,
|
|
|
|
siteVisitLocation:
|
|
data.siteVisitLocation !== undefined
|
|
? data.siteVisitLocation
|
|
: existingMapped.siteVisitLocation ?? null,
|
|
|
|
termsPickupProvince:
|
|
data.termsPickupProvince !== undefined
|
|
? data.termsPickupProvince
|
|
: existingMapped.termsPickupProvince ?? null,
|
|
};
|
|
|
|
if (mergedExtra.siteVisitRequired && !mergedExtra.siteVisitLocation?.trim()) {
|
|
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
|
|
}
|
|
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 || data.initialBondValue !== undefined) {
|
|
updateData.bondValue = Number(data.initialBondValue ?? data.bondValue ?? existing.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 ||
|
|
data.initialBondValue !== undefined ||
|
|
data.finalBondValue !== undefined ||
|
|
data.finalBondRefundPeriod !== undefined ||
|
|
data.siteVisitRequired !== undefined ||
|
|
data.siteVisitLocation !== undefined ||
|
|
data.termsPickupProvince !== undefined
|
|
) {
|
|
updateData.notes = this.buildTenderNotes(
|
|
data.notes !== undefined ? data.notes : existingMapped.notes,
|
|
{
|
|
initialBondValue: mergedExtra.initialBondValue,
|
|
finalBondValue: mergedExtra.finalBondValue,
|
|
finalBondRefundPeriod: mergedExtra.finalBondRefundPeriod,
|
|
siteVisitRequired: mergedExtra.siteVisitRequired,
|
|
siteVisitLocation: mergedExtra.siteVisitRequired ? mergedExtra.siteVisitLocation : null,
|
|
termsPickupProvince: mergedExtra.termsPickupProvince,
|
|
}
|
|
);
|
|
}
|
|
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 this.mapTenderExtraFields(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');
|
|
}
|
|
if (this.getEffectiveTenderStatus(tender) === 'EXPIRED') {
|
|
throw new AppError(400, 'Cannot convert expired tender 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;
|
|
}
|
|
|
|
async getAttachmentFile(attachmentId: string): Promise<string> {
|
|
const attachment = await prisma.attachment.findUnique({
|
|
where: { id: attachmentId },
|
|
})
|
|
|
|
if (!attachment) throw new AppError(404, 'File not found')
|
|
|
|
return attachment.path
|
|
}
|
|
|
|
async deleteAttachment(attachmentId: string): Promise<void> {
|
|
const attachment = await prisma.attachment.findUnique({
|
|
where: { id: attachmentId },
|
|
})
|
|
|
|
if (!attachment) throw new AppError(404, 'File not found')
|
|
|
|
// حذف من الديسك
|
|
if (attachment.path && fs.existsSync(attachment.path)) {
|
|
fs.unlinkSync(attachment.path)
|
|
}
|
|
|
|
// حذف من DB
|
|
await prisma.attachment.delete({
|
|
where: { id: attachmentId },
|
|
})
|
|
}
|
|
|
|
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();
|