Update: 2026-05-08 00:26:39

This commit is contained in:
Hamza-Ayed
2026-05-08 00:26:40 +03:00
parent 51d1d42f75
commit 08e2a87c10
24 changed files with 1743 additions and 210 deletions

102
app/Core/AiUsageLogger.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
/**
* AI Usage Logger Service
* Records every AI API call with token counts and estimated cost.
*/
namespace App\Core;
class AiUsageLogger
{
/**
* Cost per 1M tokens (input/output) for each model.
* Update these when pricing changes.
*/
private const MODEL_PRICING = [
'gemini-1.5-flash' => [
'input' => 0.075, // $0.075 per 1M input tokens
'output' => 0.30, // $0.30 per 1M output tokens
],
'gemini-2.0-flash' => [
'input' => 0.10,
'output' => 0.40,
],
'gemini-1.5-pro' => [
'input' => 1.25,
'output' => 5.00,
],
'grok-2' => [
'input' => 2.00,
'output' => 10.00,
],
'whisper-large-v3' => [
'input' => 0.111, // $0.111 per 1M input tokens (Groq)
'output' => 0.0,
],
];
/**
* Log an AI usage event.
*
* @param string $tenantId
* @param string $actionType One of: invoice_extraction, voice_transcribe, voice_intent, report_generation, chatbot
* @param string $modelName e.g. gemini-1.5-flash
* @param int $promptTokens
* @param int $completionTokens
* @param string|null $userId
* @param string|null $companyId
* @param array|null $metadata Any extra info (invoice_id, etc.)
*/
public static function log(
string $tenantId,
string $actionType,
string $modelName,
int $promptTokens,
int $completionTokens,
?string $userId = null,
?string $companyId = null,
?array $metadata = null,
): void {
$totalTokens = $promptTokens + $completionTokens;
$estimatedCost = self::estimateCost($modelName, $promptTokens, $completionTokens);
try {
$db = Database::getInstance();
$stmt = $db->prepare(
"INSERT INTO ai_usage_log
(tenant_id, user_id, company_id, action_type, model_name,
prompt_tokens, completion_tokens, total_tokens, estimated_cost,
request_metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())"
);
$stmt->execute([
$tenantId,
$userId,
$companyId,
$actionType,
$modelName,
$promptTokens,
$completionTokens,
$totalTokens,
$estimatedCost,
$metadata ? json_encode($metadata, JSON_UNESCAPED_UNICODE) : null,
]);
} catch (\Exception $e) {
// Logging should never break the main flow
error_log('[AiUsageLogger] Failed to log: ' . $e->getMessage());
}
}
/**
* Estimate cost in USD based on model pricing.
*/
private static function estimateCost(string $model, int $inputTokens, int $outputTokens): float
{
$pricing = self::MODEL_PRICING[$model] ?? ['input' => 0.10, 'output' => 0.40];
$inputCost = ($inputTokens / 1_000_000) * $pricing['input'];
$outputCost = ($outputTokens / 1_000_000) * $pricing['output'];
return round($inputCost + $outputCost, 6);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* AI Usage Log Endpoint
* GET /api/v1/ai-usage/log
*
* Returns paginated log of all AI requests.
*/
use App\Core\Database;
use App\Middleware\RoleMiddleware;
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$db = Database::getInstance();
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(50, max(10, (int) ($_GET['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
$tenantId = $decoded['tenant_id'];
$isSuperAdmin = $decoded['role'] === 'super_admin';
$tenantCondition = $isSuperAdmin ? "" : "WHERE a.tenant_id = ?";
$params = $isSuperAdmin ? [] : [$tenantId];
// Count
$countSql = "SELECT COUNT(*) FROM ai_usage_log a $tenantCondition";
$countStmt = $db->prepare($countSql);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
// Fetch
$sql = "SELECT
a.id, a.action_type, a.model_name,
a.prompt_tokens, a.completion_tokens, a.total_tokens,
a.estimated_cost, a.created_at
FROM ai_usage_log a
$tenantCondition
ORDER BY a.created_at DESC
LIMIT $perPage OFFSET $offset";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$logs = $stmt->fetchAll(\PDO::FETCH_ASSOC);
// Translate action types
$actionLabels = [
'invoice_extraction' => 'استخراج فاتورة',
'voice_transcribe' => 'تحويل صوت لنص',
'voice_intent' => 'تحليل أمر صوتي',
'report_generation' => 'توليد تقرير',
'chatbot' => 'محادثة ذكية',
];
foreach ($logs as &$log) {
$log['action_label'] = $actionLabels[$log['action_type']] ?? $log['action_type'];
$log['estimated_cost'] = round((float) $log['estimated_cost'], 6);
}
json_success([
'logs' => $logs,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'pages' => ceil($total / $perPage),
],
]);

View File

@@ -0,0 +1,103 @@
<?php
/**
* AI Usage Stats Endpoint
* GET /api/v1/ai-usage/stats
*
* Returns AI token consumption stats for the current tenant.
* Super admin sees system-wide; admin sees their tenant only.
*/
use App\Core\Database;
use App\Middleware\RoleMiddleware;
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$db = Database::getInstance();
$period = $_GET['period'] ?? 'month'; // day, week, month, all
$tenantId = $decoded['tenant_id'];
$isSuperAdmin = $decoded['role'] === 'super_admin';
// Date range
$dateCondition = match ($period) {
'day' => "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
'week' => "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)",
'month' => "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)",
default => "",
};
$tenantCondition = $isSuperAdmin ? "" : "AND a.tenant_id = ?";
$params = $isSuperAdmin ? [] : [$tenantId];
// Totals
$sql = "SELECT
COUNT(*) as total_requests,
COALESCE(SUM(a.prompt_tokens), 0) as total_prompt_tokens,
COALESCE(SUM(a.completion_tokens), 0) as total_completion_tokens,
COALESCE(SUM(a.total_tokens), 0) as total_tokens,
COALESCE(SUM(a.estimated_cost), 0) as total_cost
FROM ai_usage_log a
WHERE 1=1 $tenantCondition $dateCondition";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$totals = $stmt->fetch(\PDO::FETCH_ASSOC);
// Breakdown by action type
$sql2 = "SELECT
a.action_type,
COUNT(*) as requests,
COALESCE(SUM(a.total_tokens), 0) as tokens,
COALESCE(SUM(a.estimated_cost), 0) as cost
FROM ai_usage_log a
WHERE 1=1 $tenantCondition $dateCondition
GROUP BY a.action_type
ORDER BY tokens DESC";
$stmt2 = $db->prepare($sql2);
$stmt2->execute($params);
$breakdown = $stmt2->fetchAll(\PDO::FETCH_ASSOC);
// Breakdown by model
$sql3 = "SELECT
a.model_name,
COUNT(*) as requests,
COALESCE(SUM(a.total_tokens), 0) as tokens,
COALESCE(SUM(a.estimated_cost), 0) as cost
FROM ai_usage_log a
WHERE 1=1 $tenantCondition $dateCondition
GROUP BY a.model_name
ORDER BY tokens DESC";
$stmt3 = $db->prepare($sql3);
$stmt3->execute($params);
$modelBreakdown = $stmt3->fetchAll(\PDO::FETCH_ASSOC);
// Daily trend (last 30 days)
$sql4 = "SELECT
DATE(a.created_at) as date,
COALESCE(SUM(a.total_tokens), 0) as tokens,
COALESCE(SUM(a.estimated_cost), 0) as cost,
COUNT(*) as requests
FROM ai_usage_log a
WHERE 1=1 $tenantCondition AND a.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(a.created_at)
ORDER BY date ASC";
$stmt4 = $db->prepare($sql4);
$stmt4->execute($params);
$dailyTrend = $stmt4->fetchAll(\PDO::FETCH_ASSOC);
json_success([
'period' => $period,
'totals' => [
'requests' => (int) $totals['total_requests'],
'prompt_tokens' => (int) $totals['total_prompt_tokens'],
'completion_tokens' => (int) $totals['total_completion_tokens'],
'total_tokens' => (int) $totals['total_tokens'],
'estimated_cost_usd' => round((float) $totals['total_cost'], 4),
],
'by_action' => $breakdown,
'by_model' => $modelBreakdown,
'daily_trend' => $dailyTrend,
]);

View File

@@ -0,0 +1,162 @@
<?php
/**
* Submit Invoice to JoFotara (Jordan E-Invoicing)
* POST /v1/invoices/submit-jofotara
*
* Generates UBL 2.1 XML, submits to JoFotara API, and records the result.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\Encryption;
use App\Core\Security;
use App\Core\JoFotara;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
$decoded = AuthMiddleware::check();
RoleMiddleware::require(['admin', 'super_admin', 'accountant']);
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$role = $decoded['role'];
$data = Security::sanitize(input());
$invoiceId = $data['invoice_id'] ?? null;
if (!$invoiceId) {
json_error('معرّف الفاتورة مطلوب', 422);
}
$db = Database::getInstance();
// 1. Fetch Invoice
$query = $role === 'super_admin'
? "SELECT i.*, c.name as company_name, c.tax_identification_number, c.jofotara_client_id, c.jofotara_secret_key, c.address as company_address
FROM invoices i JOIN companies c ON i.company_id = c.id WHERE i.id = ?"
: "SELECT i.*, c.name as company_name, c.tax_identification_number, c.jofotara_client_id, c.jofotara_secret_key, c.address as company_address
FROM invoices i JOIN companies c ON i.company_id = c.id WHERE i.id = ? AND i.tenant_id = ?";
$params = $role === 'super_admin' ? [$invoiceId] : [$invoiceId, $tenantId];
$stmt = $db->prepare($query);
$stmt->execute($params);
$invoice = $stmt->fetch();
if (!$invoice) {
json_error('الفاتورة غير موجودة أو ليس لديك صلاحية', 404);
}
if ($invoice['status'] !== 'approved') {
json_error('يجب اعتماد الفاتورة أولاً قبل إرسالها لجوفتورة', 400);
}
// 2. Check if already submitted
$stmtCheck = $db->prepare("SELECT id FROM jofotara_submissions WHERE invoice_id = ? AND status = 'accepted'");
$stmtCheck->execute([$invoiceId]);
if ($stmtCheck->fetch()) {
json_error('تم إرسال هذه الفاتورة لجوفتورة مسبقاً', 400);
}
// 3. Verify JoFotara credentials
$clientId = $invoice['jofotara_client_id'] ?? '';
$secretKey = $invoice['jofotara_secret_key'] ?? '';
if (empty($clientId) || empty($secretKey)) {
json_error('يجب ربط الشركة بمنظومة جوفتورة أولاً (Client ID + Secret Key)', 422);
}
// 4. Decrypt sensitive fields for XML generation
$dec = function($val) {
if (empty($val)) return '';
$result = Encryption::decrypt((string)$val);
return ($result !== false && $result !== null) ? $result : (string)$val;
};
// Prepare invoice data for XML
$invoiceData = [
'invoice_number' => $invoice['invoice_number'],
'invoice_date' => $invoice['invoice_date'],
'invoice_type' => $invoice['invoice_type'],
'invoice_category' => $invoice['invoice_category'] ?? 'simplified',
'ubl_type_code' => $invoice['ubl_type_code'] ?? '388',
'payment_method_code' => $invoice['payment_method_code'] ?? '013',
'buyer_name' => $dec($invoice['buyer_name']),
'buyer_tin' => $dec($invoice['buyer_tin']),
'buyer_national_id' => $dec($invoice['buyer_national_id']),
'subtotal' => (float)$invoice['subtotal'],
'tax_amount' => (float)$invoice['tax_amount'],
'discount_total' => (float)$invoice['discount_total'],
'grand_total' => (float)$invoice['grand_total'],
];
// Fetch line items
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
$stmtLines->execute([$invoiceId]);
$invoiceData['items'] = $stmtLines->fetchAll();
$companyData = [
'name' => $invoice['company_name'],
'tax_identification_number' => $invoice['tax_identification_number'],
'address' => $invoice['company_address'] ?? '',
];
// 5. Generate XML
$jofotara = new JoFotara();
$xml = $jofotara->generateXML($invoiceData, $companyData);
// 6. Submit to JoFotara API
$result = $jofotara->submitInvoice($xml, $clientId, $secretKey);
// 7. Record submission
$submissionId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
$stmt = $db->prepare("
INSERT INTO jofotara_submissions (id, invoice_id, tenant_id, company_id, jofotara_uuid, xml_content, status, qr_code_raw, response_body, submitted_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$stmt->execute([
$submissionId,
$invoiceId,
$tenantId,
$invoice['company_id'],
$result['uuid'] ?? null,
$xml,
$result['success'] ? 'accepted' : 'rejected',
$result['qrCode'] ?? null,
json_encode($result['raw'] ?? [], JSON_UNESCAPED_UNICODE),
]);
// 8. Update invoice status if accepted
if ($result['success']) {
$db->prepare("UPDATE invoices SET status = 'submitted', jofotara_uuid = ? WHERE id = ?")
->execute([$result['uuid'], $invoiceId]);
// Generate local QR code
$qrBase64 = $jofotara->generateQRCode([
'supplier_name' => $companyData['name'],
'supplier_tin' => $companyData['tax_identification_number'],
'invoice_date' => $invoiceData['invoice_date'],
'grand_total' => $invoiceData['grand_total'],
'tax_amount' => $invoiceData['tax_amount'],
]);
$db->prepare("UPDATE invoices SET qr_code = ? WHERE id = ?")->execute([$qrBase64, $invoiceId]);
AuditLogger::log('invoice.submitted_jofotara', 'invoice', $invoiceId, null, [
'jofotara_uuid' => $result['uuid'],
], $decoded);
json_success([
'uuid' => $result['uuid'],
'qr_code' => $qrBase64,
'status' => 'accepted',
], 'تم إرسال الفاتورة لجوفتورة بنجاح');
} else {
AuditLogger::log('invoice.jofotara_rejected', 'invoice', $invoiceId, null, [
'error' => $result['error'] ?? 'Unknown',
], $decoded);
json_error('رُفضت الفاتورة من جوفتورة: ' . ($result['error'] ?? 'خطأ غير محدد'), 422);
}