updates for contacts & tenders Modules
This commit is contained in:
@@ -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}-`;
|
||||
|
||||
Reference in New Issue
Block a user