Update: 2026-05-08 14:05:50
This commit is contained in:
@@ -30,6 +30,13 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
||||||
|
$phone = ltrim($phone, '+');
|
||||||
|
if (str_starts_with($phone, '07')) {
|
||||||
|
$phone = '962' . substr($phone, 1);
|
||||||
|
} elseif (str_starts_with($phone, '7')) {
|
||||||
|
$phone = '962' . $phone;
|
||||||
|
}
|
||||||
|
|
||||||
$phoneHash = hash('sha256', $phone);
|
$phoneHash = hash('sha256', $phone);
|
||||||
|
|
||||||
// 2. Find user by phone hash OR plain phone (Support both schemas)
|
// 2. Find user by phone hash OR plain phone (Support both schemas)
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ if ($errors) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
||||||
|
$phone = ltrim($phone, '+');
|
||||||
|
if (str_starts_with($phone, '07')) {
|
||||||
|
$phone = '962' . substr($phone, 1);
|
||||||
|
} elseif (str_starts_with($phone, '7')) {
|
||||||
|
$phone = '962' . $phone;
|
||||||
|
}
|
||||||
|
|
||||||
$phoneHash = hash('sha256', $phone);
|
$phoneHash = hash('sha256', $phone);
|
||||||
$deviceId = $data['device_id'] ?? '';
|
$deviceId = $data['device_id'] ?? '';
|
||||||
$deviceName = $data['device_name'] ?? 'Unknown Device';
|
$deviceName = $data['device_name'] ?? 'Unknown Device';
|
||||||
|
|||||||
@@ -46,8 +46,16 @@ $encryptedName = Encryption::encrypt($data['name']);
|
|||||||
$encryptedEmail = Encryption::encrypt($data['email']);
|
$encryptedEmail = Encryption::encrypt($data['email']);
|
||||||
$emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login
|
$emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login
|
||||||
|
|
||||||
$encryptedPhone = Encryption::encrypt($data['phone']);
|
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
||||||
$phoneHash = hash('sha256', preg_replace('/[^0-9+]/', '', $data['phone']));
|
$phone = ltrim($phone, '+');
|
||||||
|
if (str_starts_with($phone, '07')) {
|
||||||
|
$phone = '962' . substr($phone, 1);
|
||||||
|
} elseif (str_starts_with($phone, '7')) {
|
||||||
|
$phone = '962' . $phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encryptedPhone = Encryption::encrypt($phone);
|
||||||
|
$phoneHash = hash('sha256', $phone);
|
||||||
|
|
||||||
// 3. Determine Tenant ID
|
// 3. Determine Tenant ID
|
||||||
$tenantId = null;
|
$tenantId = null;
|
||||||
|
|||||||
@@ -34,14 +34,15 @@ $values = [];
|
|||||||
|
|
||||||
if (isset($data['name'])) {
|
if (isset($data['name'])) {
|
||||||
$fields[] = 'name = ?';
|
$fields[] = 'name = ?';
|
||||||
$values[] = $data['name'];
|
$values[] = \App\Core\Encryption::encrypt($data['name']);
|
||||||
}
|
}
|
||||||
if (isset($data['email'])) {
|
if (isset($data['email'])) {
|
||||||
$fields[] = 'email = ?';
|
$fields[] = 'email = ?';
|
||||||
$values[] = $data['email'];
|
$values[] = \App\Core\Encryption::encrypt($data['email']);
|
||||||
|
$fields[] = 'email_hash = ?';
|
||||||
|
$values[] = hash('sha256', strtolower($data['email']));
|
||||||
}
|
}
|
||||||
if (isset($data['role'])) {
|
if (isset($data['role'])) {
|
||||||
// Only super_admin can change roles
|
|
||||||
if ($role !== 'super_admin' && $data['role'] === 'super_admin') {
|
if ($role !== 'super_admin' && $data['role'] === 'super_admin') {
|
||||||
json_error('لا يمكنك منح صلاحية مدير النظام', 403);
|
json_error('لا يمكنك منح صلاحية مدير النظام', 403);
|
||||||
}
|
}
|
||||||
@@ -49,8 +50,18 @@ if (isset($data['role'])) {
|
|||||||
$values[] = $data['role'];
|
$values[] = $data['role'];
|
||||||
}
|
}
|
||||||
if (isset($data['phone'])) {
|
if (isset($data['phone'])) {
|
||||||
|
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
||||||
|
$phone = ltrim($phone, '+');
|
||||||
|
if (str_starts_with($phone, '07')) {
|
||||||
|
$phone = '962' . substr($phone, 1);
|
||||||
|
} elseif (str_starts_with($phone, '7')) {
|
||||||
|
$phone = '962' . $phone;
|
||||||
|
}
|
||||||
|
|
||||||
$fields[] = 'phone = ?';
|
$fields[] = 'phone = ?';
|
||||||
$values[] = $data['phone'];
|
$values[] = \App\Core\Encryption::encrypt($phone);
|
||||||
|
$fields[] = 'phone_hash = ?';
|
||||||
|
$values[] = hash('sha256', $phone);
|
||||||
}
|
}
|
||||||
if (isset($data['is_active'])) {
|
if (isset($data['is_active'])) {
|
||||||
$fields[] = 'is_active = ?';
|
$fields[] = 'is_active = ?';
|
||||||
|
|||||||
@@ -23,10 +23,22 @@ class AuthController extends GetxController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
phone.value = phoneNumber;
|
|
||||||
|
// Normalize phone number
|
||||||
|
String normalizedPhone = phoneNumber.replaceAll(RegExp(r'[^0-9+]'), '');
|
||||||
|
if (normalizedPhone.startsWith('+')) {
|
||||||
|
normalizedPhone = normalizedPhone.substring(1);
|
||||||
|
}
|
||||||
|
if (normalizedPhone.startsWith('07')) {
|
||||||
|
normalizedPhone = '962' + normalizedPhone.substring(1);
|
||||||
|
} else if (normalizedPhone.startsWith('7')) {
|
||||||
|
normalizedPhone = '962' + normalizedPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
phone.value = normalizedPhone;
|
||||||
|
|
||||||
final response = await _dio.post('auth/mobile/request-otp', data: {
|
final response = await _dio.post('auth/mobile/request-otp', data: {
|
||||||
'phone': phoneNumber,
|
'phone': normalizedPhone,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
|||||||
@@ -57,4 +57,27 @@ class CompaniesManagementController extends GetxController {
|
|||||||
AppSnackbar.showError('خطأ', 'تعذر حذف الشركة');
|
AppSnackbar.showError('خطأ', 'تعذر حذف الشركة');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> connectJoFotara(String id, String clientId, String secretKey, String sequence) async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
final response = await _dio.post('companies/connect_jofotara', data: {
|
||||||
|
'id': id,
|
||||||
|
'client_id': clientId,
|
||||||
|
'secret_key': secretKey,
|
||||||
|
'income_source_sequence': sequence,
|
||||||
|
});
|
||||||
|
if (response.data['success'] == true) {
|
||||||
|
await fetchCompanies();
|
||||||
|
AppSnackbar.showSuccess('نجاح', 'تم ربط الشركة بجوفوترا بنجاح');
|
||||||
|
} else {
|
||||||
|
AppSnackbar.showError('خطأ', response.data['message'] ?? 'فشل الربط');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
AppLogger.error('Failed to connect jofotara', e);
|
||||||
|
AppSnackbar.showError('خطأ', 'تعذر ربط جوفوترا');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ class CompaniesManagementView extends StatelessWidget {
|
|||||||
'company_name': company['name'],
|
'company_name': company['name'],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'link_jofotara':
|
||||||
|
_showLinkJoFotaraDialog(context, company, controller);
|
||||||
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
_confirmDelete(context, controller, company['id'], company['name'] ?? '');
|
_confirmDelete(context, controller, company['id'], company['name'] ?? '');
|
||||||
break;
|
break;
|
||||||
@@ -145,6 +148,7 @@ class CompaniesManagementView extends StatelessWidget {
|
|||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(value: 'edit', child: Row(children: [Icon(Icons.edit, size: 18), SizedBox(width: 8), Text('تعديل البيانات')])),
|
const PopupMenuItem(value: 'edit', child: Row(children: [Icon(Icons.edit, size: 18), SizedBox(width: 8), Text('تعديل البيانات')])),
|
||||||
const PopupMenuItem(value: 'stats', child: Row(children: [Icon(Icons.bar_chart, size: 18), SizedBox(width: 8), Text('الإحصائيات')])),
|
const PopupMenuItem(value: 'stats', child: Row(children: [Icon(Icons.bar_chart, size: 18), SizedBox(width: 8), Text('الإحصائيات')])),
|
||||||
|
const PopupMenuItem(value: 'link_jofotara', child: Row(children: [Icon(Icons.link, size: 18, color: Color(0xFF6366F1)), SizedBox(width: 8), Text('ربط جوفوترا', style: TextStyle(color: Color(0xFF6366F1)))])),
|
||||||
const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, size: 18, color: Colors.red), SizedBox(width: 8), Text('حذف', style: TextStyle(color: Colors.red))])),
|
const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, size: 18, color: Colors.red), SizedBox(width: 8), Text('حذف', style: TextStyle(color: Colors.red))])),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -162,7 +166,7 @@ class CompaniesManagementView extends StatelessWidget {
|
|||||||
if (company['address'] != null && company['address'].toString().isNotEmpty)
|
if (company['address'] != null && company['address'].toString().isNotEmpty)
|
||||||
_chip(Icons.location_on, company['address'], Colors.orange),
|
_chip(Icons.location_on, company['address'], Colors.orange),
|
||||||
if (company['is_jofotara_linked'] == 1)
|
if (company['is_jofotara_linked'] == 1)
|
||||||
_chip(Icons.verified, 'جوفتورة', const Color(0xFF6366F1)),
|
_chip(Icons.verified, 'جوفوترا', const Color(0xFF6366F1)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -298,4 +302,43 @@ class CompaniesManagementView extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showLinkJoFotaraDialog(BuildContext context, Map<String, dynamic> company, CompaniesManagementController controller) {
|
||||||
|
final clientIdC = TextEditingController();
|
||||||
|
final secretKeyC = TextEditingController();
|
||||||
|
final sequenceC = TextEditingController();
|
||||||
|
|
||||||
|
Get.dialog(
|
||||||
|
AlertDialog(
|
||||||
|
title: const Text('ربط منصة جوفوترا', textAlign: TextAlign.center, style: TextStyle(color: Color(0xFF6366F1))),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('أدخل بيانات الربط الخاصة بالشركة:', style: TextStyle(fontSize: 13, color: Colors.grey), textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_editField('Client ID', clientIdC, Icons.vpn_key),
|
||||||
|
_editField('Secret Key', secretKeyC, Icons.lock),
|
||||||
|
_editField('Income Source Sequence (اختياري)', sequenceC, Icons.format_list_numbered),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Get.back(), child: const Text('إلغاء')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (clientIdC.text.isEmpty || secretKeyC.text.isEmpty) {
|
||||||
|
Get.snackbar('تنبيه', 'يجب إدخال الـ Client ID و Secret Key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Get.back();
|
||||||
|
controller.connectJoFotara(company['id'], clientIdC.text, secretKeyC.text, sequenceC.text);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6366F1)),
|
||||||
|
child: const Text('ربط الآن', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class DashboardController extends GetxController {
|
|||||||
|
|
||||||
var isLoading = true.obs;
|
var isLoading = true.obs;
|
||||||
var stats = {}.obs;
|
var stats = {}.obs;
|
||||||
|
var gamification = {}.obs;
|
||||||
var recentActivities = [].obs;
|
var recentActivities = [].obs;
|
||||||
var userRole = ''.obs;
|
var userRole = ''.obs;
|
||||||
|
|
||||||
@@ -67,6 +68,12 @@ class DashboardController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch Gamification
|
||||||
|
final gamificationResponse = await _dio.get('gamification/profile');
|
||||||
|
if (gamificationResponse.data['success'] == true) {
|
||||||
|
gamification.value = gamificationResponse.data['data'];
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch Recent Activity
|
// Fetch Recent Activity
|
||||||
final activityResponse = await _dio.get('dashboard/recent-activity');
|
final activityResponse = await _dio.get('dashboard/recent-activity');
|
||||||
if (activityResponse.data['success'] == true) {
|
if (activityResponse.data['success'] == true) {
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ class DashboardView extends GetView<DashboardController> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildAdminQuickActions(role, isDark),
|
_buildAdminQuickActions(role, isDark),
|
||||||
],
|
],
|
||||||
|
if (controller.gamification.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildGamificationCard(controller.gamification, isDark),
|
||||||
|
],
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
const Text('إحصائيات الفواتير',
|
const Text('إحصائيات الفواتير',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -386,6 +390,96 @@ class DashboardView extends GetView<DashboardController> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildGamificationCard(Map gamification, bool isDark) {
|
||||||
|
final points = gamification['points'] ?? 0;
|
||||||
|
final level = gamification['level'] ?? 1;
|
||||||
|
final levelName = gamification['level_name'] ?? 'مبتدئ';
|
||||||
|
final currentLevelThreshold = gamification['current_level_threshold'] ?? 0;
|
||||||
|
final nextLevelThreshold = gamification['next_level_threshold'] ?? 1000;
|
||||||
|
final progress = (points - currentLevelThreshold) /
|
||||||
|
((nextLevelThreshold - currentLevelThreshold) > 0
|
||||||
|
? (nextLevelThreshold - currentLevelThreshold)
|
||||||
|
: 1);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF6366F1).withValues(alpha: 0.3),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.stars_rounded, color: Colors.amber, size: 32),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'المستوى $level: $levelName',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$points نقطة مكتسبة',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'المكافآت',
|
||||||
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress.clamp(0.0, 1.0).toDouble(),
|
||||||
|
backgroundColor: Colors.white24,
|
||||||
|
color: Colors.amber,
|
||||||
|
minHeight: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'باقي ${nextLevelThreshold - points} نقطة للمستوى القادم',
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildQuotaMeter(Map subscription, bool isDark) {
|
Widget _buildQuotaMeter(Map subscription, bool isDark) {
|
||||||
int limit = subscription['limit'] ?? 100;
|
int limit = subscription['limit'] ?? 100;
|
||||||
int used = subscription['used'] ?? 0;
|
int used = subscription['used'] ?? 0;
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ class InvoiceDetailController extends GetxController {
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
title: const Text('تأكيد الإرسال'),
|
title: const Text('تأكيد الإرسال'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'هل أنت متأكد من إرسال هذه الفاتورة لمنظومة جوفتورة؟\nلا يمكن التراجع عن هذا الإجراء.'),
|
'هل أنت متأكد من إرسال هذه الفاتورة لمنظومة جوفوترا؟\nلا يمكن التراجع عن هذا الإجراء.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Get.back(result: false),
|
onPressed: () => Get.back(result: false),
|
||||||
@@ -246,21 +246,21 @@ class InvoiceDetailController extends GetxController {
|
|||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
AppSnackbar.showInfo('جاري الإرسال', 'يتم إرسال الفاتورة لمنظومة جوفتورة...');
|
AppSnackbar.showInfo('جاري الإرسال', 'يتم إرسال الفاتورة لمنظومة جوفوترا...');
|
||||||
final res = await DioClient().client.post(
|
final res = await DioClient().client.post(
|
||||||
'invoices/submit-jofotara',
|
'invoices/submit-jofotara',
|
||||||
data: {'invoice_id': invoiceId},
|
data: {'invoice_id': invoiceId},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.data['success'] == true) {
|
if (res.data['success'] == true) {
|
||||||
AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفتورة بنجاح');
|
AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفوترا بنجاح');
|
||||||
fetchInvoiceDetails();
|
fetchInvoiceDetails();
|
||||||
} else {
|
} else {
|
||||||
AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال');
|
AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Failed to submit to JoFotara', e);
|
AppLogger.error('Failed to submit to JoFotara', e);
|
||||||
AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفتورة');
|
AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفوترا');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class InvoiceDetailView extends StatelessWidget {
|
|||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.send_rounded),
|
icon: const Icon(Icons.send_rounded),
|
||||||
label: const Text('إرسال لجوفتورة', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
label: const Text('إرسال لجوفوترا', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -268,7 +268,7 @@ class InvoiceDetailView extends StatelessWidget {
|
|||||||
const Icon(Icons.verified, color: Color(0xFF10B981), size: 20),
|
const Icon(Icons.verified, color: Color(0xFF10B981), size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text(
|
const Text(
|
||||||
'مُقدَّمة لجوفتورة ✓',
|
'مُقدَّمة لجوفوترا ✓',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF10B981)),
|
style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF10B981)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -489,7 +489,7 @@ class InvoiceDetailView extends StatelessWidget {
|
|||||||
break;
|
break;
|
||||||
case 'submitted':
|
case 'submitted':
|
||||||
color = const Color(0xFF6366F1);
|
color = const Color(0xFF6366F1);
|
||||||
text = 'مُقدَّمة لجوفتورة';
|
text = 'مُقدَّمة لجوفوترا';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
color = const Color(0xFFF59E0B);
|
color = const Color(0xFFF59E0B);
|
||||||
|
|||||||
@@ -853,9 +853,9 @@
|
|||||||
<p>لا رسوم خفية. ابدأ مجاناً وقم بالترقية عندما تحتاج إلى المزيد من السعة.</p>
|
<p>لا رسوم خفية. ابدأ مجاناً وقم بالترقية عندما تحتاج إلى المزيد من السعة.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="pricing-grid">
|
<div class="pricing-grid">
|
||||||
<!-- Free Plan -->
|
<!-- Trial Plan -->
|
||||||
<div class="price-card">
|
<div class="price-card">
|
||||||
<div class="price-name">تريال (Trial)</div>
|
<div class="price-name">المجانية (Trial)</div>
|
||||||
<div class="price-amount">0 <span>JOD / شهر</span></div>
|
<div class="price-amount">0 <span>JOD / شهر</span></div>
|
||||||
<ul class="price-features">
|
<ul class="price-features">
|
||||||
<li><span class="feature-check">✔</span> إدارة شركة واحدة</li>
|
<li><span class="feature-check">✔</span> إدارة شركة واحدة</li>
|
||||||
@@ -868,7 +868,7 @@
|
|||||||
|
|
||||||
<!-- Basic Plan -->
|
<!-- Basic Plan -->
|
||||||
<div class="price-card">
|
<div class="price-card">
|
||||||
<div class="price-name">أساسية (Basic)</div>
|
<div class="price-name">الأساسية (Basic)</div>
|
||||||
<div class="price-amount">15 <span>JOD / شهر</span></div>
|
<div class="price-amount">15 <span>JOD / شهر</span></div>
|
||||||
<ul class="price-features">
|
<ul class="price-features">
|
||||||
<li><span class="feature-check">✔</span> إدارة 3 شركات</li>
|
<li><span class="feature-check">✔</span> إدارة 3 شركات</li>
|
||||||
@@ -879,20 +879,32 @@
|
|||||||
<a href="/register.php" class="btn btn-outline" style="width:100%; margin-top: 1rem;">اشترك الآن</a>
|
<a href="/register.php" class="btn btn-outline" style="width:100%; margin-top: 1rem;">اشترك الآن</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Office Plan -->
|
<!-- Professional Plan -->
|
||||||
<div class="price-card popular">
|
<div class="price-card popular">
|
||||||
<div class="popular-badge">الأكثر اختياراً</div>
|
<div class="popular-badge">الأكثر اختياراً</div>
|
||||||
<div class="price-name">مكتبية (Office)</div>
|
<div class="price-name">الاحترافية (Professional)</div>
|
||||||
<div class="price-amount">45 <span>JOD / شهر</span></div>
|
<div class="price-amount">45 <span>JOD / شهر</span></div>
|
||||||
<ul class="price-features">
|
<ul class="price-features">
|
||||||
<li><span class="feature-check">✔</span> إدارة حتى 10 شركات</li>
|
<li><span class="feature-check">✔</span> إدارة حتى 10 شركات</li>
|
||||||
<li><span class="feature-check">✔</span> 500 فاتورة شهرياً</li>
|
<li><span class="feature-check">✔</span> 500 فاتورة شهرياً</li>
|
||||||
<li><span class="feature-check">✔</span> 10 مستخدمين مع صلاحيات</li>
|
<li><span class="feature-check">✔</span> 10 مستخدمين مع صلاحيات</li>
|
||||||
<li><span class="feature-check">✔</span> تصدير Excel متقدم</li>
|
<li><span class="feature-check">✔</span> تصدير Excel متقدم</li>
|
||||||
<li><span class="feature-check">✔</span> أولوية الدعم الفني</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<a href="/register.php" class="btn btn-primary" style="width:100%; margin-top: 1rem;">اشترك الآن</a>
|
<a href="/register.php" class="btn btn-primary" style="width:100%; margin-top: 1rem;">اشترك الآن</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Enterprise Plan -->
|
||||||
|
<div class="price-card">
|
||||||
|
<div class="price-name">المؤسسية (Enterprise)</div>
|
||||||
|
<div class="price-amount">مخصص <span>JOD / شهر</span></div>
|
||||||
|
<ul class="price-features">
|
||||||
|
<li><span class="feature-check">✔</span> شركات غير محدودة</li>
|
||||||
|
<li><span class="feature-check">✔</span> فواتير غير محدودة</li>
|
||||||
|
<li><span class="feature-check">✔</span> مستخدمين غير محدودين</li>
|
||||||
|
<li><span class="feature-check">✔</span> أولوية الدعم الفني</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/register.php" class="btn btn-outline" style="width:100%; margin-top: 1rem;">تواصل معنا</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -259,6 +259,17 @@ foreach ($users as $user) {
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## File: `reset_queue.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Reset failed queue items so the cron worker retries them
|
||||||
|
UPDATE invoice_processing_queue SET status = 'pending', error_message = NULL WHERE status = 'failed';
|
||||||
|
|
||||||
|
-- Verify current queue state
|
||||||
|
SELECT id, batch_id, status, error_message, created_at FROM invoice_processing_queue ORDER BY created_at;
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
## File: `schema.sql`
|
## File: `schema.sql`
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -714,6 +725,26 @@ foreach ($tables as $table) {
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## File: `create_ai_usage_table.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- AI Usage Log — Token tracking for cost analysis
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_usage_log (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
input_tokens INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
cost_usd DECIMAL(12, 8) NOT NULL DEFAULT 0,
|
||||||
|
cost_jod DECIMAL(12, 8) NOT NULL DEFAULT 0,
|
||||||
|
model VARCHAR(50) NOT NULL DEFAULT 'gemini-flash-lite',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_created (created_at),
|
||||||
|
INDEX idx_model (model)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
## File: `migrate.php`
|
## File: `migrate.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
@@ -892,6 +923,122 @@ try {
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## File: `phase1_migration.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- مُصادَق — Phase 1: AI Usage Tracking + Notifications
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- AI Usage Log (tracks every AI request)
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_usage_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id CHAR(36) NOT NULL,
|
||||||
|
user_id CHAR(36) NULL,
|
||||||
|
company_id CHAR(36) NULL,
|
||||||
|
action_type ENUM('invoice_extraction','voice_transcribe','voice_intent','report_generation','chatbot') NOT NULL,
|
||||||
|
model_name VARCHAR(50) NOT NULL,
|
||||||
|
prompt_tokens INT DEFAULT 0,
|
||||||
|
completion_tokens INT DEFAULT 0,
|
||||||
|
total_tokens INT DEFAULT 0,
|
||||||
|
estimated_cost DECIMAL(10,6) DEFAULT 0,
|
||||||
|
request_metadata JSON NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_tenant_date (tenant_id, created_at),
|
||||||
|
INDEX idx_action (action_type),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 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 ENUM('invoice_processed','invoice_rejected','quota_warning','month_end','system','achievement') NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
metadata JSON NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_read (user_id, is_read),
|
||||||
|
INDEX idx_tenant (tenant_id),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Referral Codes (Phase 2 prep)
|
||||||
|
CREATE TABLE IF NOT EXISTS referral_codes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
uses_count INT DEFAULT 0,
|
||||||
|
max_uses INT DEFAULT 50,
|
||||||
|
reward_months INT DEFAULT 1,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Referral Uses (Phase 2 prep)
|
||||||
|
CREATE TABLE IF NOT EXISTS referral_uses (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code_id INT NOT NULL,
|
||||||
|
referred_tenant_id CHAR(36) NOT NULL,
|
||||||
|
reward_applied BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (code_id) REFERENCES referral_codes(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- User Achievements (Phase 2 prep)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
achievement_code VARCHAR(50) NOT NULL,
|
||||||
|
points INT NOT NULL DEFAULT 0,
|
||||||
|
earned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_user_achievement (user_id, achievement_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## File: `update_pricing.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Restore original Musadaq subscription pricing
|
||||||
|
-- Premium pricing justified by AI extraction + JoFotara + mobile app
|
||||||
|
|
||||||
|
UPDATE subscription_plans SET
|
||||||
|
name_ar = 'مجانية', name_en = 'Free',
|
||||||
|
max_companies = 1, max_invoices_month = 15, max_users = 1,
|
||||||
|
price_jod = 0.00, jofotara_enabled = 1
|
||||||
|
WHERE id = 'free';
|
||||||
|
|
||||||
|
UPDATE subscription_plans SET
|
||||||
|
name_ar = 'أساسية', name_en = 'Basic',
|
||||||
|
max_companies = 3, max_invoices_month = 100, max_users = 3,
|
||||||
|
price_jod = 15.00, jofotara_enabled = 1
|
||||||
|
WHERE id = 'basic';
|
||||||
|
|
||||||
|
UPDATE subscription_plans SET
|
||||||
|
name_ar = 'مكتبية', name_en = 'Office',
|
||||||
|
max_companies = 10, max_invoices_month = 500, max_users = 10,
|
||||||
|
price_jod = 45.00, jofotara_enabled = 1
|
||||||
|
WHERE id = 'office';
|
||||||
|
|
||||||
|
UPDATE subscription_plans SET
|
||||||
|
name_ar = 'احترافية', name_en = 'Pro',
|
||||||
|
max_companies = 25, max_invoices_month = 2000, max_users = 25,
|
||||||
|
price_jod = 99.00, jofotara_enabled = 1
|
||||||
|
WHERE id = 'pro';
|
||||||
|
|
||||||
|
UPDATE subscription_plans SET
|
||||||
|
name_ar = 'مؤسسية', name_en = 'Enterprise',
|
||||||
|
max_companies = 999, max_invoices_month = 99999, max_users = 999,
|
||||||
|
price_jod = 249.00, jofotara_enabled = 1
|
||||||
|
WHERE id = 'enterprise';
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
## File: `backfill_hashes.php`
|
## File: `backfill_hashes.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
@@ -1135,6 +1282,210 @@ echo "════════════════════════
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## File: `deploy_production.sh`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/bash
|
||||||
|
# ─────────────────────────────────────────────────────
|
||||||
|
# Musadaq Production Deployment Script
|
||||||
|
# Run this on the production server after syncing files
|
||||||
|
# ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "═══════════════════════════════════════════════"
|
||||||
|
echo " مُصادَق — Production Deployment Script"
|
||||||
|
echo "═══════════════════════════════════════════════"
|
||||||
|
|
||||||
|
# 1. Install PHP dependencies
|
||||||
|
echo ""
|
||||||
|
echo "▶ Step 1: Installing Composer dependencies..."
|
||||||
|
cd /home/musadaq/htdocs/musadaq.intaleqapp.com
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
# 2. Ensure storage directories exist
|
||||||
|
echo ""
|
||||||
|
echo "▶ Step 2: Creating storage directories..."
|
||||||
|
mkdir -p storage/invoices
|
||||||
|
mkdir -p storage/logs
|
||||||
|
mkdir -p storage/exports
|
||||||
|
mkdir -p storage/temp
|
||||||
|
chmod -R 775 storage/
|
||||||
|
|
||||||
|
# 3. Set up the Cron Job for AI Queue Worker
|
||||||
|
echo ""
|
||||||
|
echo "▶ Step 3: Setting up Cron Job for AI Worker..."
|
||||||
|
echo ""
|
||||||
|
echo " Run: crontab -e"
|
||||||
|
echo " Add this line:"
|
||||||
|
echo ""
|
||||||
|
echo " * * * * * /usr/bin/php /home/musadaq/htdocs/musadaq.intaleqapp.com/app/cron/process_batches.php >> /home/musadaq/htdocs/musadaq.intaleqapp.com/storage/logs/cron.log 2>&1"
|
||||||
|
echo ""
|
||||||
|
echo " This runs the AI Queue Worker every minute."
|
||||||
|
echo " The worker has its own lock file to prevent duplicates."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. Verify environment variables
|
||||||
|
echo "▶ Step 4: Checking .env configuration..."
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo " ✅ .env file found"
|
||||||
|
|
||||||
|
# Check critical keys
|
||||||
|
grep -q "GEMINI_API_KEY" .env && echo " ✅ GEMINI_API_KEY set" || echo " ❌ GEMINI_API_KEY missing!"
|
||||||
|
grep -q "DB_HOST" .env && echo " ✅ DB_HOST set" || echo " ❌ DB_HOST missing!"
|
||||||
|
grep -q "ENCRYPTION_KEY" .env && echo " ✅ ENCRYPTION_KEY set" || echo " ❌ ENCRYPTION_KEY missing!"
|
||||||
|
grep -q "JWT_SECRET" .env && echo " ✅ JWT_SECRET set" || echo " ❌ JWT_SECRET missing!"
|
||||||
|
grep -q "FCM_SERVER_KEY\|FIREBASE" .env && echo " ✅ Firebase key set" || echo " ⚠️ Firebase key missing (push notifications won't work)"
|
||||||
|
else
|
||||||
|
echo " ❌ .env file not found! Copy .env.example and configure it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════"
|
||||||
|
echo " ✅ Deployment Complete!"
|
||||||
|
echo ""
|
||||||
|
echo " Next steps:"
|
||||||
|
echo " 1. Add the Cron Job (shown above)"
|
||||||
|
echo " 2. Test the API: curl https://musadaq.intaleqapp.com/api/v1/auth/login"
|
||||||
|
echo " 3. Monitor logs: tail -f storage/logs/cron.log"
|
||||||
|
echo "═══════════════════════════════════════════════"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## File: `create_referral_tables.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Referral System Tables
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS referral_codes (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
tenant_id CHAR(36) NOT NULL,
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_code (code),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS referrals (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
referrer_id CHAR(36) NOT NULL,
|
||||||
|
referred_id CHAR(36) NULL,
|
||||||
|
referral_code VARCHAR(20) NOT NULL,
|
||||||
|
status ENUM('clicked', 'registered', 'subscribed') DEFAULT 'clicked',
|
||||||
|
reward_claimed TINYINT(1) DEFAULT 0,
|
||||||
|
reward_type VARCHAR(50) NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
converted_at TIMESTAMP NULL,
|
||||||
|
INDEX idx_referrer (referrer_id),
|
||||||
|
INDEX idx_code (referral_code),
|
||||||
|
FOREIGN KEY (referrer_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## File: `complete_migration.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- مُصادَق — Complete Phase 1 Migration (MySQL 8.0 Compatible)
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- 1. Invoice Line Items (AI extracted data)
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||||
|
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||||
|
invoice_id CHAR(36) NOT NULL,
|
||||||
|
line_number INT NOT NULL,
|
||||||
|
description VARCHAR(255) NOT NULL,
|
||||||
|
quantity DECIMAL(10,3) DEFAULT 1,
|
||||||
|
unit_price DECIMAL(15,4) NOT NULL,
|
||||||
|
tax_rate DECIMAL(5,2) DEFAULT 16.00,
|
||||||
|
tax_amount DECIMAL(15,4) DEFAULT 0,
|
||||||
|
discount DECIMAL(15,4) DEFAULT 0,
|
||||||
|
total_amount DECIMAL(15,4) NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_invoice (invoice_id),
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 2. JoFotara Submissions Log
|
||||||
|
CREATE TABLE IF NOT EXISTS jofotara_submissions (
|
||||||
|
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||||
|
invoice_id CHAR(36) NOT NULL,
|
||||||
|
tenant_id CHAR(36) NOT NULL,
|
||||||
|
company_id CHAR(36) NOT NULL,
|
||||||
|
jofotara_uuid VARCHAR(100) NULL,
|
||||||
|
xml_content LONGTEXT NULL,
|
||||||
|
status ENUM('accepted', 'rejected', 'pending') DEFAULT 'pending',
|
||||||
|
qr_code_raw TEXT NULL,
|
||||||
|
response_body JSON NULL,
|
||||||
|
submitted_at DATETIME NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_invoice (invoice_id),
|
||||||
|
INDEX idx_tenant (tenant_id),
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 3. AI Usage Log
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_usage_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id CHAR(36) NOT NULL,
|
||||||
|
user_id CHAR(36) NULL,
|
||||||
|
company_id CHAR(36) NULL,
|
||||||
|
action_type ENUM('invoice_extraction','voice_transcribe','voice_intent','report_generation','chatbot') NOT NULL,
|
||||||
|
model_name VARCHAR(50) NOT NULL,
|
||||||
|
prompt_tokens INT DEFAULT 0,
|
||||||
|
completion_tokens INT DEFAULT 0,
|
||||||
|
total_tokens INT DEFAULT 0,
|
||||||
|
estimated_cost DECIMAL(10,6) DEFAULT 0,
|
||||||
|
request_metadata JSON NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_tenant_date (tenant_id, created_at),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 4. 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 ENUM('invoice_processed','invoice_rejected','quota_warning','month_end','system','achievement') NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
metadata JSON NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_read (user_id, is_read),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Safe ALTER TABLE (MySQL 8 compatible — no IF NOT EXISTS)
|
||||||
|
-- Run each block separately. If column already exists,
|
||||||
|
-- MySQL will show "Duplicate column" error — just skip it.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- 5a. Companies: JoFotara credentials
|
||||||
|
-- Run these ONE BY ONE. Skip any that say "Duplicate column name"
|
||||||
|
|
||||||
|
ALTER TABLE companies ADD COLUMN jofotara_client_id VARCHAR(255) NULL;
|
||||||
|
ALTER TABLE companies ADD COLUMN jofotara_secret_key VARCHAR(255) NULL;
|
||||||
|
ALTER TABLE companies ADD COLUMN jofotara_status ENUM('active', 'inactive', 'pending') DEFAULT 'inactive';
|
||||||
|
|
||||||
|
-- 5b. Invoices: AI + JoFotara metadata
|
||||||
|
-- Run these ONE BY ONE. Skip any that say "Duplicate column name"
|
||||||
|
|
||||||
|
ALTER TABLE invoices ADD COLUMN invoice_category ENUM('simplified', 'standard') DEFAULT 'simplified';
|
||||||
|
ALTER TABLE invoices ADD COLUMN ubl_type_code VARCHAR(10) DEFAULT '388';
|
||||||
|
ALTER TABLE invoices ADD COLUMN payment_method_code VARCHAR(10) DEFAULT '013';
|
||||||
|
ALTER TABLE invoices ADD COLUMN validation_warnings JSON NULL;
|
||||||
|
ALTER TABLE invoices ADD COLUMN ai_confidence DECIMAL(5,2) DEFAULT 0;
|
||||||
|
ALTER TABLE invoices ADD COLUMN jofotara_uuid VARCHAR(100) NULL;
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
## File: `debug_data.php`
|
## File: `debug_data.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
@@ -1158,3 +1509,115 @@ print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## File: `create_test_account.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Create Test Account for App Reviewers
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../app/bootstrap/init.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Generate UUIDs
|
||||||
|
$tenantId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
|
||||||
|
$userId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Test Account Data
|
||||||
|
$tenantName = "مكتب المراجعة التجريبي";
|
||||||
|
$tenantEmail = "reviewer@musadaq.jo";
|
||||||
|
|
||||||
|
$userName = "App Reviewer";
|
||||||
|
$userEmail = "reviewer@musadaq.jo";
|
||||||
|
$userPassword = "Reviewer2026!";
|
||||||
|
|
||||||
|
// 3. Encrypt data
|
||||||
|
$encryptedTenantName = Encryption::encrypt($tenantName);
|
||||||
|
$encryptedTenantEmail = Encryption::encrypt($tenantEmail);
|
||||||
|
|
||||||
|
$encryptedUserName = Encryption::encrypt($userName);
|
||||||
|
$encryptedUserEmail = Encryption::encrypt($userEmail);
|
||||||
|
$emailHash = hash('sha256', strtolower($userEmail));
|
||||||
|
$passwordHash = password_hash($userPassword, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
|
// 4. Delete existing if any (prevent duplicates on re-run)
|
||||||
|
$stmt = $db->prepare("DELETE FROM users WHERE email_hash = ?");
|
||||||
|
$stmt->execute([$emailHash]);
|
||||||
|
|
||||||
|
// 5. Insert Tenant
|
||||||
|
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, status, created_at) VALUES (?, ?, ?, 'active', NOW())");
|
||||||
|
$stmt->execute([
|
||||||
|
$tenantId,
|
||||||
|
$encryptedTenantName,
|
||||||
|
$encryptedTenantEmail
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 6. Insert User (Manager)
|
||||||
|
$stmtUser = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, 'admin', NOW())");
|
||||||
|
$stmtUser->execute([
|
||||||
|
$userId,
|
||||||
|
$tenantId,
|
||||||
|
$encryptedUserName,
|
||||||
|
$encryptedUserEmail,
|
||||||
|
$emailHash,
|
||||||
|
$passwordHash
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 7. Insert Gamification Profile (Optional but good for testing dashboard)
|
||||||
|
$stmtProfile = $db->prepare("INSERT INTO user_profiles (user_id, points, current_level, rank_title) VALUES (?, 1500, 2, 'مُحاسب مبتدئ') ON DUPLICATE KEY UPDATE points=1500");
|
||||||
|
$stmtProfile->execute([$userId]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
echo "✅ Test Account Created Successfully!\n";
|
||||||
|
echo "=====================================\n";
|
||||||
|
echo "Email: $userEmail\n";
|
||||||
|
echo "Password: $userPassword\n";
|
||||||
|
echo "=====================================\n";
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
echo "❌ Error: " . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## File: `create_notifications_table.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Notifications Table
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
tenant_id CHAR(36) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
type ENUM('info', 'success', 'warning', 'error') DEFAULT 'info',
|
||||||
|
category VARCHAR(50) DEFAULT 'general',
|
||||||
|
entity_type VARCHAR(50) NULL,
|
||||||
|
entity_id CHAR(36) NULL,
|
||||||
|
is_read TINYINT(1) DEFAULT 0,
|
||||||
|
read_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_read (user_id, is_read),
|
||||||
|
INDEX idx_tenant (tenant_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user