Update: 2026-05-15 04:35:25
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
394
app/modules_app/invoices/export_excel.php
Normal file
394
app/modules_app/invoices/export_excel.php
Normal 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;
|
||||||
41
database/migrations/008_invoice_lines_enhance.sql
Normal file
41
database/migrations/008_invoice_lines_enhance.sql
Normal 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
34
musadaq-app/build_shorebird.sh
Executable 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 "--------------------------------------------------"
|
||||||
109
musadaq-app/lib/core/services/thermal_printer_service.dart
Normal file
109
musadaq-app/lib/core/services/thermal_printer_service.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('خطأ', 'حدث خطأ أثناء الطباعة');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
30
public/migrate_008.php
Normal 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>";
|
||||||
|
}
|
||||||
@@ -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 '';
|
||||||
|
|||||||
Reference in New Issue
Block a user