Files
zerp/backend/src/modules/tenders/tenders.service.ts
2026-05-07 16:16:31 +03:00

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();