From 6c1d67c69586505812aeb91ee027f61b59aeb7e4 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 22 Apr 2026 17:32:22 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20Phase=204:=20AI=20Usage=20Tracki?= =?UTF-8?q?ng,=20Security=20Hardening,=20and=20Risk=20Monitor=20Dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/auth/auth.controller.ts | 3 + .../modules/dashboard/dashboard.controller.ts | 5 + .../modules/dashboard/dashboard.service.ts | 22 ++ .../invoices/entities/invoice.entity.ts | 9 + .../invoices/gemini-extractor.service.ts | 17 +- .../src/modules/invoices/invoice.processor.ts | 39 +++- backend/src/modules/users/user.service.ts | 10 +- frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 4 +- .../pages/dashboard/MultiEntityDashboard.tsx | 54 ++++- .../src/pages/dashboard/RiskMonitorPage.tsx | 209 ++++++++++++++++++ 11 files changed, 361 insertions(+), 13 deletions(-) create mode 100644 frontend/src/pages/dashboard/RiskMonitorPage.tsx diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index c2c35a5..48e0bb5 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -20,6 +20,7 @@ import { LoginDto } from './dto/login.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { AuthGuard } from '@nestjs/passport'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { Throttle } from '@nestjs/throttler'; @Controller('auth') export class AuthController { @@ -36,7 +37,9 @@ export class AuthController { /** * تسجيل الدخول + * Rate limiting: 5 requests per minute */ + @Throttle({ default: { limit: 5, ttl: 60000 } }) @Post('login') @HttpCode(HttpStatus.OK) async login(@Body() dto: LoginDto) { diff --git a/backend/src/modules/dashboard/dashboard.controller.ts b/backend/src/modules/dashboard/dashboard.controller.ts index 7d9c214..8ba39a8 100644 --- a/backend/src/modules/dashboard/dashboard.controller.ts +++ b/backend/src/modules/dashboard/dashboard.controller.ts @@ -17,4 +17,9 @@ export class DashboardController { async getMultiEntityStats(@CurrentUser() user: any) { return this.dashboardService.getMultiEntityStats(user.tenantId); } + + @Get('risk-invoices') + async getRiskInvoices(@CurrentUser() user: any) { + return this.dashboardService.getRiskInvoices(user.tenantId); + } } diff --git a/backend/src/modules/dashboard/dashboard.service.ts b/backend/src/modules/dashboard/dashboard.service.ts index f936a5d..9f523c0 100644 --- a/backend/src/modules/dashboard/dashboard.service.ts +++ b/backend/src/modules/dashboard/dashboard.service.ts @@ -93,6 +93,9 @@ export class DashboardService { .select('COUNT(*)', 'total') .addSelect('SUM(CASE WHEN status = :approved THEN tax_amount ELSE 0 END)', 'totalTax') .addSelect('COUNT(CASE WHEN status = :failed THEN 1 END)', 'failedCount') + .addSelect('SUM(ai_prompt_tokens)', 'totalPromptTokens') + .addSelect('SUM(ai_completion_tokens)', 'totalCompletionTokens') + .addSelect('SUM(ai_total_cost)', 'totalAiCost') .where('invoice.company_id = :companyId', { companyId: company.id, approved: InvoiceStatus.APPROVED, @@ -107,6 +110,10 @@ export class DashboardService { totalInvoices: parseInt(stats.total) || 0, totalTax: parseFloat(stats.totalTax) || 0, failedCount: parseInt(stats.failedCount) || 0, + aiStats: { + totalTokens: (parseInt(stats.totalPromptTokens) || 0) + (parseInt(stats.totalCompletionTokens) || 0), + totalCost: parseFloat(stats.totalAiCost) || 0, + }, riskScore: this.calculateMockRiskScore(stats), // Placeholder for AI Risk Score }; }), @@ -122,4 +129,19 @@ export class DashboardService { if (failedRatio > 0.05) return 45; // Medium Risk return 12; // Low Risk } + + /** + * جلب الفواتير التي بها مخاطر (فشل التحقق أو مرفوضة) عبر كافة الشركات + */ + async getRiskInvoices(tenantId: string) { + return this.invoiceRepository.find({ + where: [ + { tenant_id: tenantId, status: InvoiceStatus.VALIDATION_FAILED }, + { tenant_id: tenantId, status: InvoiceStatus.REJECTED }, + ], + relations: ['company'], + order: { updated_at: 'DESC' }, + take: 50, + }); + } } diff --git a/backend/src/modules/invoices/entities/invoice.entity.ts b/backend/src/modules/invoices/entities/invoice.entity.ts index 9a4b536..a80e300 100644 --- a/backend/src/modules/invoices/entities/invoice.entity.ts +++ b/backend/src/modules/invoices/entities/invoice.entity.ts @@ -119,6 +119,15 @@ export class Invoice { @Column({ type: 'decimal', precision: 4, scale: 3, nullable: true }) ai_confidence_score?: number; + @Column({ type: 'int', default: 0 }) + ai_prompt_tokens!: number; + + @Column({ type: 'int', default: 0 }) + ai_completion_tokens!: number; + + @Column({ type: 'decimal', precision: 10, scale: 6, default: 0 }) + ai_total_cost!: number; + @CreateDateColumn({ type: 'timestamp' }) created_at!: Date; diff --git a/backend/src/modules/invoices/gemini-extractor.service.ts b/backend/src/modules/invoices/gemini-extractor.service.ts index 68aabec..73b81ab 100644 --- a/backend/src/modules/invoices/gemini-extractor.service.ts +++ b/backend/src/modules/invoices/gemini-extractor.service.ts @@ -120,7 +120,22 @@ export class GeminiExtractorService { const cleanedJson = responseText.replace(/```json|```/g, '').trim(); const data = JSON.parse(cleanedJson); - return data.invoices || []; + + // Get usage metadata for token tracking + const usage = result.response.usageMetadata || { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }; + + return { + invoices: data.invoices || [], + usage: { + promptTokens: usage.promptTokenCount, + completionTokens: usage.candidatesTokenCount, + totalTokens: usage.totalTokenCount, + }, + }; } catch (error: any) { this.logger.error(`AI Extraction failed: ${error.message}`); throw new InternalServerErrorException('AI Extraction failed'); diff --git a/backend/src/modules/invoices/invoice.processor.ts b/backend/src/modules/invoices/invoice.processor.ts index c7eb7d9..abd2eba 100644 --- a/backend/src/modules/invoices/invoice.processor.ts +++ b/backend/src/modules/invoices/invoice.processor.ts @@ -63,15 +63,24 @@ export class InvoiceProcessor { // 1. Update status to EXTRACTING await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.EXTRACTING }); - // 2. Extract data via Gemini (Now returns an array) - const invoicesData = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot); + // 2. Extract data via Gemini (Now returns an object with invoices and usage) + const { invoices: invoicesData, usage } = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot); if (!invoicesData || invoicesData.length === 0) { throw new Error('No invoices found in file'); } + // Calculate cost per invoice (pro-rated if multiple invoices in one file) + const costPerInvoice = this.calculateCost(usage) / invoicesData.length; + const promptTokensPerInvoice = Math.floor(usage.promptTokens / invoicesData.length); + const completionTokensPerInvoice = Math.floor(usage.completionTokens / invoicesData.length); + // 3. Process the first invoice (updates the current record) - await this.saveExtractedData(invoiceId, invoicesData[0]); + await this.saveExtractedData(invoiceId, invoicesData[0], { + promptTokens: promptTokensPerInvoice, + completionTokens: completionTokensPerInvoice, + totalCost: costPerInvoice, + }); // 4. If multiple invoices found, create new records for others if (invoicesData.length > 1) { @@ -85,7 +94,11 @@ export class InvoiceProcessor { status: InvoiceStatus.EXTRACTING, }); const savedNew = await this.invoiceRepository.save(newInvoice); - await this.saveExtractedData(savedNew.id, invoicesData[i]); + await this.saveExtractedData(savedNew.id, invoicesData[i], { + promptTokens: promptTokensPerInvoice, + completionTokens: completionTokensPerInvoice, + totalCost: costPerInvoice, + }); } } @@ -98,11 +111,23 @@ export class InvoiceProcessor { } } + /** + * حساب تكلفة استخدام Gemini بناءً على التسعيرة الحالية + */ + private calculateCost(usage: any): number { + // Gemini 1.5 Flash Pricing (as of late 2024): + // Prompt: $0.075 / 1M tokens + // Completion: $0.30 / 1M tokens + const promptCost = (usage.promptTokens / 1000000) * 0.075; + const completionCost = (usage.completionTokens / 1000000) * 0.30; + return promptCost + completionCost; + } + /** * حفظ البيانات المستخرجة في قاعدة البيانات * Save the JSON data extracted by AI into the SQL database */ - private async saveExtractedData(invoiceId: string, data: any) { + private async saveExtractedData(invoiceId: string, data: any, usage?: any) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); @@ -127,6 +152,10 @@ export class InvoiceProcessor { grand_total: data.grand_total, currency_code: data.currency_code || 'JOD', status: InvoiceStatus.EXTRACTED, + // Save AI Usage Metadata + ai_prompt_tokens: usage?.promptTokens || 0, + ai_completion_tokens: usage?.completionTokens || 0, + ai_total_cost: usage?.totalCost || 0, }); // 2. Clear old lines if any (shouldn't happen on first extract) diff --git a/backend/src/modules/users/user.service.ts b/backend/src/modules/users/user.service.ts index 1fc4dad..0559e2f 100644 --- a/backend/src/modules/users/user.service.ts +++ b/backend/src/modules/users/user.service.ts @@ -86,6 +86,14 @@ export class UsersService { } Object.assign(user, dto); - return this.userRepository.save(user); + + try { + return await this.userRepository.save(user); + } catch (error: any) { + if (error.code === '23505') { // Postgres unique violation code + throw new ConflictException('البريد الإلكتروني مسجل مسبقاً لمستخدم آخر'); + } + throw error; + } } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25df155..0d158c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { CompaniesPage } from './pages/companies/CompaniesPage'; import { StaffPage } from './pages/staff/StaffPage'; import { SettingsPage } from './pages/settings/SettingsPage'; import { MultiEntityDashboard } from './pages/dashboard/MultiEntityDashboard'; +import { RiskMonitorPage } from './pages/dashboard/RiskMonitorPage'; import { TrojanHorseConverter } from './pages/Public/TrojanHorseConverter'; @@ -33,6 +34,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ccf9259..a0fe7a0 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -12,13 +12,15 @@ import { Users, Settings, LogOut, - Crown + Crown, + AlertTriangle } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; const menuItems = [ { icon: LayoutDashboard, label: 'الرئيسية', path: '/dashboard' }, { icon: Crown, label: 'المركز الضريبي الموحد', path: '/elite-dashboard' }, + { icon: AlertTriangle, label: 'مراقبة المخاطر', path: '/risk-monitor' }, { icon: FileText, label: 'الفواتير', path: '/invoices' }, { icon: Building2, label: 'الشركات', path: '/companies' }, { icon: Users, label: 'الموظفون', path: '/staff' }, diff --git a/frontend/src/pages/dashboard/MultiEntityDashboard.tsx b/frontend/src/pages/dashboard/MultiEntityDashboard.tsx index 344d38a..888e980 100644 --- a/frontend/src/pages/dashboard/MultiEntityDashboard.tsx +++ b/frontend/src/pages/dashboard/MultiEntityDashboard.tsx @@ -11,6 +11,10 @@ interface CompanyStats { totalTax: number; failedCount: number; riskScore: number; + aiStats: { + totalTokens: number; + totalCost: number; + }; } const getRiskStatus = (score: number) => { @@ -62,6 +66,31 @@ const RiskGauge = ({ score }: { score: number }) => { ); }; +const Cpu = ({ className }: { className?: string }) => ( + + + + + + + + + + + + +); + export const MultiEntityDashboard = () => { const [companies, setCompanies] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -158,6 +187,14 @@ export const MultiEntityDashboard = () => { key={company.id} className="card-premium p-6 relative overflow-hidden group" > + {/* AI Usage Badge */} +
+
+ + {company.aiStats?.totalTokens > 1000 ? `${(company.aiStats.totalTokens / 1000).toFixed(1)}k` : company.aiStats?.totalTokens || 0} tokens +
+
+ {/* Ambient glow */}
{

{company.totalInvoices} فاتورة

- {company.failedCount > 0 && ( - - {company.failedCount} مرفوضة - - )} +
+ {company.aiStats?.totalCost > 0 && ( + + ${company.aiStats.totalCost.toFixed(3)} + + )} + {company.failedCount > 0 && ( + + {company.failedCount} مرفوضة + + )} +
{/* Progress Bar */} diff --git a/frontend/src/pages/dashboard/RiskMonitorPage.tsx b/frontend/src/pages/dashboard/RiskMonitorPage.tsx new file mode 100644 index 0000000..badb0e2 --- /dev/null +++ b/frontend/src/pages/dashboard/RiskMonitorPage.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect } from 'react'; +import { + AlertTriangle, + ShieldAlert, + FileWarning, + ChevronRight, + Search, + Filter, + Loader2, + Building2, + Calendar, + ArrowRight +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import apiClient from '../../api/client'; +import { Link } from 'react-router-dom'; + +interface RiskInvoice { + id: string; + invoice_number: string; + invoice_date: string; + status: string; + validation_errors: string[]; + grand_total: number; + company: { + name: string; + }; + updated_at: string; +} + +export const RiskMonitorPage = () => { + const [invoices, setInvoices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + const fetchRisks = async () => { + try { + const { data } = await apiClient.get('/dashboard/risk-invoices'); + setInvoices(data); + } catch (err) { + console.error('Failed to fetch risk invoices', err); + } finally { + setIsLoading(false); + } + }; + fetchRisks(); + }, []); + + const filteredInvoices = invoices.filter(inv => + inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) || + inv.company?.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (isLoading) { + return ( +
+ +

جاري تحليل المخاطر الضريبية...

+
+ ); + } + + return ( +
+ {/* Header Section */} +
+
+
+
+
+ +
+
+

مراقبة المخاطر

+

Risk & Anomaly Monitoring Center

+
+
+ +
+
+

إجمالي المخاطر

+

{invoices.length}

+
+
+
+
+
+

فشل التحقق (Validation)

+

+ {invoices.filter(i => i.status === 'validation_failed').length} +

+
+
+
+
+
+

مرفوضة من JoFotara

+

+ {invoices.filter(i => i.status === 'rejected').length} +

+
+
+
+
+
+
+
+ + {/* Main Content Card */} +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ +
+
+ +
+ + + + + + + + + + + + {filteredInvoices.map((inv, index) => ( + + + + + + + ))} + + +
الشركة والفاتورةنوع الخطرالتاريخ والقيمةالإجراء
+
+
+ +
+
+

{inv.company?.name}

+

#{inv.invoice_number || 'بدون رقم'}

+
+
+
+
+
+ + {inv.status === 'validation_failed' ? 'فشل المطابقة' : 'مرفوضة ضريبياً'} +
+ {inv.validation_errors && inv.validation_errors.length > 0 && ( +

+ {inv.validation_errors[0]} +

+ )} +
+
+
+ {Number(inv.grand_total).toFixed(3)} JOD + + {new Date(inv.invoice_date).toLocaleDateString('ar-JO')} + +
+
+ + مراجعة وتعديل + + +
+ + {filteredInvoices.length === 0 && ( +
+ +

لا توجد مخاطر مكتشفة حالياً في هذا النطاق.

+
+ )} +
+
+
+ ); +};