Update: 2026-05-06 01:38:39
This commit is contained in:
137
app/modules_app/auth/mobile_request_otp.php
Normal file
137
app/modules_app/auth/mobile_request_otp.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/modules_app/auth/mobile_verify_otp.php
Normal file
174
app/modules_app/auth/mobile_verify_otp.php
Normal 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'],
|
||||||
|
],
|
||||||
|
], 'تم التحقق بنجاح. مرحباً بك في مُصادَق!');
|
||||||
60
app/modules_app/auth/register_device.php
Normal file
60
app/modules_app/auth/register_device.php
Normal 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, 'تم تحديث بيانات الجهاز');
|
||||||
72
app/modules_app/batches/create.php
Normal file
72
app/modules_app/batches/create.php
Normal 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',
|
||||||
|
], 'تم إنشاء الدفعة بنجاح. ابدأ برفع الصور.');
|
||||||
65
app/modules_app/batches/finalize.php
Normal file
65
app/modules_app/batches/finalize.php
Normal 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']
|
||||||
|
], 'تم إنهاء الدفعة بنجاح وإرسالها للمعالجة');
|
||||||
54
app/modules_app/batches/status.php
Normal file
54
app/modules_app/batches/status.php
Normal 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
|
||||||
|
], 'تم جلب حالة الدفعة');
|
||||||
97
app/modules_app/batches/upload_image.php
Normal file
97
app/modules_app/batches/upload_image.php
Normal 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} صور في الدفعة)");
|
||||||
103
app/modules_app/voice/parse_intent.php
Normal file
103
app/modules_app/voice/parse_intent.php
Normal 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, 'تم تحليل الأمر');
|
||||||
61
app/modules_app/voice/transcribe.php
Normal file
61
app/modules_app/voice/transcribe.php
Normal 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
287
musadaq_strategic_ideas.md
Normal 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
892
newplan.md
Normal 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 |
|
||||||
@@ -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])) {
|
||||||
|
|||||||
153
scripts/migrate_phase3_mobile.php
Normal file
153
scripts/migrate_phase3_mobile.php
Normal 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";
|
||||||
Reference in New Issue
Block a user