Tul xxx Tul
User / IP
:
216.73.217.33
Host / Server
:
45.84.207.204 / aircan.me
System
:
Linux lt-bnk-web1726.main-hosting.eu 5.14.0-611.36.1.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Mar 3 11:23:52 EST 2026 x86_64
Command
|
Upload
|
Create
Mass Deface
|
Jumping
|
Symlink
|
Reverse Shell
Ping
|
Port Scan
|
DNS Lookup
|
Whois
|
Header
|
cURL
:
/
home
/
u931257429
/
domains
/
aircan.me
/
public_html
/
siscaps
/
controllers
/
Viewing: FacturasController.php
<?php use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; class FacturasController { private function ensureCsrf(): string { if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf']; } public function index(): void { $csrf = $this->ensureCsrf(); $systemData = SystemData::get(); $cutAlertDays = isset($systemData['cut_alert_days']) ? max(0, (int)$systemData['cut_alert_days']) : 0; $filters = [ 'q' => trim($_GET['q'] ?? ''), 'customer_id' => $_GET['customer_id'] ?? '', 'status' => $_GET['status'] ?? '', 'period_start' => $_GET['period_start'] ?? '', 'period_end' => $_GET['period_end'] ?? '', 'issue_from' => $_GET['issue_from'] ?? '', 'issue_to' => $_GET['issue_to'] ?? '', 'category' => $_GET['category'] ?? '', ]; $page = max(1, (int)($_GET['page'] ?? 1)); $perPage = max(1, min(100, (int)($_GET['perPage'] ?? 15))); $result = Invoice::getAll($filters, $page, $perPage); $items = $result['items']; $total = $result['total']; $lastPage = $result['lastPage']; $customers = Customer::getActive(); $statuses = ['Pendiente','Pagado','Parcial','Vencida','Anulada']; $categories = ['Servicio','Inscripcion','Reconexion','Multa','Ventas']; require __DIR__ . '/../views/facturas/index.php'; } public function generate(): void { $csrf = $this->ensureCsrf(); require __DIR__ . '/../views/facturas/generate.php'; } public function create(): void { $csrf = $this->ensureCsrf(); // Asegurar que la columna enum permita 'Ventas' try { $pdo = (new Database())->getConnection(); $col = $pdo->query("SHOW COLUMNS FROM invoices LIKE 'category'")->fetch(PDO::FETCH_ASSOC) ?: []; $type = strtolower((string)($col['Type'] ?? '')); if ($type && strpos($type, "'ventas'") === false) { $pdo->exec("ALTER TABLE invoices MODIFY COLUMN category ENUM('Servicio','Inscripcion','Reconexion','Multa','Ventas') NOT NULL DEFAULT 'Servicio'"); } } catch (Throwable $e) { /* noop */ } // Opciones visibles para factura manual $categories = ['Servicio de agua','Inscripcion','Reconexion','Multa','Ventas']; $statuses = ['Pendiente','Pagado','Parcial','Vencida','Anulada']; $invoice = [ 'customer_id' => '', 'issue_date' => date('Y-m-d'), 'due_date' => Invoice::calculateDueDate(date('Y-m-d')), 'status' => 'Pendiente', 'category' => 'Servicio de agua', 'notes' => '', 'line' => [ 'description' => '', 'quantity' => 1, 'unit_price' => 0.00, ], ]; $prefillCustomer = null; $selectedCustomerId = isset($_GET['customer_id']) ? (int)$_GET['customer_id'] : 0; if ($selectedCustomerId > 0) { $customerRow = Customer::findById($selectedCustomerId); if ($customerRow) { $invoice['customer_id'] = (int)$customerRow['id']; $prefillCustomer = [ 'id' => (int)$customerRow['id'], 'name' => (string)($customerRow['name'] ?? ''), 'code' => (string)($customerRow['customer_code'] ?? ''), ]; } } require __DIR__ . '/../views/facturas/create.php'; } public function store(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.create'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $customer_id = (int)($_POST['customer_id'] ?? 0); $category = trim($_POST['category'] ?? ''); $description = trim($_POST['description'] ?? ''); $quantity = (float)($_POST['quantity'] ?? 1); $unit_price = (float)($_POST['unit_price'] ?? 0); $issue_date = $_POST['issue_date'] ?? date('Y-m-d'); $due_date = trim((string)($_POST['due_date'] ?? '')); if ($due_date === '') { $due_date = Invoice::calculateDueDate($issue_date); } $status = trim($_POST['status'] ?? 'Pendiente'); $notes = trim($_POST['notes'] ?? ''); $tax = (float)($_POST['tax'] ?? 0); $allowedCategories = ['Servicio de agua','Inscripcion','Reconexion','Multa','Ventas']; if (!in_array($category, $allowedCategories, true)) { $_SESSION['flash_error'] = 'Categoría inválida.'; redirect('facturas.create'); } // Mapear etiqueta visible a valor interno de la BD $internalCategory = ($category === 'Servicio de agua') ? 'Servicio' : $category; if ($customer_id <= 0) { $_SESSION['flash_error'] = 'Debes seleccionar un cliente.'; redirect('facturas.create'); } $customer = Customer::findById($customer_id); if (!$customer) { $_SESSION['flash_error'] = 'El cliente seleccionado no existe.'; redirect('facturas.create'); } if ($description === '') { $_SESSION['flash_error'] = 'La descripción es obligatoria.'; redirect('facturas.create'); } if ($quantity <= 0 || $unit_price <= 0) { $_SESSION['flash_error'] = 'La cantidad y el precio unitario deben ser mayores a 0.'; redirect('facturas.create'); } if ($tax < 0) { $_SESSION['flash_error'] = 'Los impuestos no pueden ser negativos.'; redirect('facturas.create'); } $allowedStatuses = ['Pendiente','Pagado','Parcial','Vencida','Anulada']; if (!in_array($status, $allowedStatuses, true)) { $status = 'Pendiente'; } if ($issue_date && $due_date && strtotime($due_date) < strtotime($issue_date)) { $_SESSION['flash_error'] = 'La fecha de vencimiento no puede ser anterior a la fecha de emisión.'; redirect('facturas.create'); } $subtotal = $quantity * $unit_price; $total = $subtotal + $tax; try { $invoiceId = Invoice::createManual([ 'customer_id' => $customer_id, 'issue_date' => $issue_date, 'due_date' => $due_date, 'status' => $status, 'notes' => $notes !== '' ? $notes : null, 'category' => $internalCategory, 'subtotal' => $subtotal, 'tax' => $tax, 'total' => $total, ], [ [ 'description' => $description, 'quantity' => $quantity, 'unit_price' => $unit_price, 'type' => $internalCategory, ], ]); Invoice::refreshLateFeeForInvoice((int)$invoiceId); $_SESSION['flash_success'] = 'Factura creada correctamente.'; header('Location: ' . BASE_URL . '?route=facturas.show&id=' . $invoiceId); exit; } catch (Throwable $e) { $_SESSION['flash_error'] = 'No se pudo crear la factura: ' . $e->getMessage(); redirect('facturas.create'); } } public function generateFromReadings(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { header('Location: ' . BASE_URL . '?route=facturas.generate'); return; } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $period_start = $_POST['period_start'] ?? ''; $period_end = $_POST['period_end'] ?? ''; if (!$period_start || !$period_end) { $_SESSION['flash_error'] = 'Debe seleccionar periodo inicio y fin.'; redirect('facturas.generate'); } $pdo = (new Database())->getConnection(); try { $issueDate = date('Y-m-d'); $dueDate = Invoice::calculateDueDate($issueDate); // Tarifa activa global por fecha: fallback por defecto $tariffId = Invoice::getActiveTariffIdForDate($issueDate); if (!$tariffId) { $_SESSION['flash_error'] = 'No existe una tarifa activa para la fecha de emisión.'; redirect('facturas.generate'); } // Agrupar consumo por cliente para lecturas cuyo periodo se superponga con el rango seleccionado // y solo para clientes activos. Esto permite incluir clientes nuevos y lecturas cuyo periodo // no coincida exactamente con las fechas ingresadas. $sql = "SELECT r.customer_id, SUM(r.consumption_m3) AS total_m3 FROM readings r JOIN customers c ON c.id = r.customer_id WHERE r.period_start <= :pe AND r.period_end >= :ps AND c.status = 'Activo' AND EXISTS (SELECT 1 FROM contracts ct WHERE ct.customer_id = c.id) GROUP BY r.customer_id"; $stmt = $pdo->prepare($sql); $stmt->execute([':ps' => $period_start, ':pe' => $period_end]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); if (!$rows) { $_SESSION['flash_error'] = 'No se encontraron lecturas que se superpongan con el periodo seleccionado. Verifique las fechas.'; redirect('facturas.generate'); } $created = 0; $skipped = 0; $errors = 0; foreach ($rows as $row) { $cid = (int)$row['customer_id']; $cons = (float)$row['total_m3']; // Evitar duplicados $chk = $pdo->prepare('SELECT id FROM invoices WHERE customer_id = :c AND period_start = :ps AND period_end = :pe LIMIT 1'); $chk->execute([':c' => $cid, ':ps' => $period_start, ':pe' => $period_end]); if ($chk->fetchColumn()) { $skipped++; continue; } // Determinar tarifa a usar para el cliente: si tiene asignada y es usable en la fecha, usarla; si no, usar la global $custTariffId = Customer::getTariffId($cid); $tariffToUse = $tariffId; // fallback if ($custTariffId && Tariff::isUsableForDate((int)$custTariffId, $issueDate)) { $tariffToUse = (int)$custTariffId; } try { $calc = Invoice::calculateAmount($cons, $tariffToUse); } catch (Throwable $e) { $errors++; continue; } $data = [ 'customer_id' => $cid, 'period_start' => $period_start, 'period_end' => $period_end, 'issue_date' => $issueDate, 'due_date' => $dueDate, 'consumption_m3' => $cons, 'tariff_id' => $tariffToUse, 'subtotal' => $calc['subtotal'], 'tax' => $calc['tax'], 'total' => $calc['total'], 'status' => 'Pendiente', 'notes' => null, ]; try { Invoice::create($data, $calc['lines']); $created++; } catch (Throwable $e) { $errors++; } } $_SESSION['flash_success'] = "Generación completada: creadas {$created}, omitidas (duplicadas) {$skipped}, con error {$errors}."; redirect('facturas.index'); } catch (Throwable $e) { $_SESSION['flash_error'] = 'Error al generar facturas: ' . $e->getMessage(); redirect('facturas.generate'); } } public function show(): void { $csrf = $this->ensureCsrf(); $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } // Restringir a CLIENTE dueño de la factura $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } // Datos para aviso de suspensión en panel admin $customerIdForWarn = (int)($invoice['customer_id'] ?? 0); $openInvoicesCount = $customerIdForWarn > 0 ? Invoice::countOpenInvoicesByCustomer($customerIdForWarn) : 0; $openBalance = $customerIdForWarn > 0 ? Invoice::openBalanceByCustomer($customerIdForWarn) : 0.0; $systemData = SystemData::get(); $cutAlertDays = isset($systemData['cut_alert_days']) ? max(0, (int)$systemData['cut_alert_days']) : 0; // Map category for friendly label $rawCat = (string)($invoice['category'] ?? ''); $catMap = ['Servicio'=>'Servicio de agua','Ventas'=>'Ventas','Inscripcion'=>'Inscripcion','Reconexion'=>'Reconexion','Multa'=>'Multa']; $invoice['category_label'] = $catMap[$rawCat] ?? ($rawCat !== '' ? $rawCat : 'Servicio de agua'); require __DIR__ . '/../views/facturas/show.php'; } public function cutAlert(): void { $csrf = $this->ensureCsrf(); $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $systemData = SystemData::get(); $cutAlertDays = isset($systemData['cut_alert_days']) ? max(0, (int)$systemData['cut_alert_days']) : 0; $due = (string)($invoice['due_date'] ?? ''); $dueTs = $due !== '' ? strtotime($due . ' 00:00:00') : false; $daysOverdue = 0; if ($dueTs !== false) { $daysOverdue = (int)floor((time() - $dueTs) / 86400); $daysOverdue = max(0, $daysOverdue); } $eligible = $cutAlertDays > 0 && $dueTs !== false && $daysOverdue >= $cutAlertDays; if (!$eligible) { $_SESSION['flash_error'] = 'La alerta de corte no está habilitada o la factura aún no cumple los días configurados.'; header('Location: ' . BASE_URL . '?route=facturas.show&id=' . $id); exit; } $description = trim((string)($_GET['description'] ?? '')); if ($description === '') { $description = 'Falta de Pago'; } require __DIR__ . '/../views/facturas/cut_alert.php'; } public function cutAlertPdf(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.index'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['invoice_id'] ?? 0); if ($id <= 0) { redirect('facturas.index'); } $description = trim((string)($_POST['description'] ?? '')); if ($description === '') { $description = 'Falta de Pago'; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $systemData = SystemData::get(); $cutAlertDays = isset($systemData['cut_alert_days']) ? max(0, (int)$systemData['cut_alert_days']) : 0; $due = (string)($invoice['due_date'] ?? ''); $dueTs = $due !== '' ? strtotime($due . ' 00:00:00') : false; $daysOverdue = 0; if ($dueTs !== false) { $daysOverdue = (int)floor((time() - $dueTs) / 86400); $daysOverdue = max(0, $daysOverdue); } $eligible = $cutAlertDays > 0 && $dueTs !== false && $daysOverdue >= $cutAlertDays; if (!$eligible) { $_SESSION['flash_error'] = 'La alerta de corte no está habilitada o la factura aún no cumple los días configurados.'; header('Location: ' . BASE_URL . '?route=facturas.show&id=' . $id); exit; } $responsible = trim((string)($_SESSION['user']['name'] ?? '')); if ($responsible === '') { $responsible = 'Responsable'; } $meterNumber = ''; try { $meters = Meter::getByCustomer((int)($invoice['customer_id'] ?? 0)); if (!empty($meters) && !empty($meters[0]['number'])) { $meterNumber = (string)$meters[0]['number']; } } catch (Throwable $e) { /* noop */ } $html = $this->renderCutAlertHtml($invoice, $systemData, $description, $responsible, $meterNumber); $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } if (class_exists('Dompdf\\Dompdf')) { $dompdf = new Dompdf\Dompdf(); $dompdf->loadHtml($html); $dompdf->setPaper('A4'); $dompdf->render(); $filename = 'Orden-Corte-' . ($invoice['invoice_number'] ?? $invoice['id']) . '.pdf'; $dompdf->stream($filename, ['Attachment' => true]); return; } header('Content-Type: text/html; charset=UTF-8'); echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } private function renderCutAlertHtml(array $inv, array $systemData, string $description, string $responsible, string $meterNumber): string { $months = [ 1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril', 5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto', 9 => 'Septiembre', 10 => 'Octubre', 11 => 'Noviembre', 12 => 'Diciembre', ]; $fmtDateEs = static function (?string $ymd) use ($months): string { if (!$ymd) { return ''; } $ts = strtotime($ymd); if ($ts === false) { return (string)$ymd; } $d = (int)date('d', $ts); $m = (int)date('n', $ts); $y = (int)date('Y', $ts); $mon = $months[$m] ?? date('m', $ts); return $d . '-' . $mon . '-' . $y; }; $aqueduct = trim((string)($systemData['aqueduct_type'] ?? '')); $place = trim((string)($systemData['municipality'] ?? '')); $committeeName = trim((string)($systemData['committee_name'] ?? '')); $customerName = (string)($inv['customer_name'] ?? ''); $invoiceNumber = (string)($inv['invoice_number'] ?? ('#' . ($inv['id'] ?? ''))); $today = date('Y-m-d'); $account = $meterNumber !== '' ? $meterNumber : '-'; $descSafe = $description; if (mb_strlen($descSafe) > 250) { $descSafe = rtrim(mb_substr($descSafe, 0, 250)) . '...'; } ob_start(); ?> <!doctype html> <html lang="es"> <head> <meta charset="utf-8"> <title>Comunicación de corte del servicio</title> <style> * { box-sizing: border-box; } body { font-family: Arial, Helvetica, sans-serif; font-size: 12px; color:#111; margin: 24px 30px; } .title { text-align:center; font-weight:700; font-size: 14px; letter-spacing: 0.4px; margin-bottom: 18px; text-transform: uppercase; } .meta { width:100%; border-collapse: collapse; margin-top: 8px; } .meta td { padding: 4px 6px; vertical-align: bottom; } .label { width: 110px; font-weight: 700; } .line { border-bottom: 1px solid #000; height: 18px; } .line span { display:inline-block; padding-left: 6px; } .body { margin-top: 18px; line-height: 1.45; text-align: justify; } .sig { margin-top: 40px; text-align: center; } .sig .name { display:inline-block; min-width: 260px; border-bottom: 1px solid #000; padding-bottom: 6px; font-weight: 700; } .sig .role { margin-top: 6px; font-weight: 700; } .small { font-size: 11px; } </style> </head> <body> <div class="title">COMUNICACION DE CORTE DEL SERVICIO</div> <table class="meta"> <tr> <td class="label">ACUEDUCTO:</td> <td class="line"><span><?= htmlspecialchars($aqueduct !== '' ? $aqueduct : ($committeeName !== '' ? $committeeName : '-')) ?></span></td> </tr> <tr> <td class="label">LUGAR:</td> <td class="line"><span><?= htmlspecialchars($place !== '' ? $place : '-') ?></span></td> </tr> <tr> <td class="label">FECHA:</td> <td class="line"><span><?= htmlspecialchars($fmtDateEs($today)) ?></span></td> </tr> <tr> <td class="label">SEÑOR/SEÑORA:</td> <td class="line"><span><?= htmlspecialchars($customerName) ?></span></td> </tr> <tr> <td class="label">N° de cuenta:</td> <td class="line"><span><?= htmlspecialchars($account) ?></span></td> </tr> </table> <div class="body"> <div class="small">Referencia: Factura <?= htmlspecialchars($invoiceNumber) ?></div> <p> De acuerdo con lo establecido en el Articulo N° <strong>2.2</strong> parte I de la Guía para la Organización y Administración de Acueductos Rurales que es de su conocimiento, cumplo en comunicarle que el día <strong><?= htmlspecialchars($fmtDateEs($today)) ?></strong> nos veremos obligados a cortar el servicio de agua en su domicilio por: <strong><?= htmlspecialchars($descSafe) ?></strong>. </p> <p>Saludo a Ud. atentamente,</p> </div> <div class="sig"> <div class="name"><?= htmlspecialchars($responsible) ?></div> <div class="role">Responsable de Finanza y Secretario</div> </div> </body> </html> <?php return (string)ob_get_clean(); } public function downloadPdf(): void { $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } // Restringir a CLIENTE dueño de la factura $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } // Intentar cargar composer si existe $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } $html = $this->renderInvoiceHtml($invoice); if (class_exists('Dompdf\\Dompdf')) { $dompdf = new Dompdf\Dompdf(); $dompdf->loadHtml($html); $dompdf->setPaper('A4'); $dompdf->render(); $dompdf->stream('Factura-' . ($invoice['invoice_number'] ?? $invoice['id']) . '.pdf', ['Attachment' => true]); return; } // Fallback: HTML imprimible header('Content-Type: text/html; charset=UTF-8'); echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } public function printInvoice(): void { $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } // Restringir a CLIENTE dueño de la factura $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); header('Content-Type: text/html; charset=UTF-8'); $paperHeight = null; $html = $this->renderInvoiceTicketHtml($invoice, $paperHeight); $extraCss = '<style> @media screen { html, body { width: 100% !important; } body { font-size: 12px !important; line-height: 1.3 !important; max-width: none !important; } .small { font-size: 10px !important; } .center .b { font-size: 13px !important; } .label { min-width: 55% !important; } } @media print { @page{ size: auto; margin: 6mm 6mm; } html, body { width: 100% !important; } body { font-size: 12px !important; line-height: 1.3 !important; max-width: none !important; } .small { font-size: 10px !important; } .center .b { font-size: 13px !important; } .label { min-width: 55% !important; } } </style>'; $html = preg_replace('/<\/head>/i', $extraCss . '</head>', $html, 1) ?: $html; echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } public function printTicket(): void { $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } // Restringir a CLIENTE dueño de la factura $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); header('Content-Type: text/html; charset=UTF-8'); $paperHeight = null; $html = $this->renderInvoiceTicketHtml($invoice, $paperHeight); $extraCss = '<style> @media screen { html, body { width: 100% !important; } body { font-size: 12px !important; line-height: 1.3 !important; max-width: none !important; } .small { font-size: 10px !important; } .center .b { font-size: 13px !important; } .label { min-width: 55% !important; } } @media print { @page{ size: auto; margin: 6mm 6mm; } html, body { width: 100% !important; } body { font-size: 12px !important; line-height: 1.3 !important; max-width: none !important; } .small { font-size: 10px !important; } .center .b { font-size: 13px !important; } .label { min-width: 55% !important; } } </style>'; $html = preg_replace('/<\/head>/i', $extraCss . '</head>', $html, 1) ?: $html; echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } public function downloadTicket(): void { $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } // Restringir a CLIENTE dueño de la factura $role = $_SESSION['user']['role'] ?? ''; if ($role === 'CLIENTE') { $cid = (int)($_SESSION['user']['customer_id'] ?? 0); if ($cid <= 0 || (int)($invoice['customer_id'] ?? 0) !== $cid) { http_response_code(403); echo '403 Prohibido'; return; } } // Intentar cargar composer si existe (para Dompdf) $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } $paperHeight = null; $html = $this->renderInvoiceTicketHtml($invoice, $paperHeight); if (class_exists('Dompdf\\Dompdf')) { header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); $width = 162; $minHeight = 170; $maxHeight = 2000; $height = (int)($paperHeight ?: 520); $height = max($minHeight, min($maxHeight, $height)); $renderWithHeight = static function (string $html, int $width, int $height): array { $d = new \Dompdf\Dompdf(); $d->loadHtml($html); $d->setPaper([0, 0, $width, $height], 'portrait'); $d->render(); $pages = (int)$d->getCanvas()->get_page_count(); return [$d, $pages]; }; $dompdf = null; $attemptsUp = 0; while (true) { list($dompdf, $pages) = $renderWithHeight($html, $width, $height); if ($pages <= 1 || $attemptsUp >= 4 || $height >= $maxHeight) { break; } $height = min($maxHeight, (int)ceil($height * 1.35)); $attemptsUp++; } if (isset($pages) && (int)$pages <= 1) { $low = $minHeight; $high = $height; for ($i = 0; $i < 10 && $low < $high; $i++) { $mid = (int)floor(($low + $high) / 2); list($tmpDompdf, $midPages) = $renderWithHeight($html, $width, $mid); if ($midPages <= 1) { $high = $mid; } else { $low = $mid + 1; } } $finalHeight = (int)max($minHeight, min($maxHeight, $high + 2)); list($dompdf, $tmpPages) = $renderWithHeight($html, $width, $finalHeight); } $dompdf->stream('Ticket-Factura-' . ($invoice['invoice_number'] ?? $invoice['id']) . '.pdf', ['Attachment' => true]); return; } // Fallback: HTML imprimible ajustado a 57mm header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); header('Content-Type: text/html; charset=UTF-8'); echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } public function markAsPaid(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.index'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { redirect('facturas.index'); } Invoice::refreshLateFeeForInvoice($id); $pdo = (new Database())->getConnection(); $stmt = $pdo->prepare('SELECT total FROM invoices WHERE id = :id LIMIT 1'); $stmt->execute([':id' => $id]); $total = (float)($stmt->fetchColumn() ?: 0); $stmt = $pdo->prepare('SELECT COALESCE(SUM(amount),0) FROM payments WHERE invoice_id = :id'); $stmt->execute([':id' => $id]); $paid = (float)$stmt->fetchColumn(); $status = $paid >= $total && $total > 0 ? 'Pagado' : ($paid > 0 ? 'Parcial' : 'Pendiente'); Invoice::updateStatus($id, $status); $_SESSION['flash_success'] = 'Estado actualizado a ' . $status . '.'; redirect('facturas.index'); } public function edit(): void { $csrf = $this->ensureCsrf(); $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); echo 'ID inválido'; return; } $invoice = Invoice::findById($id); if (!$invoice) { http_response_code(404); echo 'Factura no encontrada'; return; } $manualCategories = ['Inscripcion','Reconexion','Multa']; $statuses = ['Pendiente','Pagado','Parcial','Vencida','Anulada']; require __DIR__ . '/../views/facturas/edit.php'; } public function update(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.index'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { redirect('facturas.index'); } $invoice = Invoice::findById($id); if (!$invoice) { $_SESSION['flash_error'] = 'Factura no encontrada.'; redirect('facturas.index'); } $issue_date = trim($_POST['issue_date'] ?? ''); $due_date = trim($_POST['due_date'] ?? ''); if ($due_date === '' && $issue_date !== '') { $due_date = Invoice::calculateDueDate($issue_date); } $status = trim($_POST['status'] ?? ''); $notes = trim($_POST['notes'] ?? ''); $allowedStatuses = ['Pendiente','Pagado','Parcial','Vencida','Anulada']; if (!in_array($status, $allowedStatuses, true)) { $status = 'Pendiente'; } $category = $invoice['category'] ?? 'Servicio'; $manualCategories = ['Inscripcion','Reconexion','Multa']; if ($category !== 'Servicio') { $postedCategory = trim($_POST['category'] ?? ''); if (!in_array($postedCategory, $manualCategories, true)) { $_SESSION['flash_error'] = 'Categoría inválida para la factura.'; header('Location: ' . BASE_URL . '?route=facturas.edit&id=' . $id); exit; } $category = $postedCategory; } try { $pdo = (new Database())->getConnection(); $stmt = $pdo->prepare("UPDATE invoices SET issue_date = :issue_date, due_date = :due_date, status = :status, notes = :notes, category = :category WHERE id = :id"); $stmt->execute([ ':issue_date' => $issue_date ?: null, ':due_date' => $due_date ?: null, ':status' => $status, ':notes' => ($notes !== '') ? $notes : null, ':category' => $category, ':id' => $id, ]); Invoice::refreshLateFeeForInvoice($id, $pdo); $_SESSION['flash_success'] = 'Factura actualizada correctamente.'; header('Location: ' . BASE_URL . '?route=facturas.show&id=' . $id); exit; } catch (Throwable $e) { $_SESSION['flash_error'] = 'No se pudo actualizar la factura: ' . $e->getMessage(); header('Location: ' . BASE_URL . '?route=facturas.edit&id=' . $id); exit; } } public function delete(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { redirect('facturas.index'); } $token = $_POST['csrf'] ?? ''; if (!$token || !isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(400); echo 'CSRF inválido'; return; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { redirect('facturas.index'); } try { $ok = Invoice::delete($id); if ($ok) { $_SESSION['flash_success'] = 'Factura eliminada correctamente.'; } else { $_SESSION['flash_error'] = 'No se pudo eliminar la factura.'; } } catch (Throwable $e) { $_SESSION['flash_error'] = 'Error al eliminar la factura: ' . $e->getMessage(); } redirect('facturas.index'); } public function export(): void { requireAuth(['ADMIN','CAJERO']); $filters = [ 'q' => trim($_GET['q'] ?? ''), 'customer_id' => $_GET['customer_id'] ?? '', 'status' => $_GET['status'] ?? '', 'period_start' => $_GET['period_start'] ?? '', 'period_end' => $_GET['period_end'] ?? '', 'issue_from' => $_GET['issue_from'] ?? '', 'issue_to' => $_GET['issue_to'] ?? '', 'category' => $_GET['category'] ?? '', ]; $rows = $this->fetchAllInvoices($filters); header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename="facturas.csv"'); echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel $out = fopen('php://output', 'w'); fputcsv($out, ['Nº', 'Cliente', 'Periodo', 'Consumo (m3)', 'Subtotal', 'Impuesto', 'Total', 'Estado', 'Emitida', 'Vence']); foreach ($rows as $r) { fputcsv($out, [ $r['invoice_number'], $r['customer_name'] ?? '', format_period($r['period_start'] ?? '', $r['period_end'] ?? ''), number_format((float)($r['consumption_m3'] ?? 0), 3, '.', ''), number_format((float)($r['subtotal'] ?? 0), 2, '.', ''), number_format((float)($r['tax'] ?? 0), 2, '.', ''), number_format((float)($r['total'] ?? 0), 2, '.', ''), $r['status'] ?? '', $r['issue_date'] ?? '', $r['due_date'] ?? '', ]); } fclose($out); exit; } public function exportExcel(): void { requireAuth(['ADMIN','CAJERO']); $filters = [ 'q' => trim($_GET['q'] ?? ''), 'customer_id' => $_GET['customer_id'] ?? '', 'status' => $_GET['status'] ?? '', 'period_start' => $_GET['period_start'] ?? '', 'period_end' => $_GET['period_end'] ?? '', 'issue_from' => $_GET['issue_from'] ?? '', 'issue_to' => $_GET['issue_to'] ?? '', 'category' => $_GET['category'] ?? '', ]; $rows = $this->fetchAllInvoices($filters); $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } if (!class_exists(Spreadsheet::class)) { http_response_code(500); echo 'PhpSpreadsheet no está disponible.'; return; } $sheetTitle = 'Facturas'; $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle($sheetTitle); $row = 1; $sheet->mergeCells("A{$row}:L{$row}"); $sheet->setCellValue("A{$row}", 'SISCAPS - Reporte de Facturas'); $sheet->getStyle("A{$row}")->getFont()->setBold(true)->setSize(15); $sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $row++; $sheet->mergeCells("A{$row}:L{$row}"); $sheet->setCellValue("A{$row}", 'Generado: ' . date('d/m/Y')); $row++; $sheet->mergeCells("A{$row}:L{$row}"); $sheet->setCellValue("A{$row}", 'Filtros: ' . $this->describeInvoiceFilters($filters)); $row += 2; $headerRow = $row; $headers = [ 'Nº', 'Cliente', 'Código', 'Categoría', 'Estado', 'Periodo', 'Consumo (m³)', 'Subtotal', 'Impuesto', 'Total', 'Emitida', 'Vence', ]; $sheet->fromArray($headers, null, "A{$headerRow}"); $sheet->getStyle("A{$headerRow}:L{$headerRow}")->applyFromArray([ 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], 'fill' => [ 'fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '173e62'], ], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], ]); $row = $headerRow + 1; foreach ($rows as $invoice) { $sheet->fromArray([ $invoice['invoice_number'] ?? '', $invoice['customer_name'] ?? '', $invoice['customer_code'] ?? Customer::findById((int)($invoice['customer_id'] ?? 0))['customer_code'] ?? '', $this->formatInvoiceCategory($invoice['category'] ?? ''), $invoice['status'] ?? '', format_period($invoice['period_start'] ?? '', $invoice['period_end'] ?? ''), (float)($invoice['consumption_m3'] ?? 0), (float)($invoice['subtotal'] ?? 0), (float)($invoice['tax'] ?? 0), (float)($invoice['total'] ?? 0), format_date($invoice['issue_date'] ?? ''), format_date($invoice['due_date'] ?? ''), ], null, "A{$row}"); $row++; } if ($row > $headerRow + 1) { $sheet->getStyle("A" . ($headerRow + 1) . ":L" . ($row - 1))->applyFromArray([ 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_HAIR]], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getStyle("G" . ($headerRow + 1) . ":G" . ($row - 1)) ->getNumberFormat()->setFormatCode('0.000'); $sheet->getStyle("H" . ($headerRow + 1) . ":J" . ($row - 1)) ->getNumberFormat()->setFormatCode('#,##0.00'); } foreach (range('A', 'L') as $col) { $sheet->getColumnDimension($col)->setAutoSize(true); } $sheet->freezePane('A' . ($headerRow + 1)); $filename = 'Facturas_' . date('Ymd_His') . '.xlsx'; header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Cache-Control: max-age=0'); $writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); $writer->save('php://output'); exit; } public function exportPdf(): void { requireAuth(['ADMIN','CAJERO']); $filters = [ 'q' => trim($_GET['q'] ?? ''), 'customer_id' => $_GET['customer_id'] ?? '', 'status' => $_GET['status'] ?? '', 'period_start' => $_GET['period_start'] ?? '', 'period_end' => $_GET['period_end'] ?? '', 'issue_from' => $_GET['issue_from'] ?? '', 'issue_to' => $_GET['issue_to'] ?? '', 'category' => $_GET['category'] ?? '', ]; $rows = $this->fetchAllInvoices($filters); $autoload = __DIR__ . '/../vendor/autoload.php'; if (is_file($autoload)) { @require_once $autoload; } $html = $this->renderInvoicesReportHtml($rows, $filters); if (class_exists('Dompdf\\Dompdf')) { $dompdf = new Dompdf\Dompdf(); $dompdf->loadHtml($html); $dompdf->setPaper('A4', 'landscape'); $dompdf->render(); $dompdf->stream('Facturas_' . date('Ymd_His') . '.pdf', ['Attachment' => true]); return; } header('Content-Type: text/html; charset=UTF-8'); echo $html; echo '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>'; } private function fetchAllInvoices(array $filters): array { $res = Invoice::getAll($filters, 1, 100000); return $res['items'] ?? []; } private function describeInvoiceFilters(array $filters): string { $parts = []; if (!empty($filters['q'])) { $parts[] = 'Búsqueda: "' . $filters['q'] . '"'; } if (!empty($filters['customer_id'])) { $customer = Customer::findById((int)$filters['customer_id']); $parts[] = 'Cliente: ' . ($customer['name'] ?? ('ID ' . $filters['customer_id'])); } if (!empty($filters['status'])) { $parts[] = 'Estado: ' . $filters['status']; } if (!empty($filters['category'])) { $parts[] = 'Categoría: ' . $this->formatInvoiceCategory($filters['category']); } if (!empty($filters['period_start']) || !empty($filters['period_end'])) { $parts[] = 'Periodo: ' . ($filters['period_start'] ?: 'inicio') . ' a ' . ($filters['period_end'] ?: 'hoy'); } if (!empty($filters['issue_from']) || !empty($filters['issue_to'])) { $parts[] = 'Emitida: ' . ($filters['issue_from'] ?: 'inicio') . ' a ' . ($filters['issue_to'] ?: 'hoy'); } return $parts ? implode(' | ', $parts) : 'Sin filtros'; } private function renderInvoicesReportHtml(array $rows, array $filters): string { $generatedAt = date('d/m/Y H:i'); $filtersDesc = $this->describeInvoiceFilters($filters); $systemData = SystemData::get(); $committeeName = trim((string)($systemData['committee_name'] ?? '')); if ($committeeName === '') { $committeeName = 'Comité de Agua Potable y Saneamiento'; } $providerReg = trim((string)($systemData['provider_registration_number'] ?? '')); $municipalCertificate = trim((string)($systemData['municipal_certificate'] ?? '')); $ruc = trim((string)($systemData['ruc_number'] ?? '')); $municipality = trim((string)($systemData['municipality'] ?? '')); $department = trim((string)($systemData['department'] ?? '')); $physicalAddress = trim((string)($systemData['physical_address'] ?? '')); $phone = trim((string)($systemData['phone'] ?? '')); if ($physicalAddress !== '') { $maxAddr = 110; if (mb_strlen($physicalAddress) > $maxAddr) { $physicalAddress = rtrim(mb_substr($physicalAddress, 0, $maxAddr)) . '...'; } } $locationLine = trim(implode(' - ', array_filter([$municipality, $department]))); $regRucLine = trim(implode(' | ', array_filter([ $providerReg !== '' ? ('Reg. prestador: ' . $providerReg) : '', $municipalCertificate !== '' ? ('Cert. municipal: ' . $municipalCertificate) : '', $ruc !== '' ? ('RUC: ' . $ruc) : '', ]))); $contactLine = trim(implode(' | ', array_filter([ $physicalAddress !== '' ? ('Dir.: ' . $physicalAddress) : '', $phone !== '' ? ('Tel.: ' . $phone) : '', ]))); $logoDataUri = null; $logoRel = trim((string)($systemData['logo_path'] ?? '')); if ($logoRel !== '') { $logoAbs = dirname(__DIR__) . '/public/' . ltrim($logoRel, '/'); if (is_file($logoAbs)) { $ext = strtolower(pathinfo($logoAbs, PATHINFO_EXTENSION)); $mimeMap = [ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif', ]; $mime = $mimeMap[$ext] ?? 'application/octet-stream'; $bin = @file_get_contents($logoAbs); if ($bin !== false) { $logoDataUri = 'data:' . $mime . ';base64,' . base64_encode($bin); } } } $customerCodes = []; ob_start(); ?> <!doctype html> <html lang="es"> <head> <meta charset="utf-8"> <title>Reporte de Facturas</title> <style> * { box-sizing: border-box; } body { font-family: Arial, Helvetica, sans-serif; font-size: 10px; color:#222; margin: 12px; } .membrete { border-bottom: 1px solid #ddd; padding-bottom: 6px; margin-bottom: 8px; } .membrete-table { width: 100%; border-collapse: collapse; table-layout: auto; } .membrete-logo { width: 64px; vertical-align: top; padding-right: 8px; } .membrete-logo img { width: 64px; height: 64px; object-fit: contain; } .membrete-info { vertical-align: top; } .membrete-right { width: 260px; vertical-align: top; text-align: right; } .membrete-title { font-size: 12px; font-weight: bold; margin: 0; line-height: 1.2; } .membrete-line { font-size: 9px; margin: 0; line-height: 1.2; color: #333; } .membrete-meta { font-size: 9px; color: #555; word-break: break-word; } table { width:100%; border-collapse: collapse; table-layout: fixed; } th, td { border:1px solid #dee2e6; padding:5px 6px; text-align:left; font-size: 9px; vertical-align: top; word-wrap: break-word; } th { background:#f8f9fa; } .right { text-align:right; } </style> </head> <body> <div class="membrete"> <table class="membrete-table"> <tr> <td class="membrete-logo"> <?php if (!empty($logoDataUri)): ?> <img src="<?= htmlspecialchars($logoDataUri) ?>" alt="Logo"> <?php endif; ?> </td> <td class="membrete-info"> <p class="membrete-title"><?= htmlspecialchars($committeeName) ?></p> <?php if ($regRucLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($regRucLine) ?></p> <?php endif; ?> <?php if ($locationLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($locationLine) ?></p> <?php endif; ?> <?php if ($contactLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($contactLine) ?></p> <?php endif; ?> </td> <td class="membrete-right"> <p class="membrete-title">Facturas</p> <div class="membrete-meta"><strong>Total:</strong> <?= count($rows) ?></div> <div class="membrete-meta"><strong>Generado:</strong> <?= htmlspecialchars($generatedAt) ?></div> <div class="membrete-meta"><strong>Filtro:</strong> <?= htmlspecialchars($filtersDesc) ?></div> </td> </tr> </table> </div> <table> <thead> <tr> <th>Nº</th> <th>Cliente</th> <th>Código</th> <th>Categoría</th> <th>Estado</th> <th>Periodo</th> <th class="right">Consumo (m³)</th> <th class="right">Subtotal</th> <th class="right">Impuesto</th> <th class="right">Total</th> <th>Emitida</th> <th>Vence</th> </tr> </thead> <tbody> <?php if (!empty($rows)): foreach ($rows as $invoice): ?> <tr> <td><?= htmlspecialchars($invoice['invoice_number'] ?? '') ?></td> <td><?= htmlspecialchars($invoice['customer_name'] ?? '') ?></td> <?php $cid = (int)($invoice['customer_id'] ?? 0); if (!array_key_exists($cid, $customerCodes)) { $cRow = $cid > 0 ? Customer::findById($cid) : null; $customerCodes[$cid] = $cRow ? (string)($cRow['customer_code'] ?? '') : ''; } ?> <td><?= htmlspecialchars($invoice['customer_code'] ?? $customerCodes[$cid] ?? '') ?></td> <td><?= htmlspecialchars($this->formatInvoiceCategory($invoice['category'] ?? '')) ?></td> <td><?= htmlspecialchars($invoice['status'] ?? '') ?></td> <td><?= htmlspecialchars(format_period($invoice['period_start'] ?? '', $invoice['period_end'] ?? '')) ?></td> <td class="right"><?= format_num($invoice['consumption_m3'] ?? 0, 3) ?></td> <td class="right"><?= format_currency($invoice['subtotal'] ?? 0) ?></td> <td class="right"><?= format_currency($invoice['tax'] ?? 0) ?></td> <td class="right"><?= format_currency($invoice['total'] ?? 0) ?></td> <td><?= htmlspecialchars(format_date($invoice['issue_date'] ?? '')) ?></td> <td><?= htmlspecialchars(format_date($invoice['due_date'] ?? '')) ?></td> </tr> <?php endforeach; else: ?> <tr><td colspan="12" style="text-align:center;">No existen facturas para los filtros aplicados.</td></tr> <?php endif; ?> </tbody> </table> </body> </html> <?php return (string)ob_get_clean(); } private function formatInvoiceCategory(?string $raw): string { $map = [ 'Servicio' => 'Servicio de agua', 'Ventas' => 'Ventas', 'Inscripcion' => 'Inscripción', 'Reconexion' => 'Reconexión', 'Multa' => 'Multa', ]; return $map[$raw] ?? ($raw !== '' ? $raw : 'Servicio de agua'); } private function renderInvoiceHtml(array $inv): string { $lines = $inv['lines'] ?? []; $openInvoicesCount = 0; $openBalance = 0.0; try { $cid = (int)($inv['customer_id'] ?? 0); $openInvoicesCount = Invoice::countOpenInvoicesByCustomer($cid); $openBalance = Invoice::openBalanceByCustomer($cid); } catch (Throwable $e) { /* noop for PDF safety */ } $systemData = SystemData::get(); $committeeName = trim((string)($systemData['committee_name'] ?? '')); if ($committeeName === '') { $committeeName = 'Comité de Agua Potable y Saneamiento'; } $providerReg = trim((string)($systemData['provider_registration_number'] ?? '')); $municipalCertificate = trim((string)($systemData['municipal_certificate'] ?? '')); $ruc = trim((string)($systemData['ruc_number'] ?? '')); $municipality = trim((string)($systemData['municipality'] ?? '')); $department = trim((string)($systemData['department'] ?? '')); $physicalAddress = trim((string)($systemData['physical_address'] ?? '')); $phone = trim((string)($systemData['phone'] ?? '')); if ($physicalAddress !== '') { $maxAddr = 110; if (mb_strlen($physicalAddress) > $maxAddr) { $physicalAddress = rtrim(mb_substr($physicalAddress, 0, $maxAddr)) . '...'; } } $locationLine = trim(implode(' - ', array_filter([$municipality, $department]))); $regRucLine = trim(implode(' | ', array_filter([ $providerReg !== '' ? ('Reg. prestador: ' . $providerReg) : '', $municipalCertificate !== '' ? ('Cert. municipal: ' . $municipalCertificate) : '', $ruc !== '' ? ('RUC: ' . $ruc) : '', ]))); $contactLine = trim(implode(' | ', array_filter([ $physicalAddress !== '' ? ('Dir.: ' . $physicalAddress) : '', $phone !== '' ? ('Tel.: ' . $phone) : '', ]))); $logoDataUri = null; $logoRel = trim((string)($systemData['logo_path'] ?? '')); if ($logoRel !== '') { $logoAbs = dirname(__DIR__) . '/public/' . ltrim($logoRel, '/'); if (is_file($logoAbs)) { $ext = strtolower(pathinfo($logoAbs, PATHINFO_EXTENSION)); $mimeMap = [ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif', ]; $mime = $mimeMap[$ext] ?? 'application/octet-stream'; $bin = @file_get_contents($logoAbs); if ($bin !== false) { $logoDataUri = 'data:' . $mime . ';base64,' . base64_encode($bin); } } } ob_start(); ?> <!doctype html> <html lang="es"> <head> <meta charset="utf-8"> <title>Factura <?= htmlspecialchars($inv['invoice_number'] ?? ('#'.$inv['id'])) ?></title> <style> * { box-sizing: border-box; } body { font-family: Arial, sans-serif; font-size: 12px; color:#222; margin: 12px; } .membrete { border-bottom: 1px solid #ddd; padding-bottom: 6px; margin-bottom: 10px; } .membrete-table { width: 100%; border-collapse: collapse; table-layout: auto; } .membrete-logo { width: 64px; vertical-align: top; padding-right: 8px; } .membrete-logo img { width: 64px; height: 64px; object-fit: contain; } .membrete-info { vertical-align: top; } .membrete-right { width: 220px; vertical-align: top; text-align: right; } .membrete-title { font-size: 12px; font-weight: bold; margin: 0; line-height: 1.2; } .membrete-line { font-size: 9px; margin: 0; line-height: 1.2; color: #333; } .membrete-meta { font-size: 10px; color: #333; } .box { border:1px solid #ccc; padding:10px; border-radius:6px; } table { width:100%; border-collapse: collapse; } th, td { border-bottom:1px solid #eee; padding:6px; text-align:left; } th { background:#f8f9fa; } .right { text-align:right; } .warn { background:#ffe5e5; border:2px solid #dc3545; color:#7a1d25; padding:10px; border-radius:6px; font-weight:bold; } </style> </head> <body> <div class="membrete"> <table class="membrete-table"> <tr> <td class="membrete-logo"> <?php if (!empty($logoDataUri)): ?> <img src="<?= htmlspecialchars($logoDataUri) ?>" alt="Logo"> <?php endif; ?> </td> <td class="membrete-info"> <p class="membrete-title"><?= htmlspecialchars($committeeName) ?></p> <?php if ($regRucLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($regRucLine) ?></p> <?php endif; ?> <?php if ($locationLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($locationLine) ?></p> <?php endif; ?> <?php if ($contactLine !== ''): ?> <p class="membrete-line"><?= htmlspecialchars($contactLine) ?></p> <?php endif; ?> </td> <td class="membrete-right"> <p class="membrete-title">Factura</p> <div class="membrete-meta"><strong>Nº:</strong> <?= htmlspecialchars($inv['invoice_number'] ?? ('#'.$inv['id'])) ?></div> <div class="membrete-meta"><strong>Emisión:</strong> <?= htmlspecialchars(format_date($inv['issue_date'] ?? '')) ?></div> <div class="membrete-meta"><strong>Vence:</strong> <?= htmlspecialchars(format_date($inv['due_date'] ?? '')) ?></div> </td> </tr> </table> </div> <?php $threshold = defined('SUSPENSION_THRESHOLD') ? SUSPENSION_THRESHOLD : 2; ?> <?php if ($openInvoicesCount > $threshold): ?> <div class="warn" style="margin:10px 0;"> AVISO DE SUSPENSIÓN DEL SERVICIO: Tiene <?= (int)$openInvoicesCount ?> facturas abiertas. Deuda total: <?= format_currency($openBalance) ?>. Evite la suspensión regularizando su cuenta a la brevedad. </div> <?php endif; ?> <hr> <div class="box"> <strong>Cliente:</strong> <?= htmlspecialchars($inv['customer_name'] ?? '') ?><br> <strong>Dirección:</strong> <?= htmlspecialchars($inv['customer_address'] ?? '') ?><br> <strong>Periodo:</strong> <?= htmlspecialchars(format_period($inv['period_start'] ?? '', $inv['period_end'] ?? '')) ?><br> <strong>Consumo:</strong> <?= format_num(($inv['consumption_m3'] ?? 0), 3) ?> m³ </div> <h3>Detalle</h3> <table> <thead><tr><th>Descripción</th><th class="right">Cantidad (m³)</th><th class="right">Precio</th><th class="right">Total</th></tr></thead> <tbody> <?php foreach ($lines as $ln): ?> <tr> <td><?= htmlspecialchars($ln['description'] ?? '') ?></td> <td class="right"><?= format_num(($ln['quantity'] ?? 0), 3) ?></td> <td class="right"><?= format_currency($ln['unit_price'] ?? 0) ?></td> <td class="right"><?= format_currency($ln['line_total'] ?? (($ln['quantity'] ?? 0)*($ln['unit_price'] ?? 0))) ?></td> </tr> <?php endforeach; ?> </tbody> <tfoot> <tr><th colspan="3" class="right">Subtotal</th><th class="right"><?= format_currency($inv['subtotal'] ?? 0) ?></th></tr> <tr><th colspan="3" class="right">Impuestos</th><th class="right"><?= format_currency($inv['tax'] ?? 0) ?></th></tr> <tr><th colspan="3" class="right">Recargo por mora</th><th class="right"><?= format_currency($inv['late_fee_amount'] ?? 0) ?></th></tr> <tr><th colspan="3" class="right">Total</th><th class="right"><?= format_currency($inv['total'] ?? 0) ?></th></tr> <tr><th colspan="3" class="right">Estado</th><th class="right"><?= htmlspecialchars($inv['status'] ?? '') ?></th></tr> </tfoot> </table> </body> </html> <?php return (string)ob_get_clean(); } private function renderInvoiceTicketHtml(array $inv, ?int &$paperHeight = null): string { // Datos de apoyo $invoiceNumber = (string)($inv['invoice_number'] ?? ('#'.$inv['id'])); $customerName = (string)($inv['customer_name'] ?? ''); $address = (string)($inv['customer_address'] ?? ''); $issue = (string)($inv['issue_date'] ?? ''); $due = (string)($inv['due_date'] ?? ''); $pstart = (string)($inv['period_start'] ?? ''); $pend = (string)($inv['period_end'] ?? ''); $cons = (float)($inv['consumption_m3'] ?? 0); $subtotal = (float)($inv['subtotal'] ?? 0); $total = (float)($inv['total'] ?? 0); $status = (string)($inv['status'] ?? 'Pendiente'); $category = (string)($inv['category'] ?? 'Servicio'); $lines = (array)($inv['lines'] ?? []); // Cabecera configurable con fallback del diseño provisto $ORG_NAME = defined('ORG_NAME') ? constant('ORG_NAME') : 'Comité de Agua Potable y Saneamiento'; $ORG_RUC = defined('ORG_RUC') ? constant('ORG_RUC') : 'J08100000052464'; $systemData = class_exists('SystemData') ? SystemData::get() : []; $committeeName = trim((string)($systemData['committee_name'] ?? '')); if ($committeeName === '') { $committeeName = $ORG_NAME; } $providerReg = trim((string)($systemData['provider_registration_number'] ?? '')); $municipalCertificate = trim((string)($systemData['municipal_certificate'] ?? '')); $ruc = trim((string)($systemData['ruc_number'] ?? '')); if ($ruc === '') { $ruc = $ORG_RUC; } $municipality = trim((string)($systemData['municipality'] ?? '')); $department = trim((string)($systemData['department'] ?? '')); $physicalAddress = trim((string)($systemData['physical_address'] ?? '')); $phone = trim((string)($systemData['phone'] ?? '')); $locationLine = trim(implode(' - ', array_filter([$municipality, $department]))); $regLines = array_values(array_filter([ $providerReg !== '' ? ('Reg. prestador: ' . $providerReg) : '', $municipalCertificate !== '' ? ('Cert. municipal: ' . $municipalCertificate) : '', $ruc !== '' ? ('RUC: ' . $ruc) : '', ])); $contactLines = array_values(array_filter([ $physicalAddress !== '' ? ('Dir.: ' . $physicalAddress) : '', $phone !== '' ? ('Tel.: ' . $phone) : '', ])); $logoDataUri = null; $logoRel = trim((string)($systemData['logo_path'] ?? '')); if ($logoRel !== '') { $logoAbs = dirname(__DIR__) . '/public/' . ltrim($logoRel, '/'); if (is_file($logoAbs)) { $ext = strtolower(pathinfo($logoAbs, PATHINFO_EXTENSION)); $mimeMap = [ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif', ]; $mime = $mimeMap[$ext] ?? 'application/octet-stream'; $bin = @file_get_contents($logoAbs); if ($bin !== false) { $logoDataUri = 'data:' . $mime . ';base64,' . base64_encode($bin); } } } // Lectura vinculada por período/cliente (mejor esfuerzo) $meterNumber = ''; $prevReading = null; $currReading = null; try { $pdo = (new Database())->getConnection(); $cid = (int)($inv['customer_id'] ?? 0); if ($cid > 0 && $pstart && $pend) { $q = $pdo->prepare( "SELECT r.*, m.number AS meter_number FROM readings r LEFT JOIN meters m ON m.id = r.meter_id WHERE r.customer_id = :cid AND r.period_start <= :pend AND r.period_end >= :pstart ORDER BY r.reading_date DESC, r.id DESC LIMIT 1" ); $q->execute([':cid'=>$cid, ':pstart'=>$pstart, ':pend'=>$pend]); if ($row = $q->fetch(PDO::FETCH_ASSOC)) { $meterNumber = (string)($row['meter_number'] ?? ''); $prevReading = isset($row['previous_reading']) ? (float)$row['previous_reading'] : null; $currReading = isset($row['reading_value']) ? (float)$row['reading_value'] : null; } } } catch (Throwable $e) { /* ignore for ticket safety */ } // Cálculos auxiliares $days = 0; if ($pstart && $pend) { $d1 = strtotime($pstart); $d2 = strtotime($pend); if ($d1 && $d2 && $d2 >= $d1) { $days = (int)round(($d2 - $d1) / 86400) + 1; } } $avgUnit = ($cons > 0.00001) ? ($subtotal / $cons) : null; // C$ por m3 // Cuentas por cobrar del cliente (saldo pendiente total) $openInvoicesCount = 0; $openBalance = 0.0; try { $cid2 = (int)($inv['customer_id'] ?? 0); if ($cid2 > 0) { $openInvoicesCount = Invoice::countOpenInvoicesByCustomer($cid2); $openBalance = Invoice::openBalanceByCustomer($cid2); } } catch (Throwable $e) { /* ignore */ } $lateFee = (float)($inv['late_fee_amount'] ?? 0); $baseTotal = (float)($inv['subtotal'] ?? 0) + (float)($inv['tax'] ?? 0); $totalConMora = $baseTotal + max(0.0, $lateFee); // Mes legible (preferentemente del periodo) $mesTexto = ''; $srcDate = $pstart ?: $issue; if ($srcDate) { $mesTexto = date('m/Y', strtotime($srcDate)); } // Concepto (primera línea o categoría) $concepto = $category; foreach ($lines as $ln) { if (!empty($ln['description'])) { $concepto = (string)$ln['description']; break; } } $wrapLines = static function (string $text, int $maxChars): int { $t = trim($text); if ($t === '') { return 0; } $len = function_exists('mb_strlen') ? mb_strlen($t) : strlen($t); return (int)max(1, (int)ceil($len / max(1, $maxChars))); }; $headerLines = 0; $headerLines += $wrapLines($committeeName, 28); foreach ($regLines as $l) { $headerLines += $wrapLines((string)$l, 42); } $headerLines += $wrapLines($locationLine, 42); foreach ($contactLines as $l) { $headerLines += $wrapLines((string)$l, 42); } $bodyLines = 0; $bodyLines += 1; $bodyLines += 1 + $wrapLines($customerName, 30); $bodyLines += 9; $bodyLines += 3; $bodyLines += 1; $bodyLines += 2; $bodyLines += 1; $bodyLines += 1; $bodyLines += 1 + $wrapLines($concepto, 32); $bodyLines += 2; $logoBonus = !empty($logoDataUri) ? 55 : 0; $paperHeight = (int)max(170, min(2000, (int)ceil(70 + $logoBonus + (($headerLines * 8) + ($bodyLines * 9)) + 80))); ob_start(); ?> <!doctype html> <html lang="es"> <head> <meta charset="utf-8"> <title>Factura <?= htmlspecialchars($invoiceNumber) ?></title> <style> @page { margin: 3mm 2mm; } body { font-family: DejaVu Sans, Arial, sans-serif; font-size: 8px; line-height:1.15; width: 57mm; margin: 0 auto; color:#000; } .center { text-align:center; } .right { text-align:right; } .b { font-weight:bold; } .sep { height:0; border-top:1px solid #000; margin: 5px 0; } .label { display:inline-block; min-width: 35mm; } .value { display:inline-block; } .small { font-size:6.5px; word-wrap: break-word; overflow-wrap: anywhere; word-break: break-word; } .red { color:#c00; } .logo { max-width: 40mm; max-height: 18mm; margin: 0 auto 2px; display:block; object-fit: contain; } table { width:100%; border-collapse: collapse; } td { padding: 1px 0; vertical-align:top; } </style> </head> <body> <div class="center"> <?php if (!empty($logoDataUri)): ?> <img class="logo" src="<?= htmlspecialchars($logoDataUri) ?>" alt="Logo"> <?php endif; ?> <div class="b"><?= htmlspecialchars($committeeName) ?></div> <?php if (!empty($regLines)): foreach ($regLines as $line): ?> <div class="small"><?= htmlspecialchars($line) ?></div> <?php endforeach; endif; ?> <?php if ($locationLine !== ''): ?> <div class="small"><?= htmlspecialchars($locationLine) ?></div> <?php endif; ?> <?php if (!empty($contactLines)): foreach ($contactLines as $line): ?> <div class="small"><?= htmlspecialchars($line) ?></div> <?php endforeach; endif; ?> </div> <div class="b center red" style="margin-top:4px;">Factura No. <?= htmlspecialchars($invoiceNumber) ?></div> <div style="margin-top:6px;"> <div> <span class="label">Nombre Usuario:</span><br> <span class="value b"><?= htmlspecialchars($customerName) ?></span> </div> <div><span class="label">Fecha de emisión:</span> <span class="value"><?= htmlspecialchars(format_date($issue)) ?></span></div> <div><span class="label">Fecha de vencimiento:</span> <span class="value"><?= htmlspecialchars(format_date($due)) ?></span></div> <div><span class="label">Días facturados:</span> <span class="value"><?= (int)$days ?></span></div> <div><span class="label">Tarifa C$:</span> <span class="value"><?= $avgUnit!==null ? format_num($avgUnit, 2) : 'N/D' ?></span></div> <div><span class="label">Numero de medidor:</span> <span class="value"><?= htmlspecialchars($meterNumber ?: '-') ?></span></div> <div><span class="label">Lectura anterior:</span> <span class="value"><?= $prevReading!==null ? format_num($prevReading, 3) : '-' ?></span></div> <div><span class="label">Lectura actual:</span> <span class="value"><?= $currReading!==null ? format_num($currReading, 3) : '-' ?></span></div> <div><span class="label">Consumo m³ actual:</span> <span class="value b"><?= format_num($cons, 3) ?></span></div> <div> <span class="label">Periodo de consumo:</span><br> <span class="value">Desde <?= htmlspecialchars(format_date($pstart)) ?></span> <span class="value" style="margin-left:1.5mm;">Hasta <?= htmlspecialchars(format_date($pend)) ?></span> </div> </div> <div class="b" style="margin-top:6px;">Pendiente de pago</div> <div style="margin-top:6px;"> <div><span class="label">Cantidad de facturas:</span> <span class="value"><?= (int)$openInvoicesCount ?></span></div> <div><span class="label">Saldo en Mora:</span> <span class="value b"><?= format_currency($openBalance) ?></span></div> </div> <div class="b small" style="margin-top:8px;">Detalle de factura actual</div> <div class="small"> <div><span class="label">Mes:</span> <span class="value"><?= htmlspecialchars($mesTexto) ?></span></div> <div><span class="label">Concepto:</span> <span class="value"><?= htmlspecialchars($concepto) ?></span></div> <div><span class="label">Valor de la factura actual:</span> <span class="value b"><?= format_currency($baseTotal) ?></span></div> <div><span class="label">Recargo por mora:</span> <span class="value b"><?= format_currency($lateFee) ?></span></div> </div> <div class="sep"></div> <div class="b small">Total a cobrar con mora: <span class="right"><?= format_currency($totalConMora) ?></span></div> </body> </html> <?php return (string)ob_get_clean(); } }
Coded With 💗 by
0x6ick