addition expense claims

This commit is contained in:
Aya
2026-04-22 11:36:47 +03:00
parent e262d8c09c
commit 0a9e1bbd4d
16 changed files with 1553 additions and 31 deletions

View File

@@ -0,0 +1,26 @@
CREATE TABLE "expense_claims" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"claimNumber" TEXT NOT NULL,
"expenseDate" DATE,
"amount" DECIMAL(12,2),
"description" TEXT,
"projectOrTender" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"rejectedReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expense_claims_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "expense_claims_claimNumber_key" ON "expense_claims"("claimNumber");
CREATE INDEX "expense_claims_employeeId_idx" ON "expense_claims"("employeeId");
CREATE INDEX "expense_claims_status_idx" ON "expense_claims"("status");
ALTER TABLE "expense_claims"
ADD CONSTRAINT "expense_claims_employeeId_fkey"
FOREIGN KEY ("employeeId") REFERENCES "employees"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
ALTER TABLE "expense_claims"
ADD COLUMN "items" JSONB;
ALTER TABLE "expense_claims"
ADD COLUMN "totalAmount" DECIMAL(12,2);
UPDATE "expense_claims"
SET "totalAmount" = "amount"
WHERE "totalAmount" IS NULL AND "amount" IS NOT NULL;

View File

@@ -200,6 +200,7 @@ model Employee {
commissions Commission[]
loans Loan[]
purchaseRequests PurchaseRequest[]
expenseClaims ExpenseClaim[]
leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[]
tenderDirectivesAssigned TenderDirective[]
@@ -503,6 +504,34 @@ model PurchaseRequest {
@@map("purchase_requests")
}
model ExpenseClaim {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
claimNumber String @unique
items Json?
totalAmount Decimal? @db.Decimal(12, 2)
expenseDate DateTime? @db.Date
amount Decimal? @db.Decimal(12, 2)
description String?
projectOrTender String?
status String @default("PENDING")
approvedBy String?
approvedAt DateTime?
rejectedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([status])
@@map("expense_claims")
}
model LeaveEntitlement {
id String @id @default(uuid())
employeeId String

View File

@@ -59,6 +59,27 @@ router.post('/portal/purchase-requests', portalController.submitPurchaseRequest)
router.get('/portal/attendance', portalController.getMyAttendance);
router.get('/portal/salaries', portalController.getMySalaries);
router.get('/portal/expense-claims', portalController.getMyExpenseClaims);
router.post('/portal/expense-claims', portalController.submitExpenseClaim);
router.get(
'/portal/managed-expense-claims',
authorize('department_expense_claims', '*', 'read'),
portalController.getManagedExpenseClaims
);
router.post(
'/portal/managed-expense-claims/:id/approve',
authorize('department_expense_claims', '*', 'approve'),
portalController.approveManagedExpenseClaim
);
router.post(
'/portal/managed-expense-claims/:id/reject',
authorize('department_expense_claims', '*', 'approve'),
portalController.rejectManagedExpenseClaim
);
// ========== EMPLOYEES ==========
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);

View File

@@ -176,6 +176,58 @@ export class PortalController {
}
}
async getMyExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.getMyExpenseClaims(req.user?.employeeId);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async submitExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.submitExpenseClaim(req.user?.employeeId, req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(data, 'تم إرسال كشف المصاريف - Expense claim submitted'));
} catch (error) {
next(error);
}
}
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
try {
const status = req.query.status as string | undefined;
const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async approveManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.approveManagedExpenseClaim(req.user?.employeeId, req.params.id, req.user!.id);
res.json(ResponseFormatter.success(data, 'تمت الموافقة على كشف المصاريف - Expense claim approved'));
} catch (error) {
next(error);
}
}
async rejectManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const data = await portalService.rejectManagedExpenseClaim(
req.user?.employeeId,
req.params.id,
rejectedReason || '',
req.user!.id
);
res.json(ResponseFormatter.success(data, 'تم رفض كشف المصاريف - Expense claim rejected'));
} catch (error) {
next(error);
}
}
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const month = req.query.month ? parseInt(req.query.month as string) : undefined;

View File

@@ -32,10 +32,11 @@ class PortalService {
throw new AppError(404, 'الموظف غير موجود - Employee not found');
}
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
const [loansCount, pendingLeaves, pendingPurchaseRequests, pendingExpenseClaims, leaveBalance] = await Promise.all([
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
prisma.expenseClaim.count({ where: { employeeId: empId, status: 'PENDING' } }),
hrService.getLeaveBalance(empId, new Date().getFullYear()),
]);
@@ -45,6 +46,7 @@ class PortalService {
activeLoansCount: loansCount,
pendingLeavesCount: pendingLeaves,
pendingPurchaseRequestsCount: pendingPurchaseRequests,
pendingExpenseClaimsCount: pendingExpenseClaims,
leaveBalance,
},
};
@@ -492,6 +494,296 @@ class PortalService {
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
}
private async generateExpenseClaimNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `EC-${year}-`;
const last = await prisma.expenseClaim.findFirst({
where: {
claimNumber: {
startsWith: prefix,
},
},
orderBy: {
createdAt: 'desc',
},
select: {
claimNumber: true,
},
});
let next = 1;
if (last?.claimNumber) {
const parts = last.claimNumber.split('-');
next = parseInt(parts[2] || '0', 10) + 1;
}
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async getMyExpenseClaims(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.expenseClaim.findMany({
where: { employeeId: empId },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
async submitExpenseClaim(
employeeId: string | undefined,
data: {
items?: Array<{
expenseDate?: string;
amount?: number | string;
entityName?: string;
description?: string;
projectOrTender?: string;
proofRef?: string;
}>;
description?: string;
},
userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const items = Array.isArray(data.items) ? data.items : [];
const normalizedItems = items
.map((item) => ({
expenseDate: item.expenseDate || '',
amount: Number(item.amount || 0),
entityName: item.entityName?.trim() || '',
description: item.description?.trim() || '',
projectOrTender: item.projectOrTender?.trim() || '',
proofRef: item.proofRef?.trim() || '',
}))
.filter((item) => item.description && item.amount > 0 && item.expenseDate);
if (normalizedItems.length === 0) {
throw new AppError(400, 'يجب إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
}
const claimNumber = await this.generateExpenseClaimNumber();
const totalAmount = normalizedItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const firstItem = normalizedItems[0];
const claim = await prisma.expenseClaim.create({
data: {
claimNumber,
employeeId: empId,
items: normalizedItems as any,
totalAmount,
expenseDate: new Date(firstItem.expenseDate),
amount: totalAmount,
description: data.description?.trim() || null,
projectOrTender: firstItem.projectOrTender || null,
status: 'PENDING',
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`;
await notificationsService.notifyUsersWithPermission({
module: 'department_expense_claims',
resource: '*',
action: 'approve',
type: 'EXPENSE_CLAIM_SUBMITTED',
title: 'كشف مصاريف جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال كشف مصاريف جديد برقم ${claim.claimNumber}.`,
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: claim.employeeId,
type: 'EXPENSE_CLAIM_CREATED',
title: 'تم إرسال كشف المصاريف',
message: `تم إرسال كشف المصاريف الخاص بك برقم ${claim.claimNumber} وهو الآن بانتظار المراجعة.`,
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
excludeUserIds: [],
});
return claim;
}
async getManagedExpenseClaims(employeeId: string | undefined, status?: string) {
this.requireEmployeeId(employeeId);
const where: any = {};
if (status && status !== 'all') {
where.status = status;
} else {
where.status = 'PENDING';
}
return prisma.expenseClaim.findMany({
where,
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
async approveManagedExpenseClaim(
managerEmployeeId: string | undefined,
claimId: string,
userId: string
) {
this.requireEmployeeId(managerEmployeeId);
const existing = await prisma.expenseClaim.findUnique({
where: { id: claimId },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (!existing) {
throw new AppError(404, 'كشف المصاريف غير موجود');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
}
const claim = await prisma.expenseClaim.update({
where: { id: claimId },
data: {
status: 'APPROVED',
approvedBy: userId,
approvedAt: new Date(),
rejectedReason: null,
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
await notificationsService.notifyEmployeeUser({
employeeId: claim.employeeId,
type: 'EXPENSE_CLAIM_APPROVED',
title: 'تمت الموافقة على كشف المصاريف',
message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.`,
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
excludeUserIds: [userId],
});
return claim;
}
async rejectManagedExpenseClaim(
managerEmployeeId: string | undefined,
claimId: string,
rejectedReason: string,
userId: string
) {
this.requireEmployeeId(managerEmployeeId);
if (!rejectedReason || !rejectedReason.trim()) {
throw new AppError(400, 'سبب الرفض مطلوب');
}
const existing = await prisma.expenseClaim.findUnique({
where: { id: claimId },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (!existing) {
throw new AppError(404, 'كشف المصاريف غير موجود');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
}
const claim = await prisma.expenseClaim.update({
where: { id: claimId },
data: {
status: 'REJECTED',
rejectedReason: rejectedReason.trim(),
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
await notificationsService.notifyEmployeeUser({
employeeId: claim.employeeId,
type: 'EXPENSE_CLAIM_REJECTED',
title: 'تم رفض كشف المصاريف',
message: `تم رفض كشف المصاريف الخاص بك برقم ${claim.claimNumber}. السبب: ${rejectedReason.trim()}`,
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
excludeUserIds: [userId],
});
return claim;
}
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
const empId = this.requireEmployeeId(employeeId);
const now = new Date();

View File

@@ -222,7 +222,7 @@ class NotificationsService {
}
async resolveApprovalRecipients(params: {
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests';
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests' | 'expense_requests';
fallbackEmployeeId?: string;
fallbackToManager?: boolean;
excludeUserIds?: string[];
@@ -260,7 +260,7 @@ class NotificationsService {
}
async notifyApprovalRecipients(params: {
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests';
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests' | 'expense_requests';
fallbackEmployeeId?: string;
fallbackToManager?: boolean;
type: string;