Update: 2026-05-15 04:35:25

This commit is contained in:
Hamza-Ayed
2026-05-15 04:35:25 +03:00
parent 1ca7e01ce0
commit 2f1ecca593
14 changed files with 858 additions and 10 deletions

View File

@@ -113,7 +113,7 @@ class InvoiceExtractionService
1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة. المواد الغذائية الأساسية في السوبرماركت (ألبان، أجبان، حليب، خبز) غالباً معفاة (0% أو 4%). لا تفرض 16% إلا على الكماليات (منظفات، حلويات، عصائر مصنعة، الخ). 1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة. المواد الغذائية الأساسية في السوبرماركت (ألبان، أجبان، حليب، خبز) غالباً معفاة (0% أو 4%). لا تفرض 16% إلا على الكماليات (منظفات، حلويات، عصائر مصنعة، الخ).
2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية للسلع غير الغذائية والخدمات. 2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية للسلع غير الغذائية والخدمات.
3. إذا صرّحت الفاتورة بنسبة مختلفة عن المتوقع → استخدم ما في الفاتورة وسجِّل ملاحظة في validation_warnings 3. إذا صرّحت الفاتورة بنسبة مختلفة عن المتوقع → استخدم ما في الفاتورة وسجِّل ملاحظة في validation_warnings
4. tax_category: استخدم "S" للخاضعة (16% أو مخفضة)، "Z" للصفري، "E" للمعفاة، "O" للخاصة 4. tax_category: استخدم "standard" للخاضعة (16% أو مخفضة)، "zero_rated" للصفري، "exempt" للمعفاة، "special" للخاصة
════════════════════════════════════════ ════════════════════════════════════════
## تصنيف طريقة الدفع: ## تصنيف طريقة الدفع:
@@ -152,7 +152,7 @@ class InvoiceExtractionService
"unit_price": 0.000, "unit_price": 0.000,
"discount": 0.000, "discount": 0.000,
"tax_rate": 0.16, "tax_rate": 0.16,
"tax_category": "S | Z | E | O", "tax_category": "standard | zero_rated | exempt | special",
"tax_exempt_reason": "string | null", "tax_exempt_reason": "string | null",
"line_total": 0.000 "line_total": 0.000
} }

View File

@@ -121,11 +121,34 @@ class InvoiceProcessor
// Save invoice line items // Save invoice line items
if (!empty($extracted['lines'])) { if (!empty($extracted['lines'])) {
$lineStmt = $db->prepare("INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total) VALUES (?,?,?,?,?,?,?,?)"); $lineStmt = $db->prepare("
INSERT INTO invoice_lines (
id, invoice_id, line_number, description,
quantity, unit_price, tax_rate, tax_amount,
discount_amount, net_total, tax_category
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
");
foreach ($extracted['lines'] as $idx => $line) { foreach ($extracted['lines'] as $idx => $line) {
$quantity = (float)($line['quantity'] ?? 1);
$unitPrice = (float)($line['unit_price'] ?? 0);
$taxRate = (float)($line['tax_rate'] ?? 0);
$discount = (float)($line['discount'] ?? $line['discount_amount'] ?? 0);
$subtotal = $quantity * $unitPrice;
$taxAmount = (float)($line['tax_amount'] ?? ($subtotal * $taxRate));
$netTotal = (float)($line['net_total'] ?? ($line['line_total'] ?? ($subtotal + $taxAmount - $discount)));
$lineStmt->execute([ $lineStmt->execute([
vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)), vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)),
$invoiceId, $line['line_number'] ?? ($idx + 1), $line['description'] ?? '', $line['quantity'] ?? 1, $line['unit_price'] ?? 0, $line['tax_rate'] ?? 0, $line['line_total'] ?? $line['total_amount'] ?? 0 $invoiceId,
$line['line_number'] ?? ($idx + 1),
$line['description'] ?? '',
$quantity,
$unitPrice,
$taxRate,
$taxAmount,
$discount,
$netTotal,
$line['tax_category'] ?? 'standard'
]); ]);
} }
self::log("Queue ID $queueId: Saved " . count($extracted['lines']) . " line items."); self::log("Queue ID $queueId: Saved " . count($extracted['lines']) . " line items.");

View File

@@ -0,0 +1,394 @@
<?php
/**
* Export Invoices as Professional Excel (.xlsx) with Formulas
* GET /v1/invoices/export-excel
*
* Generates a real .xlsx file with:
* - Invoice header info + line items
* - Excel formulas for subtotals, tax, discount, net
* - SUM row at the bottom
* - Professional formatting (colors, borders, Arabic RTL)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Style\Color;
// Autoload PhpSpreadsheet
require_once ROOT_PATH . '/vendor/autoload.php';
// Auth: Support both Bearer header and ?token= query param (for download links)
$token = $_GET['token'] ?? null;
if (!$token) {
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
$token = $matches[1];
}
}
if (!$token) json_error('غير مصرح: لا يوجد رمز دخول', 401);
$decoded = \App\Core\JWT::decode($token, env('JWT_SECRET', ''));
if (!$decoded) json_error('غير مصرح: رمز دخول غير صالح', 401);
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$role = $decoded['role'];
$companyId = $_GET['company_id'] ?? null;
$dateFrom = $_GET['date_from'] ?? null;
$dateTo = $_GET['date_to'] ?? null;
$status = $_GET['status'] ?? null;
$invoiceId = $_GET['invoice_id'] ?? null; // Single invoice export
// Build query with filters
$where = [];
$params = [];
if ($role !== 'super_admin') {
$where[] = 'i.tenant_id = ?';
$params[] = $tenantId;
}
if ($invoiceId) {
$where[] = 'i.id = ?';
$params[] = $invoiceId;
}
if ($companyId) {
$where[] = 'i.company_id = ?';
$params[] = $companyId;
}
if ($dateFrom) {
$where[] = 'i.invoice_date >= ?';
$params[] = $dateFrom;
}
if ($dateTo) {
$where[] = 'i.invoice_date <= ?';
$params[] = $dateTo;
}
if ($status) {
$where[] = 'i.status = ?';
$params[] = $status;
}
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$stmt = $db->prepare("
SELECT i.*, c.name as company_name_raw
FROM invoices i
JOIN companies c ON i.company_id = c.id
$whereClause
ORDER BY i.invoice_date DESC
LIMIT 5000
");
$stmt->execute($params);
$invoices = $stmt->fetchAll();
if (empty($invoices)) {
json_error('لا توجد فواتير لتصديرها', 404);
}
// Decrypt helper
$dec = function($val) {
if (empty($val)) return '';
$result = Encryption::decrypt((string)$val);
return ($result !== false && $result !== null) ? $result : (string)$val;
};
// ══════════════════════════════════════════
// BUILD SPREADSHEET
// ══════════════════════════════════════════
$spreadsheet = new Spreadsheet();
$spreadsheet->getProperties()
->setCreator('مُصادَق - Musadaq')
->setTitle('تقرير الفواتير')
->setDescription('تقرير فواتير المشتريات - تم إنشاؤه تلقائياً من منصة مُصادَق');
// === COLORS ===
$headerBg = '1C1550'; // Deep violet
$headerFont = 'FFFFFF'; // White
$subHeaderBg = 'EDE9FE'; // Light violet
$subHeaderFont = '5B21B6'; // Violet
$totalBg = 'D1FAE5'; // Light green
$totalFont = '065F46'; // Dark green
$borderColor = 'E2E1F0'; // Light border
$altRowBg = 'F8F7FD'; // Alternating row
// Process each invoice
$sheetIndex = 0;
foreach ($invoices as $invIdx => $inv) {
// Fetch line items for this invoice
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
$stmtLines->execute([$inv['id']]);
$lines = $stmtLines->fetchAll();
// Create sheet (reuse first sheet for first invoice)
if ($sheetIndex === 0) {
$sheet = $spreadsheet->getActiveSheet();
} else {
$sheet = $spreadsheet->createSheet();
}
$invoiceNum = $inv['invoice_number'] ?? ('INV-' . ($sheetIndex + 1));
$sheetTitle = mb_substr(preg_replace('/[^a-zA-Z0-9\x{0600}-\x{06FF}\s\-]/u', '', $invoiceNum), 0, 31) ?: ('فاتورة ' . ($sheetIndex + 1));
$sheet->setTitle($sheetTitle);
$sheet->setRightToLeft(true);
// ── Column widths ──
$sheet->getColumnDimension('A')->setWidth(6); // #
$sheet->getColumnDimension('B')->setWidth(38); // Description
$sheet->getColumnDimension('C')->setWidth(12); // Quantity
$sheet->getColumnDimension('D')->setWidth(14); // Unit Price
$sheet->getColumnDimension('E')->setWidth(16); // Subtotal (formula)
$sheet->getColumnDimension('F')->setWidth(14); // Tax Rate
$sheet->getColumnDimension('G')->setWidth(16); // Tax Amount (formula)
$sheet->getColumnDimension('H')->setWidth(14); // Discount
$sheet->getColumnDimension('I')->setWidth(18); // Net Total (formula)
$row = 1;
// ── INVOICE HEADER ──────────────────────────
$sheet->mergeCells("A{$row}:I{$row}");
$sheet->setCellValue("A{$row}", 'مُـصَـادَق — تقرير فاتورة مشتريات');
$sheet->getStyle("A{$row}")->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => new Color($headerFont)],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => new Color($headerBg)],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension($row)->setRowHeight(40);
$row++;
// Invoice meta data (2 columns layout)
$metaData = [
['رقم الفاتورة', $inv['invoice_number'] ?? '-', 'اسم المورّد', $dec($inv['supplier_name'])],
['تاريخ الفاتورة', $inv['invoice_date'] ?? '-', 'الرقم الضريبي للمورّد', $dec($inv['supplier_tin'])],
['الشركة', $dec($inv['company_name_raw'] ?? ''), 'العملة', $inv['currency_code'] ?? 'JOD'],
['نوع الفاتورة', ($inv['invoice_type'] === 'cash' ? 'نقدي' : 'آجل'), 'الحالة', match($inv['status']) {
'extracted' => 'مستخرجة',
'approved' => 'معتمدة',
'submitted' => 'مقدمة لجوفتورة',
'rejected' => 'مرفوضة',
default => $inv['status']
}],
];
foreach ($metaData as $meta) {
$sheet->setCellValue("A{$row}", $meta[0]);
$sheet->mergeCells("B{$row}:C{$row}");
$sheet->setCellValue("B{$row}", $meta[1]);
$sheet->setCellValue("E{$row}", $meta[2]);
$sheet->mergeCells("F{$row}:I{$row}");
$sheet->setCellValue("F{$row}", $meta[3]);
// Style labels
$sheet->getStyle("A{$row}")->applyFromArray([
'font' => ['bold' => true, 'size' => 11, 'color' => new Color($subHeaderFont)],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => new Color($subHeaderBg)],
]);
$sheet->getStyle("E{$row}")->applyFromArray([
'font' => ['bold' => true, 'size' => 11, 'color' => new Color($subHeaderFont)],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => new Color($subHeaderBg)],
]);
$sheet->getRowDimension($row)->setRowHeight(24);
$row++;
}
$row++; // Empty spacer row
// ── LINE ITEMS TABLE HEADER ─────────────────
$headers = ['#', 'وصف البند', 'الكمية', 'سعر الوحدة', 'المجموع الجزئي', 'نسبة الضريبة', 'قيمة الضريبة', 'قيمة الخصم', 'الصافي'];
$cols = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
$headerRow = $row;
foreach ($headers as $i => $header) {
$sheet->setCellValue($cols[$i] . $row, $header);
}
$sheet->getStyle("A{$row}:I{$row}")->applyFromArray([
'font' => ['bold' => true, 'size' => 12, 'color' => new Color($headerFont)],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => new Color($headerBg)],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => [
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => new Color($headerBg)],
],
]);
$sheet->getRowDimension($row)->setRowHeight(32);
$row++;
// ── LINE ITEMS WITH FORMULAS ────────────────
$firstDataRow = $row;
if (!empty($lines)) {
foreach ($lines as $lineIdx => $line) {
$lineNum = $lineIdx + 1;
$quantity = is_numeric($line['quantity'] ?? null) ? (float)$line['quantity'] : 1;
$unitPrice = is_numeric($line['unit_price'] ?? null) ? (float)$line['unit_price'] : 0;
$taxRate = is_numeric($line['tax_rate'] ?? null) ? (float)$line['tax_rate'] : 0.16;
$discount = is_numeric($line['discount_amount'] ?? null) ? (float)$line['discount_amount'] : 0;
// A: Line number
$sheet->setCellValue("A{$row}", $lineNum);
// B: Description
$sheet->setCellValue("B{$row}", $line['description'] ?? 'بدون وصف');
// C: Quantity (value)
$sheet->setCellValue("C{$row}", $quantity);
// D: Unit Price (value)
$sheet->setCellValue("D{$row}", $unitPrice);
// E: Subtotal = Quantity × Unit Price (FORMULA)
$sheet->setCellValue("E{$row}", "=C{$row}*D{$row}");
// F: Tax Rate (as percentage)
$sheet->setCellValue("F{$row}", $taxRate);
$sheet->getStyle("F{$row}")->getNumberFormat()->setFormatCode('0%');
// G: Tax Amount = Subtotal × Tax Rate (FORMULA)
$sheet->setCellValue("G{$row}", "=E{$row}*F{$row}");
// H: Discount amount (value)
$sheet->setCellValue("H{$row}", $discount);
// I: Net Total = Subtotal + Tax - Discount (FORMULA)
$sheet->setCellValue("I{$row}", "=E{$row}+G{$row}-H{$row}");
// Alternating row colors
if ($lineIdx % 2 === 1) {
$sheet->getStyle("A{$row}:I{$row}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => new Color($altRowBg)],
]);
}
// Number formatting for currency columns
foreach (['D', 'E', 'G', 'H', 'I'] as $col) {
$sheet->getStyle("{$col}{$row}")->getNumberFormat()->setFormatCode('#,##0.000');
}
$sheet->getStyle("C{$row}")->getNumberFormat()->setFormatCode('#,##0');
// Center align numbers
$sheet->getStyle("A{$row}:I{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("B{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getRowDimension($row)->setRowHeight(26);
$row++;
}
} else {
// If no line items, create a single row from invoice totals
$sheet->setCellValue("A{$row}", 1);
$sheet->setCellValue("B{$row}", 'إجمالي الفاتورة (بدون تفاصيل بنود)');
$sheet->setCellValue("C{$row}", 1);
$sheet->setCellValue("D{$row}", (float)($inv['subtotal'] ?? 0));
$sheet->setCellValue("E{$row}", "=C{$row}*D{$row}");
$sheet->setCellValue("F{$row}", 0.16);
$sheet->getStyle("F{$row}")->getNumberFormat()->setFormatCode('0%');
$sheet->setCellValue("G{$row}", "=E{$row}*F{$row}");
$sheet->setCellValue("H{$row}", (float)($inv['discount_total'] ?? 0));
$sheet->setCellValue("I{$row}", "=E{$row}+G{$row}-H{$row}");
foreach (['D', 'E', 'G', 'H', 'I'] as $col) {
$sheet->getStyle("{$col}{$row}")->getNumberFormat()->setFormatCode('#,##0.000');
}
$sheet->getStyle("A{$row}:I{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getRowDimension($row)->setRowHeight(26);
$row++;
}
$lastDataRow = $row - 1;
// ── DATA AREA BORDERS ──
$sheet->getStyle("A{$headerRow}:I{$lastDataRow}")->applyFromArray([
'borders' => [
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => new Color($borderColor)],
],
]);
// ── TOTALS ROW WITH SUM FORMULAS ────────────
$sheet->mergeCells("A{$row}:B{$row}");
$sheet->setCellValue("A{$row}", 'المجموع الكلي');
// C: Sum of quantities
$sheet->setCellValue("C{$row}", "=SUM(C{$firstDataRow}:C{$lastDataRow})");
// D: (empty — unit price sum doesn't make sense)
$sheet->setCellValue("D{$row}", '');
// E: Sum of subtotals
$sheet->setCellValue("E{$row}", "=SUM(E{$firstDataRow}:E{$lastDataRow})");
// F: (empty — average tax rate doesn't belong here)
$sheet->setCellValue("F{$row}", '');
// G: Sum of tax amounts
$sheet->setCellValue("G{$row}", "=SUM(G{$firstDataRow}:G{$lastDataRow})");
// H: Sum of discounts
$sheet->setCellValue("H{$row}", "=SUM(H{$firstDataRow}:H{$lastDataRow})");
// I: Sum of net totals
$sheet->setCellValue("I{$row}", "=SUM(I{$firstDataRow}:I{$lastDataRow})");
// Style totals row
$sheet->getStyle("A{$row}:I{$row}")->applyFromArray([
'font' => ['bold' => true, 'size' => 13, 'color' => new Color($totalFont)],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => new Color($totalBg)],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => [
'allBorders' => ['borderStyle' => Border::BORDER_MEDIUM, 'color' => new Color('059669')],
],
]);
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
foreach (['C', 'E', 'G', 'H', 'I'] as $col) {
$sheet->getStyle("{$col}{$row}")->getNumberFormat()->setFormatCode('#,##0.000');
}
$sheet->getRowDimension($row)->setRowHeight(36);
$row += 2;
// ── FOOTER ──
$sheet->mergeCells("A{$row}:I{$row}");
$sheet->setCellValue("A{$row}", 'تم إنشاء هذا التقرير تلقائياً من منصة مُصادَق — ' . date('Y-m-d H:i'));
$sheet->getStyle("A{$row}")->applyFromArray([
'font' => ['italic' => true, 'size' => 9, 'color' => new Color('8B82B0')],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
]);
// ── Print settings ──
$sheet->getPageSetup()->setOrientation(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::ORIENTATION_LANDSCAPE);
$sheet->getPageSetup()->setFitToWidth(1);
$sheetIndex++;
}
// Set first sheet as active
$spreadsheet->setActiveSheetIndex(0);
// ══════════════════════════════════════════
// SEND FILE
// ══════════════════════════════════════════
$filename = 'musadaq_invoices_' . date('Y-m-d_His') . '.xlsx';
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: max-age=0');
header('Pragma: public');
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
exit;

View File

@@ -0,0 +1,41 @@
-- ══════════════════════════════════════════════════
-- Migration 008: Enhance invoice_lines for tax classification
-- Adds tax_amount, discount_amount, net_total, tax_category
-- ══════════════════════════════════════════════════
-- Add tax_amount column (calculated from tax_rate × line_total)
ALTER TABLE invoice_lines
ADD COLUMN IF NOT EXISTS tax_amount DECIMAL(12,3) DEFAULT 0 AFTER tax_rate;
-- Add discount_amount column
ALTER TABLE invoice_lines
ADD COLUMN IF NOT EXISTS discount_amount DECIMAL(12,3) DEFAULT 0 AFTER tax_amount;
-- Add net_total column (subtotal + tax - discount)
ALTER TABLE invoice_lines
ADD COLUMN IF NOT EXISTS net_total DECIMAL(12,3) DEFAULT 0 AFTER discount_amount;
-- Add tax_category for classification
-- standard = 16%, zero_rated = 0%, exempt = no tax, special = variable rate
ALTER TABLE invoice_lines
ADD COLUMN IF NOT EXISTS tax_category VARCHAR(20) DEFAULT 'standard' AFTER net_total;
-- Backfill existing data: calculate tax_amount from line_total * tax_rate
UPDATE invoice_lines
SET tax_amount = ROUND(line_total * tax_rate, 3)
WHERE tax_amount = 0 AND line_total > 0;
-- Backfill: net_total = line_total + tax_amount - discount
UPDATE invoice_lines
SET net_total = ROUND(line_total + tax_amount - discount_amount, 3)
WHERE net_total = 0 AND line_total > 0;
-- Classify zero-rated items
UPDATE invoice_lines
SET tax_category = 'zero_rated'
WHERE tax_rate = 0 OR tax_rate IS NULL;
-- Classify standard rate items
UPDATE invoice_lines
SET tax_category = 'standard'
WHERE tax_rate = 0.16;

34
musadaq-app/build_shorebird.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
# مُصادَق - Shorebird Build Script with Obfuscation
# This script builds the production releases for Android and iOS using Shorebird.
echo "🚀 Starting Shorebird Build Process..."
# 1. Increment Version and Build Number in pubspec.yaml
echo "🔢 Incrementing version (patch) and build number..."
# Increments X.Y.Z+N to X.Y.(Z+1)+(N+1)
perl -i -pe 's/^(version:\s+\d+\.\d+\.)(\d+)\+(\d+)$/$1 . ($2 + 1) . "+" . ($3 + 1)/e' pubspec.yaml
NEW_VERSION=$(grep 'version:' pubspec.yaml | sed 's/version: //')
echo "✅ New Version: $NEW_VERSION"
# 2. Clean and Get Dependencies
echo "🧹 Cleaning project..."
flutter clean
echo "📦 Getting dependencies..."
flutter pub get
# 3. Build Android Release
echo "🤖 Building Android Release via Shorebird..."
shorebird release android -- --obfuscate --split-debug-info=build/app/outputs/symbols
# 4. Build iOS Release
echo "🍎 Building iOS Release via Shorebird..."
shorebird release ios -- --obfuscate --split-debug-info=build/ios/outputs/symbols
echo "✅ Build Process Completed!"
echo "--------------------------------------------------"
echo "Android APK/AppBundle: build/app/outputs/flutter-apk/release/"
echo "iOS Archive: build/ios/archive/"
echo "--------------------------------------------------"

View File

@@ -0,0 +1,109 @@
import 'package:esc_pos_printer/esc_pos_printer.dart';
import 'package:esc_pos_utils/esc_pos_utils.dart';
import '../utils/logger.dart';
class ThermalPrinterService {
/// Prints an invoice to a WiFi thermal printer
Future<bool> printInvoice({
required String ip,
required Map<String, dynamic> invoice,
int port = 9100,
PaperSize paperSize = PaperSize.mm80,
}) async {
try {
final profile = await CapabilityProfile.load();
final printer = NetworkPrinter(paperSize, profile);
final PosPrintResult res = await printer.connect(ip, port: port);
if (res != PosPrintResult.success) {
AppLogger.error('Could not connect to printer at $ip:$port');
return false;
}
AppLogger.print('Connected to printer, generating ticket...');
// Generate Ticket
_buildInvoiceTicket(printer, invoice);
printer.feed(2);
printer.cut();
printer.disconnect();
return true;
} catch (e) {
AppLogger.error('Thermal printing failed', e);
return false;
}
}
void _buildInvoiceTicket(NetworkPrinter printer, Map<String, dynamic> invoice) {
// Header
printer.text(
'M U S A D A Q',
styles: const PosStyles(
align: PosAlign.center,
height: PosTextSize.size2,
width: PosTextSize.size2,
bold: true,
),
);
printer.text(
'نظام الفوترة الإلكترونية',
styles: const PosStyles(align: PosAlign.center),
);
printer.hr();
// Invoice Info
printer.text('رقم الفاتورة: ${invoice['invoice_number'] ?? '-'}');
printer.text('التاريخ: ${invoice['invoice_date'] ?? '-'}');
printer.text('المورد: ${invoice['supplier_name'] ?? '-'}');
if (invoice['supplier_tin'] != null) {
printer.text('الرقم الضريبي: ${invoice['supplier_tin']}');
}
printer.hr();
// Table Header
printer.row([
PosColumn(text: 'البند', width: 6),
PosColumn(text: 'الكمية', width: 2, styles: const PosStyles(align: PosAlign.center)),
PosColumn(text: 'الإجمالي', width: 4, styles: const PosStyles(align: PosAlign.right)),
]);
printer.hr();
// Items
final items = invoice['items'] as List? ?? [];
for (var item in items) {
printer.row([
PosColumn(text: item['description'] ?? '-', width: 6),
PosColumn(text: item['quantity']?.toString() ?? '1', width: 2, styles: const PosStyles(align: PosAlign.center)),
PosColumn(text: item['line_total']?.toString() ?? '0', width: 4, styles: const PosStyles(align: PosAlign.right)),
]);
}
printer.hr();
// Totals
printer.row([
PosColumn(text: 'المجموع الجزئي:', width: 8),
PosColumn(text: '${invoice['subtotal'] ?? 0}', width: 4, styles: const PosStyles(align: PosAlign.right)),
]);
printer.row([
PosColumn(text: 'الضريبة:', width: 8),
PosColumn(text: '${invoice['tax_amount'] ?? 0}', width: 4, styles: const PosStyles(align: PosAlign.right)),
]);
printer.row([
PosColumn(text: 'الإجمالي الكلي:', width: 8, styles: const PosStyles(bold: true)),
PosColumn(text: '${invoice['grand_total'] ?? 0} JOD', width: 4, styles: const PosStyles(align: PosAlign.right, bold: true)),
]);
printer.hr();
printer.text(
'شكراً لاستخدامكم مُصادَق',
styles: const PosStyles(align: PosAlign.center, italic: true),
);
printer.text(
'www.musadaq.com',
styles: const PosStyles(align: PosAlign.center),
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../../../core/utils/app_snackbar.dart'; import '../../../core/utils/app_snackbar.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/services/thermal_printer_service.dart';
class InvoiceDetailController extends GetxController { class InvoiceDetailController extends GetxController {
var invoice = {}.obs; var invoice = {}.obs;
@@ -269,4 +270,57 @@ class InvoiceDetailController extends GetxController {
AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفوترا'); AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفوترا');
} }
} }
Future<void> printThermalInvoice() async {
final ipController = TextEditingController(text: '192.168.1.100'); // Default IP
final proceed = await Get.dialog<bool>(
AlertDialog(
title: const Text('إعدادات الطابعة الحرارية'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('أدخل عنوان IP للطابعة (WiFi)'),
const SizedBox(height: 12),
TextField(
controller: ipController,
decoration: const InputDecoration(
labelText: 'IP Address',
border: OutlineInputBorder(),
hintText: 'e.g. 192.168.1.100',
),
keyboardType: TextInputType.number,
),
],
),
actions: [
TextButton(onPressed: () => Get.back(result: false), child: const Text('إلغاء')),
ElevatedButton(
onPressed: () => Get.back(result: true),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0F4C81)),
child: const Text('طباعة', style: TextStyle(color: Colors.white)),
),
],
),
);
if (proceed == true && ipController.text.isNotEmpty) {
try {
AppSnackbar.showInfo('جاري الاتصال', 'يتم الاتصال بالطابعة...');
final success = await ThermalPrinterService().printInvoice(
ip: ipController.text.trim(),
invoice: Map<String, dynamic>.from(invoice),
);
if (success) {
AppSnackbar.showSuccess('تمت الطباعة', 'تم إرسال الفاتورة للطابعة بنجاح');
} else {
AppSnackbar.showError('خطأ', 'فشل الاتصال بالطابعة. تأكد من عنوان IP والشبكة.');
}
} catch (e) {
AppLogger.error('Print error', e);
AppSnackbar.showError('خطأ', 'حدث خطأ أثناء الطباعة');
}
}
}
} }

View File

@@ -109,6 +109,21 @@ class InvoiceDetailView extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
SizedBox(
height: 52,
child: ElevatedButton.icon(
onPressed: () => controller.printThermalInvoice(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF4F46E5),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
icon: const Icon(Icons.print_rounded),
label: const Text('طباعة حرارية (WiFi)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
),
const SizedBox(height: 12),
SizedBox( SizedBox(
height: 52, height: 52,
child: OutlinedButton.icon( child: OutlinedButton.icon(

View File

@@ -6,6 +6,7 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/services/upload_progress_service.dart'; import '../../../core/services/upload_progress_service.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/utils/app_snackbar.dart'; import '../../../core/utils/app_snackbar.dart';
@@ -123,11 +124,92 @@ class ScannerController extends GetxController {
addImage(file.path!); addImage(file.path!);
} }
} }
AppSnackbar.showSuccess('تمت الإضافة', 'تم استيراد ملفات الفواتير بنجاح'); AppSnackbar.showSuccess('تمت الإضافة', 'تم استيراد ملفات PDF بنجاح');
} }
} catch (e) { } catch (e) {
AppLogger.error('Failed to pick PDF', e); AppLogger.error('Failed to pick PDF', e);
AppSnackbar.showError('خطأ', 'تعذر استيراد الملفات'); AppSnackbar.showError('خطأ', 'تعذر استيراد ملفات PDF');
}
}
Future<void> pickFromGallery() async {
try {
final ImagePicker picker = ImagePicker();
final List<XFile> images = await picker.pickMultiImage();
if (images.isNotEmpty) {
for (var image in images) {
addImage(image.path);
}
AppSnackbar.showSuccess('تمت الإضافة', 'تم استيراد الصور من المعرض بنجاح');
}
} catch (e) {
AppLogger.error('Failed to pick from gallery', e);
AppSnackbar.showError('خطأ', 'تعذر استيراد الصور من المعرض');
}
}
Future<void> pickExcelFile() async {
if (selectedCompanyId.isEmpty) {
AppSnackbar.showWarning('تنبيه', 'الرجاء اختيار الشركة أولاً');
return;
}
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xlsx', 'xls', 'csv'],
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
final filePath = result.files.single.path!;
await uploadExcel(filePath);
}
} catch (e) {
AppLogger.error('Failed to pick Excel', e);
AppSnackbar.showError('خطأ', 'تعذر استيراد ملف الإكسل');
}
}
Future<void> uploadExcel(String filePath) async {
try {
isProcessing.value = true;
uploadProgress.value = 0.0;
_progressService.startUpload(selectedCompanyName.value, 1);
final file = File(filePath);
final fileName = file.path.split('/').last;
FormData formData = FormData.fromMap({
'company_id': selectedCompanyId.value,
'file': await MultipartFile.fromFile(file.path, filename: fileName),
});
final response = await DioClient().client.post(
'excel/import',
data: formData,
onSendProgress: (sent, total) {
uploadProgress.value = sent / total;
_progressService.updateProgress(uploadProgress.value, 1);
},
);
if (response.data['success'] == true) {
_progressService.complete();
AppSnackbar.showSuccess('تم بنجاح', response.data['message'] ?? 'تم استيراد البيانات بنجاح');
Get.back();
} else {
_progressService.fail();
AppSnackbar.showError('خطأ', response.data['message'] ?? 'فشل استيراد ملف الإكسل');
}
} catch (e) {
_progressService.fail();
AppLogger.error('Excel upload failed', e);
AppSnackbar.showError('خطأ', 'حدث خطأ أثناء رفع ملف الإكسل');
} finally {
isProcessing.value = false;
} }
} }

View File

@@ -131,6 +131,30 @@ class ScannerView extends GetView<ScannerController> {
tooltip: 'استيراد PDF', tooltip: 'استيراد PDF',
), ),
), ),
const SizedBox(width: 8),
Container(
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
onPressed: () => controller.pickFromGallery(),
icon: const Icon(Icons.photo_library, color: Colors.white),
tooltip: 'من المعرض',
),
),
const SizedBox(width: 8),
Container(
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
onPressed: () => controller.pickExcelFile(),
icon: const Icon(Icons.table_chart, color: Colors.white),
tooltip: 'استيراد Excel',
),
),
const Spacer(), const Spacer(),
TextButton.icon( TextButton.icon(
onPressed: () => Get.back(), onPressed: () => Get.back(),

View File

@@ -1,7 +1,7 @@
name: musadaq_app name: musadaq_app
description: Jordanian E-Invoicing Automation SaaS description: Jordanian E-Invoicing Automation SaaS
publish_to: 'none' publish_to: 'none'
version: 1.0.2+2 version: 1.0.3+3
environment: environment:
sdk: '>=3.2.0 <4.0.0' sdk: '>=3.2.0 <4.0.0'
@@ -40,6 +40,8 @@ dependencies:
# ─── PDF Generation ───────────────────────────────── # ─── PDF Generation ─────────────────────────────────
pdf: ^3.10.8 pdf: ^3.10.8
printing: ^5.12.0 printing: ^5.12.0
esc_pos_utils: ^1.1.0
esc_pos_printer: ^4.1.0
# ─── Voice & Audio ────────────────────────────────── # ─── Voice & Audio ──────────────────────────────────
speech_to_text: ^7.3.0 speech_to_text: ^7.3.0

View File

@@ -39,6 +39,7 @@ $routes = [
'v1/invoices/delete' => ['POST', 'invoices/delete.php'], 'v1/invoices/delete' => ['POST', 'invoices/delete.php'],
'v1/invoices/bulk-approve' => ['POST', 'invoices/bulk_approve.php'], 'v1/invoices/bulk-approve' => ['POST', 'invoices/bulk_approve.php'],
'v1/invoices/export' => ['GET', 'invoices/export.php'], 'v1/invoices/export' => ['GET', 'invoices/export.php'],
'v1/invoices/export-excel' => ['GET', 'invoices/export_excel.php'],
'v1/invoices/check-duplicate' => ['POST', 'invoices/check_duplicate.php'], 'v1/invoices/check-duplicate' => ['POST', 'invoices/check_duplicate.php'],
'v1/reports/tax-summary' => ['GET', 'reports/tax_summary.php'], 'v1/reports/tax-summary' => ['GET', 'reports/tax_summary.php'],
'v1/audit-log' => ['GET', 'audit/index.php'], 'v1/audit-log' => ['GET', 'audit/index.php'],

30
public/migrate_008.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
/**
* Manual Migration Runner for 008_invoice_lines_enhance
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
try {
$db = Database::getInstance();
$sql = file_get_contents(__DIR__ . '/../database/migrations/008_invoice_lines_enhance.sql');
// Split by semicolon and execute
$queries = array_filter(array_map('trim', explode(';', $sql)));
echo "<h1>Running Migration 008...</h1>";
echo "<ul>";
foreach ($queries as $query) {
if (empty($query)) continue;
$db->exec($query);
echo "<li>✅ Executed: <pre>" . htmlspecialchars(substr($query, 0, 50)) . "...</pre></li>";
}
echo "</ul>";
echo "<h2>Migration completed successfully!</h2>";
echo "<p>Please delete this file (public/migrate_008.php) for security.</p>";
} catch (\Exception $e) {
echo "<h2 style='color:red;'>Migration failed:</h2>";
echo "<pre>" . htmlspecialchars($e->getMessage()) . "</pre>";
}

View File

@@ -1391,6 +1391,9 @@
<button x-show="page==='invoices'" @click="showBatchUploadModal = true" class="btn btn-navy"> <button x-show="page==='invoices'" @click="showBatchUploadModal = true" class="btn btn-navy">
<span>📁</span> استيراد مجمع (Batch) <span>📁</span> استيراد مجمع (Batch)
</button> </button>
<button x-show="page==='invoices'" @click="exportExcel()" class="btn btn-ghost btn-sm" style="border-color: var(--green-mid); color: var(--green);">
<span>📥</span> تصدير Excel
</button>
</div> </div>
</div> </div>
@@ -2560,8 +2563,13 @@
</div> </div>
<div x-show="currentInvoice?.status === 'approved'" <div x-show="currentInvoice?.status === 'approved'"
style="width:100%; background:var(--green-subtle); color:var(--green-mid); border:1px solid rgba(5,150,105,0.2); padding:10px; border-radius:11px; font-size:14px; font-weight:700; text-align:center;"> style="width:100%; display:flex; flex-direction:column; gap:8px;">
مدققة ومعتمدة <div style="width:100%; background:var(--green-subtle); color:var(--green-mid); border:1px solid rgba(5,150,105,0.2); padding:10px; border-radius:11px; font-size:14px; font-weight:700; text-align:center;">
مدققة ومعتمدة
</div>
<button @click="exportExcel(currentInvoice.id)" class="btn btn-ghost" style="width:100%; justify-content:center; border-color:var(--green-mid); color:var(--green-mid);">
<span>📥</span> تحميل كـ Excel
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -2588,6 +2596,7 @@
<th style="padding:12px 16px; text-align:center;">الكمية</th> <th style="padding:12px 16px; text-align:center;">الكمية</th>
<th style="padding:12px 16px;">السعر</th> <th style="padding:12px 16px;">السعر</th>
<th style="padding:12px 16px;">الضريبة</th> <th style="padding:12px 16px;">الضريبة</th>
<th style="padding:12px 16px;">التصنيف</th>
<th style="padding:12px 16px;">الإجمالي</th> <th style="padding:12px 16px;">الإجمالي</th>
</tr> </tr>
</thead> </thead>
@@ -2598,7 +2607,18 @@
<td style="padding:12px 16px; text-align:center; font-family:'Outfit',sans-serif;" x-text="item.quantity"></td> <td style="padding:12px 16px; text-align:center; font-family:'Outfit',sans-serif;" x-text="item.quantity"></td>
<td style="padding:12px 16px; color:var(--green-mid); font-family:'Outfit',sans-serif;" x-text="item.unit_price"></td> <td style="padding:12px 16px; color:var(--green-mid); font-family:'Outfit',sans-serif;" x-text="item.unit_price"></td>
<td style="padding:12px 16px; color:var(--amber-mid); font-family:'Outfit',sans-serif;" x-text="((parseFloat(item.tax_rate) || 0) * 100).toFixed(0) + '%'"></td> <td style="padding:12px 16px; color:var(--amber-mid); font-family:'Outfit',sans-serif;" x-text="((parseFloat(item.tax_rate) || 0) * 100).toFixed(0) + '%'"></td>
<td style="padding:12px 16px; font-weight:700; color:var(--violet-mid); font-family:'Outfit',sans-serif;" x-text="item.line_total"></td> <td style="padding:12px 16px;">
<span class="badge"
:class="{
'badge-teal': item.tax_category === 'standard',
'badge-blue': item.tax_category === 'zero_rated',
'badge-amber': item.tax_category === 'exempt',
'badge-red': item.tax_category === 'special'
}"
x-text="item.tax_category === 'standard' ? '16%' : (item.tax_category === 'zero_rated' ? '0%' : (item.tax_category === 'exempt' ? 'معفى' : 'خاص'))">
</span>
</td>
<td style="padding:12px 16px; font-weight:700; color:var(--violet-mid); font-family:'Outfit',sans-serif;" x-text="item.net_total || item.line_total"></td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
@@ -2939,6 +2959,25 @@
if (this.page === 'subscription') this.plans = await this.apiRequest('v1/subscriptions/plans') || []; if (this.page === 'subscription') this.plans = await this.apiRequest('v1/subscriptions/plans') || [];
if (this.user.role === 'super_admin') this.tenants = await this.apiRequest('v1/tenants') || []; if (this.user.role === 'super_admin') this.tenants = await this.apiRequest('v1/tenants') || [];
}, },
// ── Excel Export ──
exportExcel(invoiceId = null) {
let url = '/index.php?route=v1/invoices/export-excel';
if (invoiceId) {
url += '&invoice_id=' + encodeURIComponent(invoiceId);
} else {
if (this.invoiceCompanyFilter) url += '&company_id=' + encodeURIComponent(this.invoiceCompanyFilter);
if (this.activeInvoiceTab === 'approved') url += '&status=approved';
else if (this.activeInvoiceTab === 'pending') url += '&status=extracted';
else if (this.activeInvoiceTab === 'rejected') url += '&status=rejected';
}
// Download via hidden link with auth token
const link = document.createElement('a');
link.href = url + '&token=' + encodeURIComponent(this.token());
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
// //
getQrSrc(inv) { getQrSrc(inv) {
if (!inv) return ''; if (!inv) return '';