Update: 2026-05-06 01:38:39

This commit is contained in:
Hamza-Ayed
2026-05-06 01:38:39 +03:00
parent c63d9944ee
commit 97ff911751
13 changed files with 2170 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
<?php
/**
* Mobile OTP Request Endpoint
* POST /v1/auth/mobile/request-otp
*
* Sends an OTP to the user's registered phone number.
* The phone must already be registered by an admin in the web dashboard.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\Validator;
use App\Core\Security;
use App\Middleware\RateLimitMiddleware;
// Rate limit: 3 OTP requests per minute per IP
RateLimitMiddleware::check(3, 60);
$data = Security::sanitize(input());
// 1. Validate
$errors = Validator::validate($data, [
'phone' => 'required',
]);
if ($errors) {
json_error('رقم الهاتف مطلوب', 422, $errors);
}
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phoneHash = hash('sha256', $phone);
// 2. Find user by phone hash
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone_hash = ? LIMIT 1");
$stmt->execute([$phoneHash]);
$user = $stmt->fetch();
if (!$user) {
// Don't reveal if phone exists — generic message
json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق');
exit;
}
if (!$user['is_active']) {
json_error('الحساب معطّل. تواصل مع المسؤول.', 403);
}
// 3. Generate OTP (6 digits)
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
$otpHash = password_hash($otp, PASSWORD_DEFAULT);
$expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes
// 4. Store OTP in database (or Redis if available)
// Using a simple approach: store in a cache file per phone
$cacheDir = STORAGE_PATH . '/cache/otp';
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$otpData = [
'hash' => $otpHash,
'user_id' => $user['id'],
'attempts' => 0,
'max_attempts' => 5,
'expires_at' => time() + 300,
'created_at' => time(),
];
$fp = fopen($cacheDir . '/otp_' . $phoneHash . '.json', 'w');
if ($fp) {
flock($fp, LOCK_EX);
fwrite($fp, json_encode($otpData));
flock($fp, LOCK_UN);
fclose($fp);
}
// 5. Send OTP via SMS
// TODO: Replace with your actual SMS provider
$smsSent = sendOtpSms($phone, $otp);
if (!$smsSent) {
error_log("WARN: Failed to send OTP SMS to phone hash: {$phoneHash}");
// Still return success to not reveal info, but log the issue
}
// Log for development (REMOVE IN PRODUCTION!)
if (env('APP_DEBUG', 'false') === 'true') {
error_log("DEV OTP for {$phone}: {$otp}");
}
json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق');
// ─── SMS Helper ──────────────────────────────────────────
function sendOtpSms(string $phone, string $otp): bool
{
$smsProvider = env('SMS_PROVIDER', 'log'); // 'log', 'twilio', 'jordan_sms', 'custom'
$message = "رمز التحقق لتطبيق مُصادَق: {$otp}\nصالح لمدة 5 دقائق.";
switch ($smsProvider) {
case 'custom':
// Custom SMS API (your own provider)
$apiUrl = env('SMS_API_URL');
$apiKey = env('SMS_API_KEY');
if (!$apiUrl || !$apiKey) return false;
try {
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'to' => $phone,
'message' => $message,
'api_key' => $apiKey,
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode >= 200 && $httpCode < 300;
} catch (\Exception $e) {
error_log("SMS send error: " . $e->getMessage());
return false;
}
case 'log':
default:
// Development: just log the OTP
error_log("SMS OTP [{$phone}]: {$otp}");
return true;
}
}

View File

@@ -0,0 +1,174 @@
<?php
/**
* Mobile OTP Verify Endpoint
* POST /v1/auth/mobile/verify-otp
*
* Verifies OTP, registers device, and returns JWT + device secret for HMAC.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\JWT;
use App\Core\Validator;
use App\Core\Security;
use App\Middleware\RateLimitMiddleware;
// Rate limit: 10 verify attempts per minute per IP
RateLimitMiddleware::check(10, 60);
$data = Security::sanitize(input());
// 1. Validate
$errors = Validator::validate($data, [
'phone' => 'required',
'otp' => 'required',
]);
if ($errors) {
json_error('رقم الهاتف ورمز التحقق مطلوبان', 422, $errors);
}
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phoneHash = hash('sha256', $phone);
$deviceId = $data['device_id'] ?? '';
$deviceName = $data['device_name'] ?? 'Unknown Device';
$platform = $data['platform'] ?? 'android';
$appVersion = $data['app_version'] ?? '1.0.0';
$pushToken = $data['push_token'] ?? null;
if (empty($deviceId)) {
json_error('معرّف الجهاز مطلوب', 422);
}
// 2. Load OTP from cache
$cacheFile = STORAGE_PATH . '/cache/otp/otp_' . $phoneHash . '.json';
if (!file_exists($cacheFile)) {
json_error('رمز التحقق غير صالح أو منتهي الصلاحية', 401);
}
$fp = fopen($cacheFile, 'r+');
if (!$fp) {
json_error('خطأ في النظام', 500);
}
flock($fp, LOCK_EX);
$content = stream_get_contents($fp);
$otpData = json_decode($content, true);
if (!$otpData || $otpData['expires_at'] < time()) {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($cacheFile);
json_error('رمز التحقق منتهي الصلاحية. اطلب رمزاً جديداً.', 401);
}
// Check attempts
if ($otpData['attempts'] >= $otpData['max_attempts']) {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($cacheFile);
json_error('تجاوزت عدد المحاولات المسموحة. اطلب رمزاً جديداً.', 429);
}
// Verify OTP
if (!password_verify($data['otp'], $otpData['hash'])) {
$otpData['attempts']++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($otpData));
flock($fp, LOCK_UN);
fclose($fp);
$remaining = $otpData['max_attempts'] - $otpData['attempts'];
json_error("رمز التحقق غير صحيح. المحاولات المتبقية: {$remaining}", 401);
}
// OTP is valid — clean up
flock($fp, LOCK_UN);
fclose($fp);
@unlink($cacheFile);
// 3. Fetch user
$db = Database::getInstance();
$userId = $otpData['user_id'];
$stmt = $db->prepare("SELECT id, tenant_id, name, email, role, is_active FROM users WHERE id = ? LIMIT 1");
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user || !$user['is_active']) {
json_error('الحساب غير موجود أو معطّل', 403);
}
// 4. Generate device secret for HMAC
$deviceSecret = hash('sha256', $userId . $deviceId . bin2hex(random_bytes(16)));
// 5. Register/Update device
$stmt = $db->prepare("
INSERT INTO user_devices (id, user_id, device_fingerprint, device_name, platform, app_version, push_token, device_secret, is_trusted, last_seen_at)
VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, TRUE, NOW())
ON DUPLICATE KEY UPDATE
device_name = VALUES(device_name),
platform = VALUES(platform),
app_version = VALUES(app_version),
push_token = VALUES(push_token),
device_secret = VALUES(device_secret),
is_trusted = TRUE,
last_seen_at = NOW(),
updated_at = NOW()
");
$stmt->execute([
$userId,
$deviceId,
$deviceName,
$platform,
$appVersion,
$pushToken,
password_hash($deviceSecret, PASSWORD_DEFAULT), // Store hashed
]);
// 6. Generate JWT (30 days for mobile)
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env');
json_error('Server configuration error', 500);
}
$payload = [
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'device_id' => $deviceId,
'source' => 'mobile',
'exp' => time() + (30 * 24 * 3600), // 30 days
];
$token = JWT::encode($payload, $secret);
// 7. Generate refresh token
$refreshToken = bin2hex(random_bytes(32));
$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
$stmt->execute([$refreshTokenHash, $userId]);
// 8. Decrypt name for response
$userName = $user['name'];
try {
$decrypted = \App\Core\Encryption::decrypt($user['name']);
if ($decrypted !== false) $userName = $decrypted;
} catch (\Exception $e) {
// Keep encrypted name
}
json_success([
'access_token' => $token,
'refresh_token' => $refreshToken,
'device_secret' => $deviceSecret, // Client stores this securely for HMAC
'user' => [
'id' => $user['id'],
'name' => $userName,
'role' => $user['role'],
'tenant_id' => $user['tenant_id'],
],
], 'تم التحقق بنجاح. مرحباً بك في مُصادَق!');

View File

@@ -0,0 +1,60 @@
<?php
/**
* Register/Update Device Endpoint
* POST /v1/auth/mobile/register-device
*
* Updates push token and device info for an already-authenticated device.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
$decoded = AuthMiddleware::check();
$userId = $decoded['user_id'];
$deviceId = $decoded['device_id'] ?? null;
if (!$deviceId) {
json_error('هذا الـ endpoint مخصص لتطبيق الهاتف فقط', 403);
}
$data = Security::sanitize(input());
$db = Database::getInstance();
$updateFields = [];
$params = [];
if (isset($data['push_token'])) {
$updateFields[] = 'push_token = ?';
$params[] = $data['push_token'];
}
if (isset($data['app_version'])) {
$updateFields[] = 'app_version = ?';
$params[] = $data['app_version'];
}
if (isset($data['device_name'])) {
$updateFields[] = 'device_name = ?';
$params[] = $data['device_name'];
}
// Always update last_seen
$updateFields[] = 'last_seen_at = NOW()';
if (empty($updateFields)) {
json_success(null, 'لا يوجد بيانات للتحديث');
exit;
}
$sql = "UPDATE user_devices SET " . implode(', ', $updateFields) . " WHERE user_id = ? AND device_fingerprint = ?";
$params[] = $userId;
$params[] = $deviceId;
$stmt = $db->prepare($sql);
$stmt->execute($params);
json_success(null, 'تم تحديث بيانات الجهاز');

View File

@@ -0,0 +1,72 @@
<?php
/**
* Create Batch Endpoint
* POST /v1/batches/create
*
* Creates a new invoice batch for the mobile scanner.
* Returns batch_id that the mobile app uses to upload images.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
use App\Core\Validator;
use App\Middleware\QuotaMiddleware;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$data = Security::sanitize(input());
// 1. Validate
$errors = Validator::validate($data, [
'company_id' => 'required',
]);
if ($errors) {
json_error('رقم الشركة مطلوب', 422, $errors);
}
$companyId = $data['company_id'];
$source = $data['source'] ?? 'mobile_scan';
$expectedImages = (int)($data['expected_images'] ?? 0);
// 2. Permission check
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId, $tenantId]);
if (!$stmt->fetch()) {
json_error('الوصول مرفوض لهذه الشركة', 403);
}
// 3. Check quota (preview — don't increment yet)
try {
QuotaMiddleware::checkInvoiceQuota($tenantId);
} catch (\Exception $e) {
json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429);
}
// 4. Generate batch ID
$batchId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
// 5. Create batch record
$stmt = $db->prepare("
INSERT INTO invoice_batches (id, tenant_id, company_id, uploaded_by, total_images, source, status)
VALUES (?, ?, ?, ?, ?, ?, 'uploading')
");
$stmt->execute([$batchId, $tenantId, $companyId, $userId, $expectedImages, $source]);
// 6. Create upload directory
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
json_success([
'batch_id' => $batchId,
'upload_url' => 'v1/batches/upload-image',
], 'تم إنشاء الدفعة بنجاح. ابدأ برفع الصور.');

View File

@@ -0,0 +1,65 @@
<?php
/**
* Finalize Batch Endpoint
* POST /v1/batches/finalize
*
* Marks a batch as ready for processing.
* Triggers background processing (or processes synchronously depending on setup).
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$data = Security::sanitize(input());
$batchId = $data['batch_id'] ?? null;
if (!$batchId) {
json_error('معرّف الدفعة مطلوب', 422);
}
$db = Database::getInstance();
// 1. Verify batch
$stmt = $db->prepare("
SELECT id, status, total_images
FROM invoice_batches
WHERE id = ? AND tenant_id = ? AND uploaded_by = ?
");
$stmt->execute([$batchId, $tenantId, $userId]);
$batch = $stmt->fetch();
if (!$batch) {
json_error('الدفعة غير موجودة', 404);
}
if ($batch['status'] !== 'uploading') {
json_error('تم إنهاء هذه الدفعة مسبقاً', 400);
}
if ($batch['total_images'] == 0) {
json_error('لا يمكن إنهاء دفعة فارغة', 400);
}
// 2. Mark as processing
$stmt = $db->prepare("
UPDATE invoice_batches
SET status = 'processing', updated_at = NOW()
WHERE id = ?
");
$stmt->execute([$batchId]);
// In a real production environment, you would dispatch a job to a queue worker here.
// For now, the queue worker is a cron job that checks the `invoice_processing_queue` table.
json_success([
'batch_id' => $batchId,
'status' => 'processing',
'total_images' => $batch['total_images']
], 'تم إنهاء الدفعة بنجاح وإرسالها للمعالجة');

View File

@@ -0,0 +1,54 @@
<?php
/**
* Batch Status Endpoint
* GET /v1/batches/status
*
* Returns the processing status of a batch and its items.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$data = Security::sanitize($_GET);
$batchId = $data['batch_id'] ?? null;
if (!$batchId) {
json_error('معرّف الدفعة مطلوب', 422);
}
$db = Database::getInstance();
// 1. Get batch info
$stmt = $db->prepare("
SELECT id, status, total_images, processed_images, failed_images, created_at, completed_at
FROM invoice_batches
WHERE id = ? AND tenant_id = ?
");
$stmt->execute([$batchId, $tenantId]);
$batch = $stmt->fetch();
if (!$batch) {
json_error('الدفعة غير موجودة', 404);
}
// 2. Get items
$stmt = $db->prepare("
SELECT id, invoice_id, image_order, status, error_message, created_at, processed_at
FROM invoice_processing_queue
WHERE batch_id = ?
ORDER BY image_order ASC
");
$stmt->execute([$batchId]);
$items = $stmt->fetchAll();
json_success([
'batch' => $batch,
'items' => $items
], 'تم جلب حالة الدفعة');

View File

@@ -0,0 +1,97 @@
<?php
/**
* Upload Image to Batch
* POST /v1/batches/upload-image
*
* Uploads a single image to an existing batch.
* Supports multipart/form-data with 'image' file and 'batch_id'.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
// 1. Validate request
$batchId = $_POST['batch_id'] ?? null;
$imageOrder = (int)($_POST['image_order'] ?? 0);
if (!$batchId || !isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
$uploadError = $_FILES['image']['error'] ?? 'No file';
json_error("معرّف الدفعة وصورة الفاتورة مطلوبان (كود: {$uploadError})", 422);
}
// 2. Verify batch belongs to this tenant and is still uploading
$db = Database::getInstance();
$stmt = $db->prepare("
SELECT id, company_id, status, total_images
FROM invoice_batches
WHERE id = ? AND tenant_id = ? AND uploaded_by = ?
");
$stmt->execute([$batchId, $tenantId, $userId]);
$batch = $stmt->fetch();
if (!$batch) {
json_error('الدفعة غير موجودة أو ليس لديك صلاحية', 404);
}
if ($batch['status'] !== 'uploading') {
json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400);
}
// 3. Validate file type
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
$mimeType = $_FILES['image']['type'];
if (!in_array($mimeType, $allowedTypes)) {
json_error('نوع الملف غير مدعوم. المسموح: JPEG, PNG, WebP, HEIC', 422);
}
// 4. Validate file size (max 10MB)
$maxSize = 10 * 1024 * 1024;
if ($_FILES['image']['size'] > $maxSize) {
json_error('حجم الصورة أكبر من 10 ميغابايت', 422);
}
// 5. Save file
$companyId = $batch['company_id'];
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$extension = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION) ?: 'jpg';
$fileName = sprintf('img_%03d_%s.%s', $imageOrder, bin2hex(random_bytes(4)), $extension);
$targetPath = $uploadDir . '/' . $fileName;
if (!move_uploaded_file($_FILES['image']['tmp_name'], $targetPath)) {
json_error('فشل في حفظ الصورة', 500);
}
// 6. Add to processing queue
$stmt = $db->prepare("
INSERT INTO invoice_processing_queue (batch_id, tenant_id, company_id, image_path, image_order, status)
VALUES (?, ?, ?, ?, ?, 'pending')
");
$stmt->execute([$batchId, $tenantId, $companyId, $targetPath, $imageOrder]);
// 7. Update batch image count
$stmt = $db->prepare("
UPDATE invoice_batches
SET total_images = total_images + 1, updated_at = NOW()
WHERE id = ?
");
$stmt->execute([$batchId]);
// Count uploaded so far
$stmt = $db->prepare("SELECT COUNT(*) FROM invoice_processing_queue WHERE batch_id = ?");
$stmt->execute([$batchId]);
$uploadedCount = (int)$stmt->fetchColumn();
json_success([
'uploaded' => $uploadedCount,
'file_name' => $fileName,
], "تم رفع الصورة بنجاح ({$uploadedCount} صور في الدفعة)");

View File

@@ -0,0 +1,103 @@
<?php
/**
* Voice Parse Intent Proxy Endpoint
* POST /v1/voice/parse-intent
*
* Proxies transcribed text to Gemini to extract intent and parameters.
*/
declare(strict_types=1);
use App\Middleware\AuthMiddleware;
use App\Middleware\RateLimitMiddleware;
use App\Core\Security;
use App\Core\Validator;
// Rate limit: 20 per minute
RateLimitMiddleware::check(20, 60);
$decoded = AuthMiddleware::check();
$data = Security::sanitize(input());
$errors = Validator::validate($data, ['text' => 'required']);
if ($errors) {
json_error('النص مطلوب', 422);
}
$apiKey = env('GEMINI_API_KEY');
if (!$apiKey) {
json_error('Gemini API Key غير متوفر', 500);
}
$text = $data['text'];
$systemPrompt = <<<PROMPT
أنت محلل أوامر لنظام مُصادَق للفوترة الأردني.
استخرج النية والمعاملات من النص وأرجع JSON فقط.
الأوامر المتاحة:
- list_invoices: { company?: string, from?: date, to?: date, status?: string }
- check_quota: {}
- open_scanner: { company?: string }
- search_invoice: { amount?: number, company?: string, number?: string }
- get_report: { type: "tax"|"monthly", period?: string }
- check_status: { invoice_id?: string, company?: string }
- export_pdf: { invoice_id?: string, company?: string }
- navigate: { screen: string }
أرجع JSON بهذا التنسيق:
{
"action": "...",
"params": {...},
"confirmation": "نص قصير تأكيد بالعامية الأردنية أو الفصحى المبسطة"
}
PROMPT;
$payload = [
'contents' => [
['parts' => [['text' => $text]]]
],
'systemInstruction' => [
'parts' => [['text' => $systemPrompt]]
],
'generationConfig' => [
'responseMimeType' => 'application/json',
'temperature' => 0.2
]
];
// Determine appropriate endpoint based on env
$model = env('GEMINI_MODEL', 'gemini-1.5-flash');
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json']
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Gemini Error: $response | $error");
json_error('فشل في تحليل الأمر', 500);
}
$respData = json_decode($response, true);
if (!isset($respData['candidates'][0]['content']['parts'][0]['text'])) {
json_error('رد غير متوقع من AI', 500);
}
$jsonText = $respData['candidates'][0]['content']['parts'][0]['text'];
$parsed = json_decode($jsonText, true);
if (!$parsed) {
json_error('فشل في تحليل الرد كـ JSON', 500);
}
json_success($parsed, 'تم تحليل الأمر');

View File

@@ -0,0 +1,61 @@
<?php
/**
* Voice Transcribe Proxy Endpoint
* POST /v1/voice/transcribe
*
* Proxies audio file to Groq STT (Whisper) safely keeping API keys on backend.
*/
declare(strict_types=1);
use App\Middleware\AuthMiddleware;
use App\Middleware\RateLimitMiddleware;
// Rate limit: 20 per minute
RateLimitMiddleware::check(20, 60);
$decoded = AuthMiddleware::check();
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
json_error('ملف الصوت مطلوب', 422);
}
$apiKey = env('GROQ_API_KEY');
if (!$apiKey) {
json_error('Groq API Key غير متوفر', 500);
}
// Ensure it's a valid audio file (basic check)
$tmpPath = $_FILES['audio']['tmp_name'];
$cfile = curl_file_create($tmpPath, $_FILES['audio']['type'], $_FILES['audio']['name']);
$postData = [
'file' => $cfile,
'model' => 'whisper-large-v3',
'language' => 'ar',
'response_format' => 'json'
];
$ch = curl_init('https://api.groq.com/openai/v1/audio/transcriptions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Groq Error: $response | $error");
json_error('فشل في تحويل الصوت إلى نص', 500);
}
$data = json_decode($response, true);
json_success(['text' => $data['text'] ?? ''], 'تم التحويل بنجاح');

287
musadaq_strategic_ideas.md Normal file
View File

@@ -0,0 +1,287 @@
# 💡 أفكار استراتيجية لتطبيق مُصادَق — خارج الصندوق
## رفع الميزة التنافسية والقيمة التسويقية
---
> [!IMPORTANT]
> هذه الأفكار مرتبة حسب **التأثير التسويقي × سهولة التنفيذ**. الأفكار في الأعلى هي الأسرع تأثيراً.
---
## 🏆 الفئة 1: أفكار تسويقية قاتلة (High Impact — Quick Win)
### 1. 🟢 تكامل WhatsApp Business API — "فاتورتك على واتساب"
**الفكرة:** بدل ما المحاسب يفتح التطبيق كل مرة، يرسل صورة الفاتورة على رقم واتساب مُصادَق → النظام يستخرج البيانات تلقائياً → يرد عليه بملخص + حالة الفاتورة.
**لماذا قاتلة؟**
- 95% من المحاسبين الأردنيين يستخدمون واتساب يومياً
- لا يحتاج تحميل تطبيق جديد للبدء (يقلل حاجز الدخول)
- **شعار تسويقي:** *"صوّر الفاتورة وأرسلها على واتساب — وإحنا بنكمل الباقي"*
**التنفيذ:**
- WhatsApp Business API (عبر Twilio أو 360dialog)
- Webhook يستقبل الصور → يمررها لنفس `InvoiceExtractionService`
- يرد برسالة فيها: رقم الفاتورة، المبلغ، حالة JoFotara
**التأثير التسويقي:** ⭐⭐⭐⭐⭐
---
### 2. 🏅 نظام Gamification للمحاسبين — "المحاسب المتميز"
**الفكرة:** نقاط ومستويات وشارات للمحاسبين بناءً على أدائهم:
| الإنجاز | الشارة | النقاط |
|---------|--------|--------|
| أول 100 فاتورة بدون أخطاء | 🥇 محاسب دقيق | 500 |
| رفع 50 فاتورة في يوم واحد | ⚡ محاسب سريع | 300 |
| 0% رفض من JoFotara لمدة شهر | ✅ متوافق 100% | 1000 |
| استخدام المساعد الصوتي 30 مرة | 🎤 محاسب ذكي | 200 |
**لماذا؟**
- يخلق **ولاء** (Retention) — المحاسب ما بيترك التطبيق لأنه بنى "سمعة"
- يمكن ربطها بخصومات على الاشتراك أو ميزات إضافية
- **شعار:** *"مُصادَق مش بس يسهّل شغلك — بيكافئك كمان"*
**التأثير التسويقي:** ⭐⭐⭐⭐
---
### 3. 📊 "تقرير صحة الشركة" الشهري التلقائي — AI Health Report
**الفكرة:** كل نهاية شهر، النظام يولّد تقرير PDF تلقائي فيه:
- نسبة الامتثال الضريبي (JoFotara Compliance Score)
- عدد الفواتير المرفوضة وأسبابها
- توقعات الضريبة المستحقة
- مقارنة مع الشهر السابق (تحسن/تراجع)
- توصيات AI لتحسين الأداء
**لماذا؟**
- المحاسب يرسل هذا التقرير لصاحب الشركة → **صاحب الشركة يشوف قيمة مُصادَق مباشرة**
- يخلق "لحظة WOW" شهرية تجدد الالتزام بالاشتراك
- **شعار:** *"مُصادَق يعطيك تقرير صحة شركتك — قبل ما المدقق يوصل"*
**التأثير التسويقي:** ⭐⭐⭐⭐⭐
---
### 4. 🔗 نظام الإحالة (Referral Program) — "ادعُ محاسب واحصل على شهر مجاني"
**الفكرة:**
- كل مستخدم عنده رابط/كود إحالة فريد
- إذا سجّل محاسب جديد عبر الرابط → المُحيل يحصل على **شهر مجاني** أو **500 فاتورة إضافية**
- المُحال يحصل على **أسبوعين تجربة مجانية** بدل أسبوع
**لماذا؟**
- المحاسبين في الأردن مجتمع صغير ومتواصل — الكلام ينتشر بسرعة
- تكلفة اكتساب العميل (CAC) تنخفض بشكل كبير
- **شعار:** *"شارك مُصادَق مع زملائك — وخلّي اشتراكك يدفع حاله"*
**التأثير التسويقي:** ⭐⭐⭐⭐⭐
---
## 🚀 الفئة 2: ميزات تقنية تنافسية (Competitive Moat)
### 5. 🔔 Smart Notifications — "التطبيق اللي بيحكيك قبل ما تسأل"
**الفكرة:** نظام إشعارات ذكي مبني على AI:
-**"عندك 12 فاتورة ما انرفعت من 3 أيام — بدك أرفعهم؟"**
- ⚠️ **"شركة X عندها 5 فواتير مرفوضة — اضغط لمراجعتها"**
- 📅 **"باقي 3 أيام على نهاية الشهر — عندك 8 فواتير بانتظار الإرسال"**
- 💰 **"رصيدك المتبقي 50 فاتورة — ترقّي باقتك الآن بخصم 20%"**
- 🎉 **"مبروك! هذا الشهر نسبة امتثالك 98% — أعلى من 85% من المستخدمين"**
**لماذا؟**
- تحول التطبيق من "أداة" إلى "مساعد شخصي" — وهذا فرق تسويقي ضخم
- تزيد الـ Daily Active Users بشكل كبير
---
### 6. 📸 AR Invoice Scanner — "وجّه الكاميرا وخلّص"
**الفكرة:** بدل ما المحاسب يصور ويضغط → الكاميرا تكشف الفاتورة تلقائياً وترسم إطار حولها → تستخرج البيانات **لحظياً** على الشاشة (Augmented Reality overlay).
**التنفيذ:**
- ML Kit (Google) أو Apple Vision Framework
- عرض البيانات المستخرجة فوق الصورة مباشرة (اسم المورد، المبلغ، الضريبة)
- المحاسب يأكد بضغطة واحدة
**لماذا؟**
- **لحظة WOW** عند العرض التجريبي (Demo) — تبيع نفسها
- لا يوجد منافس أردني يقدم هذه الميزة
- **شعار:** *"وجّه الكاميرا — وشوف الفاتورة تنقرأ قدام عينك"*
**التأثير التسويقي:** ⭐⭐⭐⭐⭐ (للعروض التقديمية والتسويق)
---
### 7. 📱 Offline-First مع مزامنة ذكية — "اشتغل بدون إنترنت"
**الفكرة (موجودة في خطتك لكن نوسّعها تسويقياً):**
- المحاسب يصور 100 فاتورة في الميدان بدون إنترنت
- كل شي يتخزن محلياً مع تشفير
- بمجرد توفر WiFi → مزامنة تلقائية مع شريط تقدم
**الإضافة التسويقية:**
- عدّاد على الشاشة الرئيسية: **"15 فاتورة بانتظار المزامنة ⏳"**
- إشعار: **"تم مزامنة 47 فاتورة بنجاح ✅ — صرلك شغال من 3 ساعات بدون إنترنت"**
- **شعار:** *"مُصادَق يشتغل حتى لو الإنترنت ما اشتغل"*
---
### 8. 🤖 Chatbot محاسبي داخل التطبيق — "اسأل مُصادَق"
**الفكرة:** بدل المساعد الصوتي فقط، chatbot نصي داخل التطبيق:
- "كم ضريبة المبيعات على الأجهزة الإلكترونية؟" → **16%**
- "شو الحد الأقصى للإعفاء الضريبي للأدوية؟" → **معفاة بالكامل**
- "كيف أسجل فاتورة مرتجع؟" → خطوات + رابط مباشر
- "شو حالة آخر دفعة لشركة X؟" → يعرض التفاصيل
**لماذا؟**
- يقلل الحاجة للدعم الفني بشكل كبير
- يجعل المحاسب المبتدئ يشعر بالثقة
- مبني على Gemini — نفس البنية التحتية اللي عندنا
---
## 🌟 الفئة 3: أفكار للنمو والتوسع (Growth & Scale)
### 9. 🏢 "Marketplace" للمحاسبين — ربط أصحاب الشركات بالمحاسبين
**الفكرة:** قسم داخل المنصة يسمح لـ:
- **أصحاب الشركات** بنشر طلب "أحتاج محاسب لإدارة فواتيري"
- **المحاسبين المسجلين** بعرض خدماتهم مع تقييماتهم (من نظام Gamification)
**لماذا؟**
- يحول مُصادَق من "أداة" إلى **"منصة" (Platform)** — وهذا يرفع التقييم (Valuation) بشكل كبير
- يخلق Network Effect — كل مستخدم جديد يزيد قيمة المنصة لكل المستخدمين
- **شعار:** *"مُصادَق — المنصة اللي بتوصلك بأفضل محاسب"*
**التأثير التسويقي:** ⭐⭐⭐⭐⭐ (طويل المدى)
---
### 10. 📊 لوحة "المقارنة المعيارية" (Benchmarking Dashboard)
**الفكرة:** تعرض للمحاسب/صاحب الشركة:
- "شركتك ترفع فواتير أسرع من **73%** من الشركات المشابهة"
- "نسبة الرفض عندك **3%** — المعدل العام **8%**"
- "أنت تستخدم **6 من 10** ميزات مُصادَق — جرّب الباقي"
**لماذا؟**
- يخلق شعور بالمنافسة والتحسين المستمر
- بيانات مجمّعة ومجهولة الهوية (anonymized) — لا تكشف بيانات أحد
- تحفّز الترقية لباقات أعلى
---
### 11. 🔐 "ختم مُصادَق" الرقمي (Musadaq Verified Seal)
**الفكرة:** شارة/ختم رقمي يظهر على فواتير الشركة:
- **"هذه الفاتورة تمت معالجتها والتحقق منها عبر منصة مُصادَق ✅"**
- QR Code على الفاتورة المطبوعة يوصل لصفحة تحقق
- يعمل كـ **Trust Badge** مثل SSL Certificate للمواقع
**لماذا؟**
- يعطي الشركة مصداقية إضافية أمام عملائها
- تسويق مجاني — كل فاتورة مطبوعة عليها شعار مُصادَق
- **شعار:** *"فاتورة مختومة بـ مُصادَق = فاتورة موثوقة"*
---
### 12. 📱 Widget للشاشة الرئيسية — "فواتيرك على اللوك سكرين"
**الفكرة:** Widget لـ iOS و Android على الشاشة الرئيسية يعرض:
- عدد الفواتير المعلقة
- رصيد الباقة المتبقي
- زر "صوّر فاتورة" مباشر
**لماذا؟**
- يبقي التطبيق "حاضر" دائماً أمام المستخدم
- يقلل الخطوات لبدء العمل (من 4 نقرات إلى 1)
---
### 13. 🎓 "أكاديمية مُصادَق" — محتوى تعليمي مدمج
**الفكرة:** قسم تعليمي داخل التطبيق:
- فيديوهات قصيرة (60 ثانية) عن: "كيف تتجنب أخطاء JoFotara الشائعة"
- اختبارات سريعة (Quiz) مع شهادات
- تحديثات عن التغييرات الضريبية الأردنية
**لماذا؟**
- يبني **Authority** — مُصادَق يصير المرجع الأول للفوترة في الأردن
- SEO + Content Marketing مجاني
- يجذب محاسبين جدد عبر المحتوى التعليمي
---
### 14. 🔄 تكامل مع البنوك الأردنية — "مطابقة تلقائية"
**الفكرة:** ربط مع APIs البنوك (مثل بنك الأردن، العربي) لـ:
- مطابقة الفواتير مع حركات الحساب البنكي تلقائياً
- تنبيه: "فاتورة #1234 بمبلغ 500 دينار — تطابق حوالة بتاريخ 15/4"
- تسهيل التسوية البنكية (Bank Reconciliation)
**لماذا؟**
- يوفر ساعات عمل يومية على المحاسب
- ميزة لا يقدمها أي منافس محلي حالياً
- يتطلب Open Banking API (اللي بدأ ينتشر في الأردن)
---
### 15. 📋 "قوالب الفواتير الذكية" — Smart Invoice Templates
**الفكرة:** مكتبة قوالب جاهزة حسب القطاع:
- قالب مطاعم (مع بنود طعام + ضريبة خاصة)
- قالب مقاولات (مع دفعات جزئية + ضمانات)
- قالب عيادات (مع إعفاءات صحية)
- القوالب تتعلم من فواتير المستخدم وتقترح بنود تلقائياً
---
## 📈 الفئة 4: استراتيجيات تسويقية ميدانية
### 16. 🎯 "أسبوع مُصادَق المجاني" — حملة إطلاق
**الخطة:**
1. اختيار 50 مكتب محاسبة في عمّان
2. زيارة ميدانية مع iPad يعرض التطبيق
3. تفعيل حساب مجاني لمدة شهر مع 200 فاتورة
4. متابعة بعد أسبوع بالنتائج
5. طلب شهادة (Testimonial) فيديو قصيرة
### 17. 🤝 شراكة مع نقابة المحاسبين الأردنيين
- تقديم مُصادَق كـ "الأداة المعتمدة" من النقابة
- خصم خاص لأعضاء النقابة
- ورشات عمل مشتركة عن الفوترة الإلكترونية
### 18. 📱 حملة "صوّر وأرسل" على السوشال ميديا
- المحاسب يصور عملية مسح فاتورة (10 ثوانٍ)
- ينشرها على TikTok/Instagram مع هاشتاغ **#مُصادَق_بثانية**
- أفضل فيديو كل أسبوع يفوز بـ 3 أشهر مجانية
---
## 🗺️ ملخص الأولويات المقترحة
| الأولوية | الفكرة | السبب |
|----------|--------|-------|
| 🔴 فوري | WhatsApp Integration | أقل حاجز دخول + أعلى انتشار |
| 🔴 فوري | Referral Program | نمو عضوي بتكلفة صفر تقريباً |
| 🟡 قريب | Smart Notifications | يرفع Retention بشكل مباشر |
| 🟡 قريب | AI Health Report | يعطي قيمة ملموسة لصاحب القرار |
| 🟢 متوسط | Gamification | يبني ولاء طويل المدى |
| 🟢 متوسط | AR Scanner | ميزة WOW للعروض التقديمية |
| 🔵 طويل المدى | Marketplace | يحول المنصة لـ Platform |
| 🔵 طويل المدى | Bank Integration | ميزة تنافسية لا تُقلَّد بسهولة |
---
> [!TIP]
> **النصيحة الذهبية:** ابدأ بـ WhatsApp + Referral Program — هذولا الاثنين لحالهم ممكن يجيبوا أول 100 عميل بدون ميزانية تسويق كبيرة. بعدين اشتغل على Smart Notifications و AI Health Report لتثبيت العملاء (Retention). الباقي يتبنى تدريجياً مع نمو الإيرادات.

892
newplan.md Normal file
View File

@@ -0,0 +1,892 @@
# خطة تنفيذ مُصادَق — Flutter Mobile App
## خارطة الطريق الشاملة بالأولويات والمكتبات والتفاصيل
---
## أولاً: قرارات المكتبات النهائية
### هيكل `pubspec.yaml` الكامل
```yaml
dependencies:
flutter:
sdk: flutter
# ─── State Management ───────────────────────────────
get: ^4.6.6 # GetX — state + routing + DI
# ─── Networking ─────────────────────────────────────
dio: ^5.4.0 # HTTP client
flutter_secure_storage: ^9.0.0 # تخزين JWT + secrets آمن
# ─── Local Database (Isar > Hive للمشروع هذا) ──────
isar: ^3.1.0+1 # قاعدة بيانات محلية — أسرع من Hive
isar_flutter_libs: ^3.1.0+1 # Flutter bindings
path_provider: ^2.1.2 # مسارات الملفات
# ─── Authentication & Security ──────────────────────
local_auth: ^2.1.8 # Fingerprint + FaceID
device_info_plus: ^10.1.0 # Device fingerprinting
crypto: ^3.0.3 # HMAC-SHA256
# ─── Camera & Scanning ──────────────────────────────
camerawesome: ^2.0.0 # كاميرا متقدمة مع تحكم كامل
cunning_document_scanner: ^0.2.0 # Edge detection + Auto-crop (iOS/Android)
image_picker: ^1.0.7 # Gallery fallback
# ─── Image Processing ───────────────────────────────
image: ^4.1.7 # معالجة صور Dart-native (offline)
opencv_dart: ^1.3.2 # Adaptive thresholding + advanced ops
flutter_image_compress: ^2.1.0 # ضغط JPEG بجودة قابلة للضبط
# ─── PDF Generation ─────────────────────────────────
pdf: ^3.10.8 # إنشاء PDF بدعم العربية
printing: ^5.12.0 # مشاركة + طباعة + preview
# ─── Voice & Audio ──────────────────────────────────
record: ^5.1.0 # تسجيل صوتي (OGG/WAV) — أخف من flutter_sound
permission_handler: ^11.3.0 # صلاحيات Mic + Camera + Storage
# ─── Connectivity & Background ──────────────────────
connectivity_plus: ^6.0.3 # كشف الإنترنت
workmanager: ^0.5.2 # Background sync jobs
# ─── UI & UX ────────────────────────────────────────
cached_network_image: ^3.3.1 # صور من الشبكة
shimmer: ^3.0.0 # Loading skeleton
lottie: ^3.1.0 # Animations
# ─── Utilities ──────────────────────────────────────
uuid: ^4.3.3 # Batch IDs
intl: ^0.19.0 # تنسيق التواريخ والأرقام العربية
package_info_plus: ^8.0.0 # App version
dev_dependencies:
isar_generator: ^3.1.0+1
build_runner: ^2.4.8
flutter_test:
sdk: flutter
```
### لماذا Isar وليس Hive؟
| المعيار | Hive | Isar |
|---------|------|------|
| السرعة | جيد | أسرع 10x على الـ queries |
| Type Safety | يدوي (TypeAdapters) | تلقائي عبر code generation |
| Queries | محدود | Full query engine مع indexes |
| الحجم | خفيف | أكبر قليلاً |
| الاستخدام | بسيط جداً | يحتاج build_runner مرة واحدة |
| الاختيار | MVP بسيط | **مُصادَق** — لأننا نحتاج queries معقدة على الفواتير المحلية |
---
## المرحلة 1 — أساس المشروع والأمان (الأسبوع 1-3)
### 1.1 هيكل مجلدات Flutter
```
lib/
├── main.dart
├── app/
│ ├── bindings/ # GetX bindings
│ ├── routes/ # AppPages + AppRoutes
│ └── theme/ # AppColors, AppStyles
├── core/
│ ├── network/
│ │ ├── dio_client.dart # Dio instance
│ │ └── hmac_interceptor.dart # HMAC signing
│ ├── storage/
│ │ ├── isar_service.dart # Isar instance singleton
│ │ └── secure_storage.dart # flutter_secure_storage wrapper
│ ├── services/
│ │ ├── auth_service.dart
│ │ ├── biometric_service.dart
│ │ └── device_service.dart # fingerprint + hardware check
│ └── constants/
│ ├── app_link.dart # API endpoints
│ ├── app_color.dart
│ └── box_name.dart # Isar collection names
├── features/
│ ├── auth/ # Login + OTP + Biometric
│ ├── scanner/ # Batch camera + image processing
│ ├── invoices/ # Invoice list + detail
│ ├── voice/ # Voice assistant
│ └── dashboard/ # Main dashboard
└── shared/
├── widgets/
└── models/
```
### 1.2 HMAC Interceptor (أمان الـ API)
```dart
// core/network/hmac_interceptor.dart
class HmacInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
final body = options.data != null ? jsonEncode(options.data) : '';
final apiSecret = SecureStorage.read(BoxName.apiSecret);
// HMAC-SHA256: timestamp + method + path + body
final message = '$timestamp|${options.method}|${options.path}|$body';
final hmac = Hmac(sha256, utf8.encode(apiSecret ?? ''));
final signature = hmac.convert(utf8.encode(message)).toString();
options.headers['X-Timestamp'] = timestamp;
options.headers['X-Signature'] = signature;
options.headers['Authorization'] = 'Bearer ${SecureStorage.read(BoxName.jwt)}';
handler.next(options);
}
}
```
### 1.3 نظام المصادقة — SMS OTP + Biometric
**التدفق الكامل:**
```
[المدير يُضيف محاسب + رقم هاتفه في Web Dashboard]
[المحاسب يفتح التطبيق → يدخل رقم الهاتف]
[Backend يرسل OTP عبر SMS (Twilio أو منصة محلية أردنية)]
[المحاسب يدخل OTP → Backend يتحقق → يرجع JWT]
[التطبيق يحفظ JWT + يطلب تفعيل البصمة/FaceID]
[عمليات الدخول التالية: بصمة فقط (أو PIN كـ fallback)]
```
```dart
// features/auth/controllers/auth_controller.dart
class AuthController extends GetxController {
final _biometricService = Get.find<BiometricService>();
final _authService = Get.find<AuthService>();
final _deviceService = Get.find<DeviceService>();
Future<void> requestOtp(String phone) async {
final deviceFingerprint = await _deviceService.getFingerprint();
// إرسال OTP مع fingerprint للتحقق منه في Backend
await _authService.requestOtp(phone: phone, deviceId: deviceFingerprint);
}
Future<void> verifyOtp(String phone, String otp) async {
final deviceFingerprint = await _deviceService.getFingerprint();
final result = await _authService.verifyOtp(
phone: phone,
otp: otp,
deviceId: deviceFingerprint,
);
// حفظ JWT في secure storage
await SecureStorage.write(BoxName.jwt, result.token);
await SecureStorage.write(BoxName.apiSecret, result.apiSecret);
// عرض شاشة تفعيل البصمة
if (await _biometricService.isAvailable()) {
Get.toNamed(AppRoutes.biometricSetup);
}
}
Future<void> loginWithBiometric() async {
final authenticated = await _biometricService.authenticate();
if (authenticated) {
// التحقق من JWT مخزن + تجديده إذا انتهت صلاحيته
final token = await SecureStorage.read(BoxName.jwt);
if (token != null) Get.offAllNamed(AppRoutes.dashboard);
}
}
}
```
```dart
// core/services/device_service.dart
class DeviceService extends GetxService {
Future<String> getFingerprint() async {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
// Combination of stable device identifiers
final raw = '${android.brand}|${android.model}|${android.id}';
return sha256.convert(utf8.encode(raw)).toString();
} else {
final ios = await info.iosInfo;
final raw = '${ios.model}|${ios.identifierForVendor}';
return sha256.convert(utf8.encode(raw)).toString();
}
}
Future<DeviceCapability> checkCapability() async {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
final sdkInt = android.version.sdkInt;
// Android 10+ (API 29) = متوافق
if (sdkInt >= 29) return DeviceCapability.full;
if (sdkInt >= 26) return DeviceCapability.limited;
return DeviceCapability.unsupported;
}
// iOS 14+ = متوافق
return DeviceCapability.full;
}
}
enum DeviceCapability { full, limited, unsupported }
```
**Backend PHP — التحقق من OTP + Device:**
```php
// api/auth/verify_otp.php
function verifyOtpAndIssueToken($phone, $otp, $deviceId) {
// 1. تحقق من OTP
$storedOtp = Redis::get("otp:$phone");
if ($storedOtp !== $otp) return ['error' => 'OTP غير صحيح'];
// 2. جلب المستخدم
$user = User::where('phone', $phone)->first();
if (!$user) return ['error' => 'المستخدم غير موجود'];
// 3. تسجيل/تحديث الجهاز
UserDevice::updateOrCreate(
['user_id' => $user->id, 'device_fingerprint' => $deviceId],
['last_seen' => now(), 'is_trusted' => true]
);
// 4. توليد JWT مع device_id مضمّن
$payload = [
'user_id' => $user->id,
'tenant_id' => $user->tenant_id,
'device_id' => $deviceId,
'exp' => time() + (30 * 24 * 3600) // 30 يوم
];
$token = JWT::encode($payload, config('jwt_secret'), 'HS256');
// 5. توليد API Secret خاص بهذا الجهاز للـ HMAC
$apiSecret = hash('sha256', $user->id . $deviceId . config('app_key'));
Redis::del("otp:$phone");
return ['token' => $token, 'api_secret' => $apiSecret];
}
```
**جدول `user_devices` المطلوب إضافته:**
```sql
CREATE TABLE user_devices (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
device_fingerprint VARCHAR(64) NOT NULL,
device_name VARCHAR(100),
is_trusted TINYINT(1) DEFAULT 0,
last_seen TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE KEY unique_device (user_id, device_fingerprint)
);
```
---
## المرحلة 2 — الماسح الذكي ومعالجة الصور (الأسبوع 3-7)
### 2.1 وضع الدفعة (Batch Scan Mode)
```dart
// features/scanner/controllers/batch_scanner_controller.dart
class BatchScannerController extends GetxController {
final images = <File>[].obs;
final isProcessing = false.obs;
final processedCount = 0.obs;
// Pipeline: Scan → Process → Queue → Upload
Future<void> addAndProcessImage(File rawImage) async {
final capability = await DeviceService().checkCapability();
File processed;
if (capability == DeviceCapability.full) {
// معالجة كاملة على الجهاز
processed = await ImageProcessor.processLocally(rawImage);
} else {
// ضغط فقط — المعالجة على السيرفر
processed = await ImageProcessor.compressOnly(rawImage);
}
images.add(processed);
processedCount.value = images.length;
}
Future<void> generateAndUpload(String companyId) async {
isProcessing.value = true;
// 1. دمج الصور في PDF
final pdf = await PdfGenerator.fromImages(images, companyName: companyId);
// 2. حفظ في Isar كـ pending batch
final batch = InvoiceBatch(
id: Uuid().v4(),
companyId: companyId,
pdfPath: pdf.path,
status: BatchStatus.pending,
imageCount: images.length,
createdAt: DateTime.now(),
);
await IsarService.instance.writeTxn(() async {
await IsarService.instance.invoiceBatchs.put(batch);
});
// 3. محاولة رفع فورية إذا في إنترنت
final hasConnection = await ConnectivityService.isConnected();
if (hasConnection) {
await _uploadBatch(batch);
}
// وإلا WorkManager سيتولى الرفع لاحقاً
isProcessing.value = false;
}
}
```
### 2.2 معالجة الصور — Pipeline كامل
```dart
// core/services/image_processor.dart
class ImageProcessor {
/// Full pipeline: Edge → Perspective → Grayscale → Threshold → Compress
static Future<File> processLocally(File input) async {
// 1. تحميل الصورة
final bytes = await input.readAsBytes();
img.Image image = img.decodeImage(bytes)!;
// 2. تدرج الرمادي
image = img.grayscale(image);
// 3. زيادة التباين (يساعد OCR)
image = img.adjustColor(image, contrast: 1.5);
// 4. تحسين الحدة
image = img.sharpen(image, amount: 1.0);
// 5. Binarization بسيط عبر image package
image = _applyThreshold(image, threshold: 128);
// ملاحظة: لـ Adaptive Thresholding الحقيقي → استخدم opencv_dart
// final mat = await opencv.imread(input.path);
// final processed = await opencv.adaptiveThreshold(mat, 255,
// opencv.ADAPTIVE_THRESH_GAUSSIAN_C, opencv.THRESH_BINARY, 11, 2);
// 6. ضغط JPEG بجودة 85%
final compressed = img.encodeJpg(image, quality: 85);
// حجم متوقع: 4MB → ~200KB
final output = File('${input.parent.path}/processed_${input.path.split('/').last}');
await output.writeAsBytes(compressed);
return output;
}
static img.Image _applyThreshold(img.Image src, {int threshold = 128}) {
return img.adjustColor(src,
hueRotation: 0,
saturation: 0,
exposure: 0,
gamma: threshold / 128.0,
);
}
static Future<File> compressOnly(File input) async {
final compressed = await FlutterImageCompress.compressAndGetFile(
input.path,
'${input.parent.path}/compressed_${input.path.split('/').last}',
quality: 70,
minWidth: 1200,
minHeight: 1600,
);
return compressed ?? input;
}
}
```
### 2.3 توليد PDF من الصور
```dart
// core/services/pdf_generator.dart
class PdfGenerator {
static Future<File> fromImages(
List<File> images, {
required String companyName,
}) async {
final pdf = pw.Document();
for (final imageFile in images) {
final imageBytes = await imageFile.readAsBytes();
final pdfImage = pw.MemoryImage(imageBytes);
pdf.addPage(pw.Page(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(0),
build: (ctx) => pw.Image(pdfImage, fit: pw.BoxFit.contain),
));
}
final date = DateTime.now().toString().split(' ')[0];
final filename = 'فواتير_${companyName}_$date.pdf';
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$filename');
await file.writeAsBytes(await pdf.save());
return file;
}
}
```
### 2.4 Isar Schema للـ Offline Queue
```dart
// shared/models/invoice_batch.dart
@collection
class InvoiceBatch {
Id get isarId => fastHash(id);
late String id; // UUID
late String companyId;
late String pdfPath;
late int imageCount;
@enumerated
late BatchStatus status; // pending | uploading | done | failed
late DateTime createdAt;
DateTime? uploadedAt;
int retryCount = 0;
String? errorMessage;
}
@collection
class LocalInvoice {
Id get isarId => fastHash(id);
late String id;
late String batchId;
late String imagePath;
late String companyId;
@enumerated
late InvoiceStatus status; // pending | extracted | validated | submitted
// بيانات مستخرجة من AI
String? invoiceNumber;
double? totalAmount;
double? taxAmount;
String? supplierName;
late DateTime createdAt;
bool isSynced = false;
}
```
---
## المرحلة 3 — المساعد الصوتي (الأسبوع 7-10)
### 3.1 معمارية كاملة: Record → Groq → Gemini → Command
```dart
// features/voice/controllers/voice_controller.dart
class VoiceController extends GetxController {
final _recorder = AudioRecorder();
final isRecording = false.obs;
final lastCommand = ''.obs;
final commandResult = Rxn<VoiceCommandResult>();
static const maxDurationSeconds = 15;
Timer? _autoStopTimer;
Future<void> startRecording() async {
if (!await _recorder.hasPermission()) return;
final dir = await getTemporaryDirectory();
final path = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path,
);
isRecording.value = true;
// إيقاف تلقائي بعد 15 ثانية
_autoStopTimer = Timer(Duration(seconds: maxDurationSeconds), stopAndProcess);
}
Future<void> stopAndProcess() async {
_autoStopTimer?.cancel();
if (!isRecording.value) return;
final path = await _recorder.stop();
isRecording.value = false;
if (path == null) return;
final file = File(path);
final result = await VoiceService.processCommand(file);
commandResult.value = result;
await _executeCommand(result);
}
Future<void> _executeCommand(VoiceCommandResult result) async {
switch (result.action) {
case VoiceAction.listInvoices:
Get.toNamed(AppRoutes.invoices, arguments: result.params);
break;
case VoiceAction.checkQuota:
Get.toNamed(AppRoutes.subscription);
break;
case VoiceAction.openScanner:
Get.toNamed(AppRoutes.scanner, arguments: result.params);
break;
// ... باقي الأوامر
}
}
}
```
### 3.2 Voice Service — Groq STT + Gemini Intent
```dart
// features/voice/services/voice_service.dart
class VoiceService {
static Future<VoiceCommandResult> processCommand(File audioFile) async {
// Step 1: STT via Groq Whisper
final text = await _groqTranscribe(audioFile);
if (text.isEmpty) return VoiceCommandResult.failed('لم أفهم الأمر');
// Step 2: Intent parsing via Gemini Flash Lite Latest
final intent = await _parseIntent(text);
return intent;
}
static Future<String> _groqTranscribe(File audio) async {
final dio = DioClient.instance; // بدون HMAC (Groq خارجي)
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(audio.path, filename: 'voice.m4a'),
'model': 'whisper-large-v3-turbo',
'language': 'ar',
'response_format': 'text',
});
final response = await Dio().post(
'https://api.groq.com/openai/v1/audio/transcriptions',
data: formData,
options: Options(headers: {
'Authorization': 'Bearer ${AppConfig.groqApiKey}',
}),
);
return response.data.toString().trim();
}
static Future<VoiceCommandResult> _parseIntent(String text) async {
const systemPrompt = '''
أنت محلل أوامر لنظام مُصادَق للفوترة الأردني.
استخرج النية والمعاملات من النص وأرجع JSON فقط.
الأوامر المتاحة:
- list_invoices: { company?: string, from?: date, to?: date, status?: string }
- check_quota: {}
- open_scanner: { company?: string }
- search_invoice: { amount?: number, company?: string, number?: string }
- get_report: { type: "tax"|"monthly", period?: string }
- check_status: { invoice_id?: string, company?: string }
- export_pdf: { invoice_id?: string, company?: string }
- navigate: { screen: string }
أرجع: { "action": "...", "params": {...}, "confirmation": "نص قصير تأكيد" }
''';
final response = await Dio().post(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite-latest:generateContent',
queryParameters: {'key': AppConfig.geminiApiKey},
data: {
'contents': [{'parts': [{'text': text}]}],
'systemInstruction': {'parts': [{'text': systemPrompt}]},
'generationConfig': {
'responseMimeType': 'application/json',
'maxOutputTokens': 200,
},
},
);
final json = jsonDecode(
response.data['candidates'][0]['content']['parts'][0]['text']
);
return VoiceCommandResult(
action: VoiceAction.fromString(json['action']),
params: json['params'] ?? {},
confirmation: json['confirmation'] ?? '',
rawText: text,
);
}
}
```
### 3.3 الأوامر الصوتية الـ 10
```dart
enum VoiceAction {
listInvoices, // "فواتير شركة X لشهر كذا"
checkQuota, // "كم باقي من رصيدي"
openScanner, // "صور فاتورة لشركة X"
searchInvoice, // "ابحث عن فاتورة 150 دينار"
getReport, // "تقرير ضريبة الشهر الماضي"
checkStatus, // "حالة فاتورة شركة X"
exportPdf, // "حوّل فاتورة 300 لـ PDF"
listRejected, // "الفواتير المرفوضة هذا الأسبوع"
subscriptionInfo,// "حالة اشتراكي"
navigate, // "افتح لوحة التحكم"
unknown,
}
```
---
## المرحلة 4 — AI Pre-Audit الضريبي (الأسبوع 10-13)
### 4.1 تحديث Backend — نظام Queue للفواتير
**المطلوب إضافته في الـ schema:**
```sql
-- جدول جديد: invoice_processing_queue
CREATE TABLE invoice_processing_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
batch_id VARCHAR(36) NOT NULL,
invoice_id INT, -- بعد الاستخراج
tenant_id INT NOT NULL,
image_path VARCHAR(500) NOT NULL,
status ENUM('pending','processing','done','failed') DEFAULT 'pending',
attempts INT DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
INDEX idx_status_tenant (status, tenant_id),
INDEX idx_batch (batch_id)
);
-- جدول جديد: invoice_batches
CREATE TABLE invoice_batches (
id VARCHAR(36) PRIMARY KEY,
tenant_id INT NOT NULL,
company_id INT NOT NULL,
pdf_path VARCHAR(500),
total_images INT,
processed_images INT DEFAULT 0,
status ENUM('uploading','processing','done','partial_fail') DEFAULT 'uploading',
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL
);
```
### 4.2 تحديث InvoiceExtractionService.php
```php
// services/InvoiceExtractionService.php
class InvoiceExtractionService {
// قواعد الضريبة الأردنية
private array $taxRules = [
// معفيات صفر
'zero_rated' => ['أدوية', 'دواء', 'خبز', 'طحين', 'سكر', 'أرز'],
// ضريبة خاصة
'special' => ['سيارات' => 0.15, 'سجائر' => 0.32, 'كحول' => 0.30],
// معدل عام
'standard_rate' => 0.16, // 16% ضريبة مبيعات أردن
];
public function extractAndAudit(string $imagePath, int $tenantId): array {
// 1. استخراج البيانات من Gemini Vision
$extracted = $this->extractWithVision($imagePath);
// 2. AI Pre-Audit
$audit = $this->performPreAudit($extracted);
// 3. حساب Hash لمنع التكرار
$hash = $this->calculateInvoiceHash($extracted);
// 4. التحقق من التكرار
$duplicate = Invoice::where('invoice_hash', $hash)
->where('tenant_id', $tenantId)->first();
if ($duplicate) {
$audit['warnings'][] = [
'code' => 'DUPLICATE_INVOICE',
'message' => 'هذه الفاتورة مرفوعة مسبقاً (رقم ' . $duplicate->id . ')',
'severity' => 'critical'
];
}
return [
'extracted' => $extracted,
'audit' => $audit,
'hash' => $hash,
'jofotara_readiness' => $this->assessJoFotaraReadiness($extracted, $audit),
];
}
private function performPreAudit(array $data): array {
$warnings = [];
$errors = [];
// فحص الضريبة
if (isset($data['items'])) {
foreach ($data['items'] as $item) {
$expectedTax = $this->calculateExpectedTax($item['name'], $item['amount']);
if (abs($item['tax'] - $expectedTax) > 0.01) {
$warnings[] = [
'code' => 'TAX_MISMATCH',
'message' => "ضريبة غير صحيحة لـ '{$item['name']}': المتوقع {$expectedTax} الموجود {$item['tax']}",
'severity' => 'high'
];
}
}
}
// فحص اكتمال البيانات الإلزامية
$required = ['supplier_name', 'invoice_number', 'invoice_date', 'total_amount'];
foreach ($required as $field) {
if (empty($data[$field])) {
$errors[] = ['code' => 'MISSING_FIELD', 'field' => $field, 'severity' => 'critical'];
}
}
return ['warnings' => $warnings, 'errors' => $errors];
}
private function assessJoFotaraReadiness(array $data, array $audit): array {
$errorCount = count($audit['errors']);
$highWarnings = array_filter($audit['warnings'], fn($w) => $w['severity'] === 'high');
$score = 100 - ($errorCount * 30) - (count($highWarnings) * 15);
return [
'score' => max(0, $score),
'ready' => $score >= 70,
'message' => $score >= 70 ? 'جاهزة للإرسال' : 'تحتاج مراجعة قبل الإرسال'
];
}
}
```
---
## المرحلة 5 — فحص الجهاز + Offline Architecture (الأسبوع 12-14)
### 5.1 Device Capability Check عند أول تشغيل
```dart
// features/auth/views/device_check_screen.dart
class DeviceCheckScreen extends GetView<DeviceCheckController> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Obx(() {
if (controller.isChecking.value) {
return _buildCheckingUI();
}
return _buildResultUI(controller.capability.value);
}),
);
}
Widget _buildResultUI(DeviceCapability cap) {
return switch (cap) {
DeviceCapability.full => _buildFullModeUI(),
DeviceCapability.limited => _buildLimitedModeUI(),
DeviceCapability.unsupported => _buildUnsupportedUI(),
};
}
}
class DeviceCheckController extends GetxController {
final isChecking = true.obs;
final capability = DeviceCapability.full.obs;
@override
void onInit() {
super.onInit();
_runCheck();
}
Future<void> _runCheck() async {
final result = await DeviceService().checkCapability();
// حفظ النتيجة محلياً
GetStorage().write(BoxName.deviceCapability, result.name);
capability.value = result;
isChecking.value = false;
}
}
```
### 5.2 WorkManager للـ Background Sync
```dart
// main.dart
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
switch (task) {
case 'syncPendingBatches':
await _syncPendingBatches();
break;
case 'retryFailedUploads':
await _retryFailedUploads();
break;
}
return Future.value(true);
});
}
// في main():
Workmanager().initialize(callbackDispatcher);
Workmanager().registerPeriodicTask(
'syncTask',
'syncPendingBatches',
frequency: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected),
);
```
---
## الملخص التنفيذي بالأسابيع
| الأسبوع | التركيز | المخرجات |
|---------|---------|----------|
| 1-2 | هيكل المشروع + HMAC + Isar setup | بنية مجلدات نظيفة، DioClient محمي |
| 2-3 | SMS OTP + Biometric Auth | تسجيل دخول آمن كامل |
| 3-5 | Batch Scanner + camerawesome | تصوير متسلسل يعمل |
| 5-7 | Image Processing (image + opencv) | صور مضغوطة ومحسّنة |
| 7-8 | PDF Generator + Offline Queue | دفعات محفوظة محلياً |
| 8-10 | Voice: Record → Groq → Gemini | 10 أوامر صوتية تعمل |
| 10-12 | AI Pre-Audit في Backend | تحذيرات ضريبية قبل JoFotara |
| 12-13 | Device Check + WorkManager Sync | مزامنة خلفية موثوقة |
| 13-14 | Testing + Beta (10 مكاتب عمان) | إطلاق تجريبي |
---
## قرارات نهائية سريعة
| القرار | الاختيار | السبب |
|--------|---------|-------|
| STT | Groq Whisper | الأرخص 9x + الأسرع |
| Intent LLM | Gemini 2.0 Flash Lite Latest | ما ينوقف، مجاني بحد معقول |
| Local DB | Isar | Queries معقدة على الفواتير |
| Camera | camerawesome | تحكم أفضل في Batch Mode |
| Image Processing | image (Dart) + opencv_dart | مرونة: Dart للبسيط، OpenCV للمعقد |
| PDF | pdf (pub.dev) | دعم RTL + عربية |
| Recording | record | أخف من flutter_sound، كافٍ للمهمة |
| Background Sync | WorkManager | الأكثر موثوقية لـ iOS + Android |
| Security Storage | flutter_secure_storage | Keychain/Keystore native |

View File

@@ -42,6 +42,21 @@ $routes = [
'v1/subscriptions/current' => ['GET', 'subscriptions/current.php'], 'v1/subscriptions/current' => ['GET', 'subscriptions/current.php'],
'v1/subscriptions/assign' => ['POST', 'subscriptions/assign.php'], 'v1/subscriptions/assign' => ['POST', 'subscriptions/assign.php'],
'v1/subscriptions/usage' => ['GET', 'subscriptions/usage.php'], 'v1/subscriptions/usage' => ['GET', 'subscriptions/usage.php'],
// Mobile Auth & Device Routes
'v1/auth/mobile/request-otp' => ['POST', 'auth/mobile_request_otp.php'],
'v1/auth/mobile/verify-otp' => ['POST', 'auth/mobile_verify_otp.php'],
'v1/auth/mobile/register-device' => ['POST', 'auth/register_device.php'],
// Batch Scanning Routes
'v1/batches/create' => ['POST', 'batches/create.php'],
'v1/batches/upload-image' => ['POST', 'batches/upload_image.php'],
'v1/batches/finalize' => ['POST', 'batches/finalize.php'],
'v1/batches/status' => ['GET', 'batches/status.php'],
// Voice Assistant Proxies
'v1/voice/transcribe' => ['POST', 'voice/transcribe.php'],
'v1/voice/parse-intent' => ['POST', 'voice/parse_intent.php'],
]; ];
if (isset($routes[$route])) { if (isset($routes[$route])) {

View File

@@ -0,0 +1,153 @@
<?php
/**
* Phase 3 Migration: Mobile App Support
* Run: php scripts/migrate_phase3_mobile.php
*
* Adds tables and columns required for:
* - Mobile OTP Authentication
* - Device Management
* - Batch Invoice Upload from Scanner
* - Processing Queue for AI extraction
* - Notifications System
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Phase 3 Migration\n";
echo " Mobile App + Batch Processing Support\n";
echo "═══════════════════════════════════════════\n\n";
$migrations = [
// ─── 1. User Device Management ─────────────────────────
'create_user_devices' => "
CREATE TABLE IF NOT EXISTS user_devices (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id CHAR(36) NOT NULL,
device_fingerprint VARCHAR(64) NOT NULL,
device_name VARCHAR(100) NULL,
platform ENUM('android','ios','web') NOT NULL DEFAULT 'android',
app_version VARCHAR(20) NULL,
push_token TEXT NULL,
device_secret VARCHAR(128) NULL,
is_trusted BOOLEAN DEFAULT FALSE,
last_seen_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_user_device (user_id, device_fingerprint),
INDEX idx_device_fingerprint (device_fingerprint)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
",
// ─── 2. Users table: Add phone + mobile fields ─────────
'add_users_phone' => "ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL AFTER email",
'add_users_phone_hash' => "ALTER TABLE users ADD COLUMN phone_hash VARCHAR(64) NULL AFTER phone",
'add_users_pin_hash' => "ALTER TABLE users ADD COLUMN pin_hash VARCHAR(255) NULL AFTER password_hash",
'add_users_biometric' => "ALTER TABLE users ADD COLUMN biometric_enabled BOOLEAN DEFAULT FALSE AFTER pin_hash",
'add_users_phone_index' => "CREATE INDEX idx_phone_hash ON users(phone_hash)",
// ─── 3. Invoice Batches (Mobile Scanner) ───────────────
'create_invoice_batches' => "
CREATE TABLE IF NOT EXISTS invoice_batches (
id CHAR(36) PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
uploaded_by CHAR(36) NOT NULL,
total_images INT NOT NULL DEFAULT 0,
processed_images INT NOT NULL DEFAULT 0,
failed_images INT NOT NULL DEFAULT 0,
status ENUM('uploading','processing','done','partial_fail','failed') DEFAULT 'uploading',
source ENUM('mobile_scan','web_upload','whatsapp') DEFAULT 'mobile_scan',
pdf_path VARCHAR(500) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
INDEX idx_tenant_status (tenant_id, status),
INDEX idx_company (company_id),
INDEX idx_uploaded_by (uploaded_by),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
",
// ─── 4. Invoice Processing Queue ───────────────────────
'create_processing_queue' => "
CREATE TABLE IF NOT EXISTS invoice_processing_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
batch_id CHAR(36) NOT NULL,
invoice_id CHAR(36) NULL,
tenant_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
image_path VARCHAR(500) NOT NULL,
image_order INT NOT NULL DEFAULT 0,
status ENUM('pending','processing','done','failed') DEFAULT 'pending',
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 3,
error_message TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME NULL,
INDEX idx_status_tenant (status, tenant_id),
INDEX idx_batch (batch_id),
INDEX idx_pending (status, attempts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
",
// ─── 5. Add batch_id to invoices table ─────────────────
'add_invoices_batch_id' => "ALTER TABLE invoices ADD COLUMN batch_id CHAR(36) NULL AFTER company_id",
'add_invoices_batch_index' => "CREATE INDEX idx_batch_id ON invoices(batch_id)",
// ─── 6. Notifications Table ────────────────────────────
'create_notifications' => "
CREATE TABLE IF NOT EXISTS notifications (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
data JSON NULL,
is_read BOOLEAN DEFAULT FALSE,
read_at DATETIME NULL,
push_sent BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_unread (user_id, is_read),
INDEX idx_tenant (tenant_id),
INDEX idx_type (type),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
",
];
$success = 0;
$skipped = 0;
$failed = 0;
foreach ($migrations as $name => $sql) {
try {
$db->exec($sql);
echo "{$name}\n";
$success++;
} catch (\PDOException $e) {
$msg = $e->getMessage();
if (str_contains($msg, 'Duplicate column') || str_contains($msg, 'Duplicate key name') || str_contains($msg, 'already exists')) {
echo " ⏭️ {$name} (already exists)\n";
$skipped++;
} else {
echo "{$name}: {$msg}\n";
$failed++;
}
}
}
echo "\n═══════════════════════════════════════════\n";
echo " Migration Complete!\n";
echo " ✅ Success: {$success} | ⏭️ Skipped: {$skipped} | ❌ Failed: {$failed}\n";
echo "═══════════════════════════════════════════\n";