|
|
|
|
@@ -3,6 +3,8 @@ 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',
|
|
|
|
|
@@ -33,18 +35,31 @@ 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;
|
|
|
|
|
@@ -71,12 +86,92 @@ class TendersService {
|
|
|
|
|
return `TND-${year}-${seq}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private readonly EXTRA_META_START = '[EXTRA_TENDER_META]';
|
|
|
|
|
private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]';
|
|
|
|
|
|
|
|
|
|
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: {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 }>(tender: T) {
|
|
|
|
|
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...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.bondValue);
|
|
|
|
|
|
|
|
|
|
const bondValue = Number(data.initialBondValue ?? data.bondValue ?? 0);
|
|
|
|
|
const where: Prisma.TenderWhereInput = {
|
|
|
|
|
status: { not: 'CANCELLED' },
|
|
|
|
|
};
|
|
|
|
|
@@ -135,20 +230,33 @@ class TendersService {
|
|
|
|
|
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: data.bondValue,
|
|
|
|
|
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: data.notes?.trim() || null,
|
|
|
|
|
notes: finalNotes,
|
|
|
|
|
contactId: data.contactId || null,
|
|
|
|
|
createdById: userId,
|
|
|
|
|
},
|
|
|
|
|
@@ -165,8 +273,10 @@ class TendersService {
|
|
|
|
|
userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { tender, possibleDuplicates: possibleDuplicates.length ? possibleDuplicates : undefined };
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
@@ -195,7 +305,12 @@ class TendersService {
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
});
|
|
|
|
|
return { tenders, total, page, pageSize };
|
|
|
|
|
return {
|
|
|
|
|
tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
|
|
|
|
|
total,
|
|
|
|
|
page,
|
|
|
|
|
pageSize,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async findById(id: string) {
|
|
|
|
|
@@ -216,8 +331,8 @@ class TendersService {
|
|
|
|
|
attachments: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!tender) throw new AppError(404, 'Tender not found');
|
|
|
|
|
return tender;
|
|
|
|
|
if (!tender) throw new AppError(404, 'Tender not found');
|
|
|
|
|
return this.mapTenderExtraFields(tender);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async update(id: string, data: Partial<CreateTenderData>, userId: string) {
|
|
|
|
|
@@ -228,17 +343,76 @@ class TendersService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) updateData.bondValue = data.bondValue;
|
|
|
|
|
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) updateData.notes = data.notes?.trim() || null;
|
|
|
|
|
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 } }
|
|
|
|
|
@@ -261,8 +435,8 @@ class TendersService {
|
|
|
|
|
userId,
|
|
|
|
|
changes: { before: existing, after: data },
|
|
|
|
|
});
|
|
|
|
|
return tender;
|
|
|
|
|
}
|
|
|
|
|
return this.mapTenderExtraFields(tender);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async createDirective(tenderId: string, data: CreateDirectiveData, userId: string) {
|
|
|
|
|
const tender = await prisma.tender.findUnique({
|
|
|
|
|
@@ -518,6 +692,34 @@ class TendersService {
|
|
|
|
|
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}-`;
|
|
|
|
|
|