diff --git a/app/Services/InvoiceExtractionService.php b/app/Services/InvoiceExtractionService.php index 0f33934..666b8d3 100644 --- a/app/Services/InvoiceExtractionService.php +++ b/app/Services/InvoiceExtractionService.php @@ -113,7 +113,7 @@ class InvoiceExtractionService 1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة. المواد الغذائية الأساسية في السوبرماركت (ألبان، أجبان، حليب، خبز) غالباً معفاة (0% أو 4%). لا تفرض 16% إلا على الكماليات (منظفات، حلويات، عصائر مصنعة، الخ). 2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية للسلع غير الغذائية والخدمات. 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, "discount": 0.000, "tax_rate": 0.16, - "tax_category": "S | Z | E | O", + "tax_category": "standard | zero_rated | exempt | special", "tax_exempt_reason": "string | null", "line_total": 0.000 } diff --git a/app/Services/InvoiceProcessor.php b/app/Services/InvoiceProcessor.php index f90fb5e..86c2336 100644 --- a/app/Services/InvoiceProcessor.php +++ b/app/Services/InvoiceProcessor.php @@ -121,11 +121,34 @@ class InvoiceProcessor // Save invoice line items 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) { + $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([ 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."); diff --git a/app/modules_app/invoices/export_excel.php b/app/modules_app/invoices/export_excel.php new file mode 100644 index 0000000..20330e8 --- /dev/null +++ b/app/modules_app/invoices/export_excel.php @@ -0,0 +1,394 @@ += ?'; + $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; diff --git a/database/migrations/008_invoice_lines_enhance.sql b/database/migrations/008_invoice_lines_enhance.sql new file mode 100644 index 0000000..e99448c --- /dev/null +++ b/database/migrations/008_invoice_lines_enhance.sql @@ -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; diff --git a/musadaq-app/build_shorebird.sh b/musadaq-app/build_shorebird.sh new file mode 100755 index 0000000..3a475da --- /dev/null +++ b/musadaq-app/build_shorebird.sh @@ -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 "--------------------------------------------------" diff --git a/musadaq-app/lib/core/services/thermal_printer_service.dart b/musadaq-app/lib/core/services/thermal_printer_service.dart new file mode 100644 index 0000000..feb7dfc --- /dev/null +++ b/musadaq-app/lib/core/services/thermal_printer_service.dart @@ -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 printInvoice({ + required String ip, + required Map 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 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), + ); + } +} diff --git a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart index 94bcc87..484c2e5 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart @@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart'; import '../../../core/network/dio_client.dart'; import '../../../core/utils/app_snackbar.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/services/thermal_printer_service.dart'; class InvoiceDetailController extends GetxController { var invoice = {}.obs; @@ -269,4 +270,57 @@ class InvoiceDetailController extends GetxController { AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفوترا'); } } + + Future printThermalInvoice() async { + final ipController = TextEditingController(text: '192.168.1.100'); // Default IP + + final proceed = await Get.dialog( + 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.from(invoice), + ); + + if (success) { + AppSnackbar.showSuccess('تمت الطباعة', 'تم إرسال الفاتورة للطابعة بنجاح'); + } else { + AppSnackbar.showError('خطأ', 'فشل الاتصال بالطابعة. تأكد من عنوان IP والشبكة.'); + } + } catch (e) { + AppLogger.error('Print error', e); + AppSnackbar.showError('خطأ', 'حدث خطأ أثناء الطباعة'); + } + } + } } diff --git a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart index a51e2fc..74372f9 100644 --- a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart +++ b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart @@ -109,6 +109,21 @@ class InvoiceDetailView extends StatelessWidget { 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( height: 52, child: OutlinedButton.icon( diff --git a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart index f0e3c78..abe276c 100644 --- a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart +++ b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart @@ -6,6 +6,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; import '../../../core/services/upload_progress_service.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/app_snackbar.dart'; @@ -123,11 +124,92 @@ class ScannerController extends GetxController { addImage(file.path!); } } - AppSnackbar.showSuccess('تمت الإضافة', 'تم استيراد ملفات الفواتير بنجاح'); + AppSnackbar.showSuccess('تمت الإضافة', 'تم استيراد ملفات PDF بنجاح'); } } catch (e) { AppLogger.error('Failed to pick PDF', e); - AppSnackbar.showError('خطأ', 'تعذر استيراد الملفات'); + AppSnackbar.showError('خطأ', 'تعذر استيراد ملفات PDF'); + } + } + + Future pickFromGallery() async { + try { + final ImagePicker picker = ImagePicker(); + final List 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 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 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; } } diff --git a/musadaq-app/lib/features/scanner/views/scanner_view.dart b/musadaq-app/lib/features/scanner/views/scanner_view.dart index 2eda52f..f51c3f5 100644 --- a/musadaq-app/lib/features/scanner/views/scanner_view.dart +++ b/musadaq-app/lib/features/scanner/views/scanner_view.dart @@ -131,6 +131,30 @@ class ScannerView extends GetView { 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(), TextButton.icon( onPressed: () => Get.back(), diff --git a/musadaq-app/pubspec.yaml b/musadaq-app/pubspec.yaml index 54f85c1..bcfc4fa 100644 --- a/musadaq-app/pubspec.yaml +++ b/musadaq-app/pubspec.yaml @@ -1,7 +1,7 @@ name: musadaq_app description: Jordanian E-Invoicing Automation SaaS publish_to: 'none' -version: 1.0.2+2 +version: 1.0.3+3 environment: sdk: '>=3.2.0 <4.0.0' @@ -40,6 +40,8 @@ dependencies: # ─── PDF Generation ───────────────────────────────── pdf: ^3.10.8 printing: ^5.12.0 + esc_pos_utils: ^1.1.0 + esc_pos_printer: ^4.1.0 # ─── Voice & Audio ────────────────────────────────── speech_to_text: ^7.3.0 diff --git a/public/index.php b/public/index.php index a5690fa..1841a96 100644 --- a/public/index.php +++ b/public/index.php @@ -39,6 +39,7 @@ $routes = [ 'v1/invoices/delete' => ['POST', 'invoices/delete.php'], 'v1/invoices/bulk-approve' => ['POST', 'invoices/bulk_approve.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/reports/tax-summary' => ['GET', 'reports/tax_summary.php'], 'v1/audit-log' => ['GET', 'audit/index.php'], diff --git a/public/migrate_008.php b/public/migrate_008.php new file mode 100644 index 0000000..cd802ed --- /dev/null +++ b/public/migrate_008.php @@ -0,0 +1,30 @@ +Running Migration 008..."; + echo "
    "; + foreach ($queries as $query) { + if (empty($query)) continue; + $db->exec($query); + echo "
  • ✅ Executed:
    " . htmlspecialchars(substr($query, 0, 50)) . "...
  • "; + } + echo "
"; + echo "

Migration completed successfully!

"; + echo "

Please delete this file (public/migrate_008.php) for security.

"; + +} catch (\Exception $e) { + echo "

Migration failed:

"; + echo "
" . htmlspecialchars($e->getMessage()) . "
"; +} diff --git a/public/shell.php b/public/shell.php index 52abc74..d9e23f4 100644 --- a/public/shell.php +++ b/public/shell.php @@ -1391,6 +1391,9 @@ + @@ -2560,8 +2563,13 @@
- ✅ مدققة ومعتمدة + style="width:100%; display:flex; flex-direction:column; gap:8px;"> +
+ ✅ مدققة ومعتمدة +
+
@@ -2588,6 +2596,7 @@ الكمية السعر الضريبة + التصنيف الإجمالي @@ -2598,7 +2607,18 @@ - + + + + + @@ -2939,6 +2959,25 @@ 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') || []; }, + // ── 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) { if (!inv) return '';