add supplier management module

This commit is contained in:
Aya
2026-05-06 10:56:31 +03:00
parent 8621096a82
commit da4cb36036
22 changed files with 1579 additions and 583 deletions

View File

@@ -29,13 +29,13 @@ class ContactsController {
const filters = {
search: req.query.search as string,
type: req.query.type as string,
specialization: req.query.specialization as string,
status: req.query.status as string,
category: req.query.category as string,
source: req.query.source as string,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
createdFrom: req.query.createdFrom ? new Date(req.query.createdFrom as string) : undefined,
createdTo: req.query.createdTo ? new Date(req.query.createdTo as string) : undefined,
excludeSuppliers: req.query.excludeSuppliers === 'true',
};
const result = await contactsService.findAll(filters, page, pageSize);
@@ -242,6 +242,7 @@ class ContactsController {
category: req.query.category as string,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true',
excludeSuppliers: req.query.excludeSuppliers === 'true',
};
const buffer = await contactsService.export(filters);

View File

@@ -36,7 +36,6 @@ interface UpdateContactData extends Partial<CreateContactData> {
interface SearchFilters {
search?: string;
type?: string;
specialization?: string;
status?: string;
category?: string;
source?: string;
@@ -44,6 +43,7 @@ interface SearchFilters {
createdFrom?: Date;
createdTo?: Date;
excludeCompanyEmployees?: boolean;
excludeSuppliers?: boolean;
}
class ContactsService {
@@ -149,12 +149,6 @@ class ContactsService {
where.type = filters.type;
}
if (filters.specialization) {
where.tags = {
has: filters.specialization,
};
}
if (filters.status) {
where.status = filters.status;
}
@@ -173,6 +167,22 @@ class ContactsService {
};
}
if (filters.excludeSuppliers) {
where.NOT = [
{ type: 'SUPPLIER' },
{
categories: {
some: {
OR: [
{ name: { in: ['Supplier', 'Suppliers'] } },
{ nameAr: { contains: 'مورد' } },
],
},
},
},
];
}
if (filters.createdFrom || filters.createdTo) {
where.createdAt = {};
if (filters.createdFrom) {
@@ -765,6 +775,7 @@ class ContactsService {
const where: Prisma.ContactWhereInput = {
status: { not: 'DELETED' },
};
const notConditions: Prisma.ContactWhereInput[] = [];
if (filters.search) {
where.OR = [
@@ -779,19 +790,39 @@ class ContactsService {
if (filters.source) where.source = filters.source;
if (filters.rating) where.rating = filters.rating;
if (filters.excludeSuppliers) {
notConditions.push(
{ type: 'SUPPLIER' },
{
categories: {
some: {
OR: [
{ name: { in: ['Supplier', 'Suppliers'] } },
{ nameAr: { contains: 'مورد' } },
],
},
},
}
);
}
if (filters.excludeCompanyEmployees) {
const companyEmployeeCategory = await prisma.contactCategory.findFirst({
where: { name: 'Company Employee', isActive: true },
});
if (companyEmployeeCategory) {
where.NOT = {
notConditions.push({
categories: {
some: { id: companyEmployeeCategory.id },
},
};
});
}
}
if (notConditions.length > 0) {
where.NOT = notConditions;
}
// Fetch all contacts (no pagination for export)
const contacts = await prisma.contact.findMany({
where,

View File

@@ -15,22 +15,11 @@ if (!fs.existsSync(expenseClaimsUploadDir)) {
fs.mkdirSync(expenseClaimsUploadDir, { recursive: true });
}
const decodeOriginalFileName = (name: string) => {
return Buffer.from(name, 'latin1').toString('utf8');
};
const expenseClaimStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir),
filename: (_req, file, cb) => {
const originalName = decodeOriginalFileName(file.originalname);
const ext = path.extname(originalName);
const safeBaseName = path
.basename(originalName, ext)
.replace(/[^a-zA-Z0-9\u0600-\u06FF._-]/g, '_');
(file as any).decodedOriginalName = originalName;
cb(null, `${crypto.randomUUID()}-${safeBaseName}${ext}`);
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, `${crypto.randomUUID()}-${safeName}`);
},
});

View File

@@ -1,7 +1,6 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { notificationsService } from '../notifications/notifications.service';
class HRService {
// ========== EMPLOYEES ==========
@@ -353,40 +352,15 @@ class HRService {
},
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'CREATE',
userId,
});
const employeeFullName = `${leave.employee.firstName} ${leave.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'leave_requests',
fallbackEmployeeId: leave.employeeId,
fallbackToManager: true,
type: 'LEAVE_REQUEST_SUBMITTED',
title: 'طلب إجازة جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب إجازة جديد.`,
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: leave.employeeId,
type: 'LEAVE_REQUEST_CREATED',
title: 'تم إرسال طلب الإجازة',
message: 'تم إرسال طلب الإجازة الخاص بك بنجاح وهو الآن بانتظار المراجعة.',
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [],
});
return leave;
}
async approveLeave(id: string, approvedBy: string, userId: string) {
const leave = await prisma.leave.update({
where: { id },
@@ -404,25 +378,16 @@ class HRService {
const year = new Date(leave.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'APPROVE',
userId,
});
await notificationsService.notifyEmployeeUser({
employeeId: leave.employeeId,
type: 'LEAVE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الإجازة',
message: 'تمت الموافقة على طلب الإجازة الخاص بك.',
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [userId],
});
return leave;
}
async rejectLeave(id: string, rejectedReason: string, userId: string) {
const leave = await prisma.leave.findUnique({ where: { id } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
@@ -438,26 +403,13 @@ class HRService {
include: { employee: true },
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: id,
action: 'REJECT',
userId,
reason: rejectedReason,
});
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_REJECTED',
title: 'تم رفض طلب الإجازة',
message: rejectedReason
? `تم رفض طلب الإجازة الخاص بك. السبب: ${rejectedReason}`
: 'تم رفض طلب الإجازة الخاص بك.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated;
}
@@ -614,23 +566,13 @@ async findManagedLeaves(status?: string) {
const year = new Date(updated.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: updated.id,
action: 'MANAGER_APPROVE',
userId,
});
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الإجازة',
message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated;
}
@@ -668,7 +610,7 @@ async findManagedLeaves(status?: string) {
},
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: updated.id,
action: 'MANAGER_REJECT',
@@ -676,18 +618,6 @@ async findManagedLeaves(status?: string) {
reason: rejectedReason,
});
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_REJECTED',
title: 'تم رفض طلب الإجازة',
message: rejectedReason
? `تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر. السبب: ${rejectedReason}`
: 'تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated;
}
@@ -913,30 +843,7 @@ private isSystemAdminUser(user: any) {
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
const employeeFullName = `${loan.employee.firstName} ${loan.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'loan_requests',
type: 'LOAN_REQUEST_SUBMITTED',
title: 'طلب قرض جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب قرض جديد برقم ${loan.loanNumber}.`,
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: loan.employeeId,
type: 'LOAN_REQUEST_CREATED',
title: 'تم إرسال طلب القرض',
message: `تم إرسال طلب القرض الخاص بك برقم ${loan.loanNumber} وهو الآن بانتظار المراجعة.`,
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [],
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
return loan;
}
@@ -982,7 +889,7 @@ private isSystemAdminUser(user: any) {
const loanAmount = Number(loan.amount || 0);
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
// المرحلة الأولى: HR approval
if (loan.status === 'PENDING_HR') {
if (needsAdminApproval) {
const updatedLoan = await prisma.loan.update({
@@ -992,36 +899,13 @@ private isSystemAdminUser(user: any) {
},
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
userId,
});
const fullLoan = await this.findLoanById(id);
const employeeFullName = `${fullLoan.employee.firstName} ${fullLoan.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'loan_requests',
type: 'LOAN_REQUEST_PENDING_ADMIN',
title: 'طلب قرض محال إلى مدير النظام',
message: `تمت إحالة طلب القرض رقم ${fullLoan.loanNumber} الخاص بالموظف ${employeeFullName} إلى مدير النظام لاعتماده النهائي.`,
entityType: 'LOAN',
entityId: id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: fullLoan.employee.id,
type: 'LOAN_REQUEST_ESCALATED',
title: 'تمت إحالة طلب القرض للاعتماد النهائي',
message: `تمت الموافقة المبدئية على طلب القرض رقم ${fullLoan.loanNumber} وإحالته للاعتماد النهائي.`,
entityType: 'LOAN',
entityId: id,
excludeUserIds: [userId],
});
return updatedLoan;
}
}
@@ -1070,26 +954,14 @@ private isSystemAdminUser(user: any) {
),
]);
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
userId,
});
const approvedLoan = await this.findLoanById(id);
await notificationsService.notifyEmployeeUser({
employeeId: approvedLoan.employee.id,
type: 'LOAN_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب القرض',
message: `تمت الموافقة على طلب القرض الخاص بك برقم ${approvedLoan.loanNumber}.`,
entityType: 'LOAN',
entityId: approvedLoan.id,
excludeUserIds: [userId],
});
return approvedLoan;
return this.findLoanById(id);
}
async rejectLoan(id: string, rejectedReason: string, userId: string) {
@@ -1109,7 +981,7 @@ private isSystemAdminUser(user: any) {
include: { employee: true },
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: 'REJECT',
@@ -1117,18 +989,6 @@ private isSystemAdminUser(user: any) {
reason: rejectedReason,
});
await notificationsService.notifyEmployeeUser({
employeeId: loan.employeeId,
type: 'LOAN_REQUEST_REJECTED',
title: 'تم رفض طلب القرض',
message: rejectedReason?.trim()
? `تم رفض طلب القرض الخاص بك. السبب: ${rejectedReason.trim()}`
: 'تم رفض طلب القرض الخاص بك.',
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [userId],
});
return loan;
}
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
@@ -1203,30 +1063,7 @@ private isSystemAdminUser(user: any) {
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
const employeeFullName = `${req.employee.firstName} ${req.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'purchase_requests',
type: 'PURCHASE_REQUEST_SUBMITTED',
title: 'طلب شراء جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب شراء جديد برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_CREATED',
title: 'تم إرسال طلب الشراء',
message: `تم إرسال طلب الشراء الخاص بك برقم ${req.requestNumber} وهو الآن بانتظار المراجعة.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [],
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
return req;
}
@@ -1236,19 +1073,8 @@ private isSystemAdminUser(user: any) {
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الشراء',
message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
return req;;
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
return req;
}
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
@@ -1258,19 +1084,6 @@ private isSystemAdminUser(user: any) {
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_REJECTED',
title: 'تم رفض طلب الشراء',
message: rejectedReason?.trim()
? `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}. السبب: ${rejectedReason.trim()}`
: `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
return req;
}

View File

@@ -0,0 +1,89 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
import { suppliersService } from './suppliers.service';
class SuppliersController {
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 as string,
status: req.query.status as string,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
category: req.query.category as string,
};
const result = await suppliersService.findAll(filters, page, pageSize);
res.json(ResponseFormatter.paginated(result.suppliers, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async getStats(req: AuthRequest, res: Response, next: NextFunction) {
try {
const stats = await suppliersService.getStats();
res.json(ResponseFormatter.success(stats));
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const supplier = await suppliersService.findById(req.params.id);
res.json(ResponseFormatter.success(supplier));
} catch (error) {
next(error);
}
}
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const supplier = await suppliersService.create({ ...req.body, createdById: req.user!.id }, req.user!.id);
res.status(201).json(ResponseFormatter.success(supplier, 'تم إنشاء المورد بنجاح - Supplier created successfully'));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const supplier = await suppliersService.update(req.params.id, req.body, req.user!.id);
res.json(ResponseFormatter.success(supplier, 'تم تحديث المورد بنجاح - Supplier updated successfully'));
} catch (error) {
next(error);
}
}
async archive(req: AuthRequest, res: Response, next: NextFunction) {
try {
const supplier = await suppliersService.archive(req.params.id, req.user!.id, req.body.reason);
res.json(ResponseFormatter.success(supplier, 'تم أرشفة المورد بنجاح - Supplier archived successfully'));
} catch (error) {
next(error);
}
}
async export(req: AuthRequest, res: Response, next: NextFunction) {
try {
const filters = {
search: req.query.search as string,
status: req.query.status as string,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
category: req.query.category as string,
};
const buffer = await suppliersService.export(filters);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=suppliers-${Date.now()}.xlsx`);
res.send(buffer);
} catch (error) {
next(error);
}
}
}
export const suppliersController = new SuppliersController();

View File

@@ -0,0 +1,66 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
import { suppliersController } from './suppliers.controller';
const router = Router();
router.use(authenticate);
router.get('/', authorize('contacts', 'contacts', 'read'), suppliersController.findAll);
router.get('/stats', authorize('contacts', 'contacts', 'read'), suppliersController.getStats);
router.get('/export', authorize('contacts', 'contacts', 'read'), suppliersController.export);
router.get(
'/:id',
authorize('contacts', 'contacts', 'read'),
param('id').isUUID(),
validate,
suppliersController.findById
);
router.post(
'/',
authorize('contacts', 'contacts', 'create'),
[
body('name').optional({ values: 'falsy' }).trim(),
body('companyName').optional({ values: 'falsy' }).trim(),
body('email')
.optional({ values: 'falsy' })
.custom((value) => {
if (value === null || value === undefined || value === '') return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
})
.withMessage('Invalid email format'),
validate,
],
suppliersController.create
);
router.put(
'/:id',
authorize('contacts', 'contacts', 'update'),
[
param('id').isUUID(),
body('email')
.optional({ values: 'falsy' })
.custom((value) => {
if (value === null || value === undefined || value === '') return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
})
.withMessage('Invalid email format'),
validate,
],
suppliersController.update
);
router.post(
'/:id/archive',
authorize('contacts', 'contacts', 'archive'),
param('id').isUUID(),
validate,
suppliersController.archive
);
export default router;

View File

@@ -0,0 +1,314 @@
import prisma from '../../config/database';
import { Prisma } from '@prisma/client';
import { AppError } from '../../shared/middleware/errorHandler';
import { contactsService } from '../contacts/contacts.service';
interface SupplierFilters {
search?: string;
status?: string;
rating?: number;
category?: string;
}
interface SupplierContactData {
name?: string;
nameAr?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
companyName?: string;
companyNameAr?: string;
taxNumber?: string;
commercialRegister?: string;
address?: string;
city?: string;
country?: string;
postalCode?: string;
categories?: string[];
tags?: string[];
source?: string;
rating?: number;
status?: string;
customFields?: any;
createdById?: string;
}
class SuppliersService {
private supplierCategoryNames = ['Supplier', 'Suppliers'];
private isSupplierSystemCategory(category: any) {
return (
this.supplierCategoryNames.includes(category?.name) ||
Boolean(category?.nameAr && String(category.nameAr).includes('مورد'))
);
}
private getSupplierCategoryLabels(supplier: any): string[] {
const customFields = supplier.customFields || {};
if (Array.isArray(customFields.supplierCategories)) {
return customFields.supplierCategories
.map((category: any) => String(category || '').trim())
.filter(Boolean);
}
if (customFields.supplierCategory) return [String(customFields.supplierCategory)];
return (supplier.categories || [])
.filter((category: any) => !this.isSupplierSystemCategory(category))
.map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name)
.filter(Boolean);
}
private async ensureSupplierCategory() {
const existing = await prisma.contactCategory.findFirst({
where: {
isActive: true,
OR: [
{ name: { in: this.supplierCategoryNames } },
{ nameAr: { contains: 'مورد' } },
],
},
});
if (existing) return existing;
return prisma.contactCategory.create({
data: {
name: 'Supplier',
nameAr: 'مورّد',
description: 'Supplier / vendor records managed from Supplier Management module',
},
});
}
private supplierCondition(): Prisma.ContactWhereInput {
return {
OR: [
{ type: 'SUPPLIER' },
{
categories: {
some: {
OR: [
{ name: { in: this.supplierCategoryNames } },
{ nameAr: { contains: 'مورد' } },
],
},
},
},
],
};
}
private buildWhere(filters: SupplierFilters = {}): Prisma.ContactWhereInput {
const andConditions: Prisma.ContactWhereInput[] = [
{ archivedAt: null },
this.supplierCondition(),
];
if (filters.search) {
andConditions.push({
OR: [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ nameAr: { contains: filters.search, mode: 'insensitive' } },
{ email: { contains: filters.search, mode: 'insensitive' } },
{ phone: { contains: filters.search } },
{ mobile: { contains: filters.search } },
{ companyName: { contains: filters.search, mode: 'insensitive' } },
{ companyNameAr: { contains: filters.search, mode: 'insensitive' } },
{ taxNumber: { contains: filters.search, mode: 'insensitive' } },
{ commercialRegister: { contains: filters.search, mode: 'insensitive' } },
],
});
}
if (filters.status) andConditions.push({ status: filters.status });
if (filters.rating !== undefined) andConditions.push({ rating: filters.rating });
if (filters.category) {
andConditions.push({
OR: [
{
customFields: {
path: ['supplierCategories'],
array_contains: [filters.category],
} as any,
},
{
customFields: {
path: ['supplierCategory'],
equals: filters.category,
} as any,
},
{
categories: {
some: {
OR: [
{ name: { equals: filters.category, mode: 'insensitive' } },
{ nameAr: { equals: filters.category } },
],
},
},
},
],
});
}
return { AND: andConditions };
}
private isSupplierContact(contact: any) {
return (
contact.type === 'SUPPLIER' ||
contact.categories?.some((category: any) => this.isSupplierSystemCategory(category))
);
}
async findAll(filters: SupplierFilters, page: number = 1, pageSize: number = 20) {
const skip = (page - 1) * pageSize;
const where = this.buildWhere(filters);
const [total, suppliers] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.findMany({
where,
skip,
take: pageSize,
include: {
categories: true,
parent: { select: { id: true, name: true, type: true } },
createdBy: { select: { id: true, email: true, username: true } },
},
orderBy: { createdAt: 'desc' },
}),
]);
return { suppliers, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
async findById(id: string) {
const supplier = await prisma.contact.findUnique({
where: { id },
include: {
categories: true,
parent: true,
createdBy: { select: { id: true, email: true, username: true } },
},
});
if (!supplier || supplier.archivedAt || !this.isSupplierContact(supplier)) {
throw new AppError(404, 'المورد غير موجود - Supplier not found');
}
return supplier;
}
async create(data: SupplierContactData, userId: string) {
const supplierCategory = await this.ensureSupplierCategory();
const categories = [supplierCategory.id];
return contactsService.create(
{
...data,
type: 'SUPPLIER',
name: data.name || data.companyName || 'Supplier',
companyName: data.companyName || data.name,
country: data.country || 'Syria',
source: data.source || 'SUPPLIER_MODULE',
categories,
createdById: data.createdById || userId,
},
userId
);
}
async update(id: string, data: SupplierContactData, userId: string) {
const existing = await this.findById(id);
const supplierCategory = await this.ensureSupplierCategory();
const categories = [supplierCategory.id];
return contactsService.update(
id,
{
...data,
type: 'SUPPLIER',
name: data.name || data.companyName || existing.name,
companyName: data.companyName || data.name || existing.companyName || undefined,
categories,
},
userId
);
}
async archive(id: string, userId: string, reason?: string) {
await this.findById(id);
return contactsService.archive(id, userId, reason || 'Archived from Supplier Management');
}
async getStats() {
const [total, active, inactive, blocked] = await Promise.all([
prisma.contact.count({ where: this.buildWhere() }),
prisma.contact.count({ where: this.buildWhere({ status: 'ACTIVE' }) }),
prisma.contact.count({ where: this.buildWhere({ status: 'INACTIVE' }) }),
prisma.contact.count({ where: this.buildWhere({ status: 'BLOCKED' }) }),
]);
return { total, active, inactive, blocked };
}
async export(filters: SupplierFilters): Promise<Buffer> {
const xlsx = require('xlsx');
const suppliers = await prisma.contact.findMany({
where: this.buildWhere(filters),
include: {
categories: true,
createdBy: { select: { username: true, email: true } },
},
orderBy: { createdAt: 'desc' },
});
const exportData = suppliers.map((supplier) => {
const customFields: any = supplier.customFields || {};
const categoryNames = this.getSupplierCategoryLabels(supplier).join(', ');
return {
'Supplier ID': supplier.uniqueContactId,
'Supplier Code': customFields.supplierCode || '',
'Supplier Name': supplier.companyName || supplier.name,
'Supplier Name (Arabic)': supplier.companyNameAr || supplier.nameAr || '',
'Contact Person': supplier.name || '',
'Email': supplier.email || '',
'Phone': supplier.phone || '',
'Mobile': supplier.mobile || '',
'Website': supplier.website || '',
'Tax Number': supplier.taxNumber || '',
'Commercial Register': supplier.commercialRegister || '',
'Categories': categoryNames,
'Payment Terms': customFields.paymentTerms || '',
'Bank Name': customFields.bankName || '',
'Bank Account': customFields.bankAccount || '',
'Address': supplier.address || '',
'City': supplier.city || '',
'Country': supplier.country || '',
'Status': supplier.status,
'Rating': supplier.rating || '',
'Notes': customFields.notes || '',
'Created By': supplier.createdBy?.username || supplier.createdBy?.email || '',
'Created At': supplier.createdAt.toISOString(),
};
});
const worksheet = xlsx.utils.json_to_sheet(exportData);
const workbook = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(workbook, worksheet, 'Suppliers');
worksheet['!cols'] = [
{ wch: 16 }, { wch: 18 }, { wch: 28 }, { wch: 28 }, { wch: 24 }, { wch: 30 },
{ wch: 16 }, { wch: 16 }, { wch: 24 }, { wch: 18 }, { wch: 22 }, { wch: 18 },
{ wch: 18 }, { wch: 22 }, { wch: 26 }, { wch: 30 }, { wch: 16 }, { wch: 16 },
{ wch: 12 }, { wch: 12 }, { wch: 30 }, { wch: 18 }, { wch: 24 },
];
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
}
export const suppliersService = new SuppliersService();

View File

@@ -1,7 +1,6 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { notificationsService } from '../notifications/notifications.service';
import { Prisma } from '@prisma/client';
import path from 'path';
import fs from 'fs'
@@ -209,25 +208,11 @@ class TendersService {
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
}
private getComputedTenderStatus<T extends { status?: string | null; closingDate?: Date | string | null }>(tender: T) {
if (tender.status === 'CONVERTED_TO_DEAL' || tender.status === 'CANCELLED') {
return tender.status;
}
if (tender.closingDate && new Date(tender.closingDate) < new Date()) {
return 'EXPIRED';
}
return tender.status || 'ACTIVE';
}
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any; status?: string | null; closingDate?: Date | string | null }>(tender: T) {
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(tender: T) {
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
return {
...tender,
status: this.getComputedTenderStatus(tender),
originalStatus: tender.status,
notes: cleanNotes || null,
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
finalBondValue: meta.finalBondValue ?? null,
@@ -360,9 +345,7 @@ class TendersService {
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
];
}
if (filters.status && filters.status !== 'EXPIRED') {
where.status = filters.status;
}
if (filters.status) where.status = filters.status;
if (filters.source) where.source = filters.source;
if (filters.announcementType) where.announcementType = filters.announcementType;
@@ -378,15 +361,9 @@ class TendersService {
},
orderBy: { createdAt: 'desc' },
});
const mappedTenders = tenders.map((t) => this.mapTenderExtraFields(t));
const filteredTenders =
filters.status === 'EXPIRED'
? mappedTenders.filter((t: any) => t.status === 'EXPIRED')
: mappedTenders;
return {
tenders: filteredTenders,
total: filters.status === 'EXPIRED' ? filteredTenders.length : total,
return {
tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
total,
page,
pageSize,
};
@@ -540,20 +517,20 @@ class TendersService {
},
});
const assignedUser = directive.assignedToEmployee?.user;
if (assignedUser?.id) {
const typeLabel = this.getDirectiveTypeLabel(data.type);
await notificationsService.notifyMany({
userIds: [assignedUser.id],
type: 'TENDER_DIRECTIVE_ASSIGNED',
title: 'تم إسناد توجيه مناقصة جديد',
message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`,
entityType: 'TENDER',
entityId: tender.id,
excludeUserIds: [userId],
});
}
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',
@@ -701,17 +678,7 @@ class TendersService {
return deal;
}
private decodeUploadedFileName(fileName: string) {
if (!fileName) return 'file';
try {
return Buffer.from(fileName, 'latin1').toString('utf8');
} catch {
return fileName;
}
}
async uploadTenderAttachment(
async uploadTenderAttachment(
tenderId: string,
file: { path: string; originalname: string; mimetype: string; size: number },
userId: string,
@@ -719,17 +686,14 @@ class TendersService {
) {
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 originalName = this.decodeUploadedFileName(file.originalname);
const attachment = await prisma.attachment.create({
data: {
entityType: 'TENDER',
entityId: tenderId,
tenderId,
fileName,
originalName,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
@@ -737,7 +701,6 @@ class TendersService {
uploadedBy: userId,
},
});
await AuditLogger.log({
entityType: 'TENDER',
entityId: tenderId,
@@ -745,7 +708,6 @@ class TendersService {
userId,
changes: { attachmentUploaded: attachment.id },
});
return attachment;
}
@@ -759,12 +721,8 @@ class TendersService {
where: { id: directiveId },
select: { id: true, tenderId: true },
});
if (!directive) throw new AppError(404, 'Directive not found');
const fileName = path.basename(file.path);
const originalName = this.decodeUploadedFileName(file.originalname);
const attachment = await prisma.attachment.create({
data: {
entityType: 'TENDER_DIRECTIVE',
@@ -772,7 +730,7 @@ class TendersService {
tenderDirectiveId: directiveId,
tenderId: directive.tenderId,
fileName,
originalName,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
@@ -780,7 +738,6 @@ class TendersService {
uploadedBy: userId,
},
});
await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE',
entityId: directiveId,
@@ -788,9 +745,9 @@ class TendersService {
userId,
changes: { attachmentUploaded: attachment.id },
});
return attachment;
}
async getAttachmentFile(attachmentId: string): Promise<string> {
const attachment = await prisma.attachment.findUnique({
where: { id: attachmentId },
@@ -808,10 +765,12 @@ class TendersService {
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 },
})

View File

@@ -10,6 +10,7 @@ import projectsRoutes from '../modules/projects/projects.routes';
import marketingRoutes from '../modules/marketing/marketing.routes';
import tendersRoutes from '../modules/tenders/tenders.routes';
import notificationsRoutes from '../modules/notifications/notifications.routes';
import suppliersRoutes from '../modules/suppliers/suppliers.routes';
const router = Router();
@@ -18,6 +19,7 @@ router.use('/admin', adminRoutes);
router.use('/dashboard', dashboardRoutes);
router.use('/auth', authRoutes);
router.use('/contacts', contactsRoutes);
router.use('/suppliers', suppliersRoutes);
router.use('/crm', crmRoutes);
router.use('/hr', hrRoutes);
router.use('/inventory', inventoryRoutes);
@@ -35,6 +37,7 @@ router.get('/', (req, res) => {
modules: [
'Auth',
'Contact Management',
'Supplier Management',
'CRM',
'HR Management',
'Inventory & Assets',