Files
musadaq-saas/app/modules_app/invoices/export_excel.php
2026-05-15 04:41:45 +03:00

408 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
// Enable error reporting for debugging
ini_set('display_errors', '1');
error_reporting(E_ALL);
// Autoload PhpSpreadsheet
require_once ROOT_PATH . '/vendor/autoload.php';
try {
// 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;
} catch (\Exception $e) {
if (ob_get_length()) ob_end_clean();
header('Content-Type: text/plain; charset=utf-8');
file_put_contents(STORAGE_PATH . '/logs/export_errors.log', "[" . date('Y-m-d H:i:s') . "] " . $e->getMessage() . "\n" . $e->getTraceAsString(), FILE_APPEND);
die("خطأ في التصدير: " . $e->getMessage());
}