Update: 2026-05-08 00:26:39
This commit is contained in:
102
app/Core/AiUsageLogger.php
Normal file
102
app/Core/AiUsageLogger.php
Normal 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);
|
||||
}
|
||||
}
|
||||
67
app/modules_app/ai-usage/log.php
Normal file
67
app/modules_app/ai-usage/log.php
Normal 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),
|
||||
],
|
||||
]);
|
||||
103
app/modules_app/ai-usage/stats.php
Normal file
103
app/modules_app/ai-usage/stats.php
Normal 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,
|
||||
]);
|
||||
162
app/modules_app/invoices/submit_jofotara.php
Normal file
162
app/modules_app/invoices/submit_jofotara.php
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user