feat(crm): add contracts, cost sheets, invoices modules and API clients
Made-with: Cursor
This commit is contained in:
65
backend/src/modules/crm/contracts.controller.ts
Normal file
65
backend/src/modules/crm/contracts.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { contractsService } from './contracts.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
export class ContractsController {
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contract = await contractsService.create(req.body, req.user!.id);
|
||||
res.status(201).json(
|
||||
ResponseFormatter.success(contract, 'تم إنشاء العقد - Contract created')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contract = await contractsService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(contract));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const list = await contractsService.findByDeal(req.params.dealId);
|
||||
res.json(ResponseFormatter.success(list));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contract = await contractsService.update(req.params.id, req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(contract, 'تم تحديث العقد - Contract updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { status } = req.body;
|
||||
const contract = await contractsService.updateStatus(req.params.id, status, req.user!.id);
|
||||
res.json(ResponseFormatter.success(contract, 'تم تحديث حالة العقد - Contract status updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async markSigned(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contract = await contractsService.markSigned(req.params.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success(contract, 'تم توقيع العقد - Contract signed'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contractsController = new ContractsController();
|
||||
136
backend/src/modules/crm/contracts.service.ts
Normal file
136
backend/src/modules/crm/contracts.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
|
||||
interface CreateContractData {
|
||||
dealId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
clientInfo: any;
|
||||
companyInfo: any;
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
value: number;
|
||||
paymentTerms: any;
|
||||
deliveryTerms: any;
|
||||
terms: string;
|
||||
}
|
||||
|
||||
class ContractsService {
|
||||
async create(data: CreateContractData, userId: string) {
|
||||
const contractNumber = await this.generateContractNumber();
|
||||
const contract = await prisma.contract.create({
|
||||
data: {
|
||||
contractNumber,
|
||||
dealId: data.dealId,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
clientInfo: data.clientInfo || {},
|
||||
companyInfo: data.companyInfo || {},
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
||||
value: data.value,
|
||||
paymentTerms: data.paymentTerms || {},
|
||||
deliveryTerms: data.deliveryTerms || {},
|
||||
terms: data.terms,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
include: {
|
||||
deal: { include: { contact: true, owner: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTRACT',
|
||||
entityId: contract.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
return contract;
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
deal: { include: { contact: true, owner: true } },
|
||||
},
|
||||
});
|
||||
if (!contract) throw new AppError(404, 'العقد غير موجود - Contract not found');
|
||||
return contract;
|
||||
}
|
||||
|
||||
async findByDeal(dealId: string) {
|
||||
return prisma.contract.findMany({
|
||||
where: { dealId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string, userId: string) {
|
||||
const contract = await prisma.contract.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
include: { deal: true },
|
||||
});
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTRACT',
|
||||
entityId: id,
|
||||
action: 'STATUS_CHANGE',
|
||||
userId,
|
||||
changes: { status },
|
||||
});
|
||||
return contract;
|
||||
}
|
||||
|
||||
async markSigned(id: string, userId: string) {
|
||||
const contract = await prisma.contract.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', signedAt: new Date() },
|
||||
include: { deal: true },
|
||||
});
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTRACT',
|
||||
entityId: id,
|
||||
action: 'SIGN',
|
||||
userId,
|
||||
});
|
||||
return contract;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<CreateContractData>, userId: string) {
|
||||
const updateData: Record<string, any> = { ...data };
|
||||
if (updateData.startDate) updateData.startDate = new Date(updateData.startDate);
|
||||
if (updateData.endDate !== undefined) updateData.endDate = updateData.endDate ? new Date(updateData.endDate) : null;
|
||||
const contract = await prisma.contract.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { deal: { include: { contact: true, owner: true } } },
|
||||
});
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTRACT',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
});
|
||||
return contract;
|
||||
}
|
||||
|
||||
private async generateContractNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `CTR-${year}-`;
|
||||
const last = await prisma.contract.findFirst({
|
||||
where: { contractNumber: { startsWith: prefix } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { contractNumber: true },
|
||||
});
|
||||
let next = 1;
|
||||
if (last) {
|
||||
const parts = last.contractNumber.split('-');
|
||||
next = parseInt(parts[2] || '0') + 1;
|
||||
}
|
||||
return `${prefix}${next.toString().padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const contractsService = new ContractsService();
|
||||
55
backend/src/modules/crm/costSheets.controller.ts
Normal file
55
backend/src/modules/crm/costSheets.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { costSheetsService } from './costSheets.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
export class CostSheetsController {
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cs = await costSheetsService.create(req.body, req.user!.id);
|
||||
res.status(201).json(
|
||||
ResponseFormatter.success(cs, 'تم إنشاء كشف التكلفة - Cost sheet created')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cs = await costSheetsService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(cs));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const list = await costSheetsService.findByDeal(req.params.dealId);
|
||||
res.json(ResponseFormatter.success(list));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approve(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cs = await costSheetsService.approve(req.params.id, req.user!.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success(cs, 'تمت الموافقة على كشف التكلفة - Cost sheet approved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async reject(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cs = await costSheetsService.reject(req.params.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success(cs, 'تم رفض كشف التكلفة - Cost sheet rejected'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const costSheetsController = new CostSheetsController();
|
||||
113
backend/src/modules/crm/costSheets.service.ts
Normal file
113
backend/src/modules/crm/costSheets.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
|
||||
interface CreateCostSheetData {
|
||||
dealId: string;
|
||||
items: any[];
|
||||
totalCost: number;
|
||||
suggestedPrice: number;
|
||||
profitMargin: number;
|
||||
}
|
||||
|
||||
class CostSheetsService {
|
||||
async create(data: CreateCostSheetData, userId: string) {
|
||||
const latest = await prisma.costSheet.findFirst({
|
||||
where: { dealId: data.dealId },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true },
|
||||
});
|
||||
const version = latest ? latest.version + 1 : 1;
|
||||
const costSheetNumber = await this.generateCostSheetNumber();
|
||||
|
||||
const costSheet = await prisma.costSheet.create({
|
||||
data: {
|
||||
costSheetNumber,
|
||||
dealId: data.dealId,
|
||||
version,
|
||||
items: data.items,
|
||||
totalCost: data.totalCost,
|
||||
suggestedPrice: data.suggestedPrice,
|
||||
profitMargin: data.profitMargin,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
include: {
|
||||
deal: { include: { contact: true, owner: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'COST_SHEET',
|
||||
entityId: costSheet.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
return costSheet;
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const cs = await prisma.costSheet.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
deal: { include: { contact: true, owner: true } },
|
||||
},
|
||||
});
|
||||
if (!cs) throw new AppError(404, 'كشف التكلفة غير موجود - Cost sheet not found');
|
||||
return cs;
|
||||
}
|
||||
|
||||
async findByDeal(dealId: string) {
|
||||
return prisma.costSheet.findMany({
|
||||
where: { dealId },
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async approve(id: string, approvedBy: string, userId: string) {
|
||||
const cs = await prisma.costSheet.update({
|
||||
where: { id },
|
||||
data: { status: 'APPROVED', approvedBy, approvedAt: new Date() },
|
||||
include: { deal: true },
|
||||
});
|
||||
await AuditLogger.log({
|
||||
entityType: 'COST_SHEET',
|
||||
entityId: id,
|
||||
action: 'APPROVE',
|
||||
userId,
|
||||
});
|
||||
return cs;
|
||||
}
|
||||
|
||||
async reject(id: string, userId: string) {
|
||||
const cs = await prisma.costSheet.update({
|
||||
where: { id },
|
||||
data: { status: 'REJECTED', approvedBy: null, approvedAt: null },
|
||||
include: { deal: true },
|
||||
});
|
||||
await AuditLogger.log({
|
||||
entityType: 'COST_SHEET',
|
||||
entityId: id,
|
||||
action: 'REJECT',
|
||||
userId,
|
||||
});
|
||||
return cs;
|
||||
}
|
||||
|
||||
private async generateCostSheetNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `CS-${year}-`;
|
||||
const last = await prisma.costSheet.findFirst({
|
||||
where: { costSheetNumber: { startsWith: prefix } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { costSheetNumber: true },
|
||||
});
|
||||
let next = 1;
|
||||
if (last) {
|
||||
const parts = last.costSheetNumber.split('-');
|
||||
next = parseInt(parts[2] || '0') + 1;
|
||||
}
|
||||
return `${prefix}${next.toString().padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const costSheetsService = new CostSheetsService();
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { pipelinesController, dealsController, quotesController } from './crm.controller';
|
||||
import { costSheetsController } from './costSheets.controller';
|
||||
import { contractsController } from './contracts.controller';
|
||||
import { invoicesController } from './invoices.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
@@ -171,5 +174,153 @@ router.post(
|
||||
quotesController.send
|
||||
);
|
||||
|
||||
// ============= COST SHEETS =============
|
||||
|
||||
router.get(
|
||||
'/deals/:dealId/cost-sheets',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('dealId').isUUID(),
|
||||
validate,
|
||||
costSheetsController.findByDeal
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cost-sheets/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
costSheetsController.findById
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cost-sheets',
|
||||
authorize('crm', 'deals', 'create'),
|
||||
[
|
||||
body('dealId').isUUID(),
|
||||
body('items').isArray(),
|
||||
body('totalCost').isNumeric(),
|
||||
body('suggestedPrice').isNumeric(),
|
||||
body('profitMargin').isNumeric(),
|
||||
validate,
|
||||
],
|
||||
costSheetsController.create
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cost-sheets/:id/approve',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
costSheetsController.approve
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cost-sheets/:id/reject',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
costSheetsController.reject
|
||||
);
|
||||
|
||||
// ============= CONTRACTS =============
|
||||
|
||||
router.get(
|
||||
'/deals/:dealId/contracts',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('dealId').isUUID(),
|
||||
validate,
|
||||
contractsController.findByDeal
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/contracts/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contractsController.findById
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/contracts',
|
||||
authorize('crm', 'deals', 'create'),
|
||||
[
|
||||
body('dealId').isUUID(),
|
||||
body('title').notEmpty().trim(),
|
||||
body('type').notEmpty().trim(),
|
||||
body('startDate').isISO8601(),
|
||||
body('value').isNumeric(),
|
||||
body('terms').notEmpty().trim(),
|
||||
validate,
|
||||
],
|
||||
contractsController.create
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/contracts/:id',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contractsController.update
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/contracts/:id/sign',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contractsController.markSigned
|
||||
);
|
||||
|
||||
// ============= INVOICES =============
|
||||
|
||||
router.get(
|
||||
'/deals/:dealId/invoices',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('dealId').isUUID(),
|
||||
validate,
|
||||
invoicesController.findByDeal
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/invoices/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
invoicesController.findById
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/invoices',
|
||||
authorize('crm', 'deals', 'create'),
|
||||
[
|
||||
body('items').isArray(),
|
||||
body('subtotal').isNumeric(),
|
||||
body('taxAmount').isNumeric(),
|
||||
body('total').isNumeric(),
|
||||
body('dueDate').isISO8601(),
|
||||
validate,
|
||||
],
|
||||
invoicesController.create
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/invoices/:id',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
invoicesController.update
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/invoices/:id/record-payment',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('paidAmount').isNumeric(),
|
||||
validate,
|
||||
],
|
||||
invoicesController.recordPayment
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
61
backend/src/modules/crm/invoices.controller.ts
Normal file
61
backend/src/modules/crm/invoices.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { invoicesService } from './invoices.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
export class InvoicesController {
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const invoice = await invoicesService.create(req.body, req.user!.id);
|
||||
res.status(201).json(
|
||||
ResponseFormatter.success(invoice, 'تم إنشاء الفاتورة - Invoice created')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const invoice = await invoicesService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(invoice));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const list = await invoicesService.findByDeal(req.params.dealId);
|
||||
res.json(ResponseFormatter.success(list));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const invoice = await invoicesService.update(req.params.id, req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(invoice, 'تم تحديث الفاتورة - Invoice updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async recordPayment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { paidAmount, paidDate } = req.body;
|
||||
const invoice = await invoicesService.recordPayment(
|
||||
req.params.id,
|
||||
paidAmount,
|
||||
paidDate ? new Date(paidDate) : new Date(),
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(invoice, 'تم تسجيل الدفع - Payment recorded'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const invoicesController = new InvoicesController();
|
||||
130
backend/src/modules/crm/invoices.service.ts
Normal file
130
backend/src/modules/crm/invoices.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
|
||||
interface CreateInvoiceData {
|
||||
dealId?: string;
|
||||
items: any[];
|
||||
subtotal: number;
|
||||
taxAmount: number;
|
||||
total: number;
|
||||
dueDate: Date;
|
||||
}
|
||||
|
||||
class InvoicesService {
|
||||
async create(data: CreateInvoiceData, userId: string) {
|
||||
const invoiceNumber = await this.generateInvoiceNumber();
|
||||
const invoice = await prisma.invoice.create({
|
||||
data: {
|
||||
invoiceNumber,
|
||||
dealId: data.dealId || null,
|
||||
items: data.items,
|
||||
subtotal: data.subtotal,
|
||||
taxAmount: data.taxAmount,
|
||||
total: data.total,
|
||||
dueDate: new Date(data.dueDate),
|
||||
status: 'DRAFT',
|
||||
},
|
||||
include: {
|
||||
deal: { include: { contact: true, owner: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'INVOICE',
|
||||
entityId: invoice.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const invoice = await prisma.invoice.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
deal: { include: { contact: true, owner: true } },
|
||||
},
|
||||
});
|
||||
if (!invoice) throw new AppError(404, 'الفاتورة غير موجودة - Invoice not found');
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async findByDeal(dealId: string) {
|
||||
return prisma.invoice.findMany({
|
||||
where: { dealId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string, userId: string) {
|
||||
const invoice = await prisma.invoice.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
include: { deal: true },
|
||||
});
|
||||
await AuditLogger.log({
|
||||
entityType: 'INVOICE',
|
||||
entityId: id,
|
||||
action: 'STATUS_CHANGE',
|
||||
userId,
|
||||
changes: { status },
|
||||
});
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async recordPayment(id: string, paidAmount: number, paidDate: Date, userId: string) {
|
||||
const invoice = await prisma.invoice.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
paidAmount,
|
||||
paidDate: new Date(paidDate),
|
||||
},
|
||||
include: { deal: true },
|
||||
});
|
||||
await AuditLogger.log({
|
||||
entityType: 'INVOICE',
|
||||
entityId: id,
|
||||
action: 'PAYMENT_RECORDED',
|
||||
userId,
|
||||
changes: { paidAmount, paidDate },
|
||||
});
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<CreateInvoiceData>, userId: string) {
|
||||
const updateData: Record<string, any> = { ...data };
|
||||
if (updateData.dueDate) updateData.dueDate = new Date(updateData.dueDate);
|
||||
const invoice = await prisma.invoice.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { deal: { include: { contact: true, owner: true } } },
|
||||
});
|
||||
await AuditLogger.log({
|
||||
entityType: 'INVOICE',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
});
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private async generateInvoiceNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `INV-${year}-`;
|
||||
const last = await prisma.invoice.findFirst({
|
||||
where: { invoiceNumber: { startsWith: prefix } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { invoiceNumber: true },
|
||||
});
|
||||
let next = 1;
|
||||
if (last) {
|
||||
const parts = last.invoiceNumber.split('-');
|
||||
next = parseInt(parts[2] || '0') + 1;
|
||||
}
|
||||
return `${prefix}${next.toString().padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const invoicesService = new InvoicesService();
|
||||
Reference in New Issue
Block a user