Update: 2026-05-15 04:35:25
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user