[ 'pin' => '4321', 'name' => 'María Ortega', 'role' => 'admin', 'role_label' => 'Administrador', 'branch' => 'Sucursal Centro', ], 'CAJA01' => [ 'pin' => '1234', 'name' => 'Luis Rivera', 'role' => 'cashier', 'role_label' => 'Cajero', 'branch' => 'Sucursal Centro', ], 'CAJA02' => [ 'pin' => '2468', 'name' => 'Ana Torres', 'role' => 'cashier', 'role_label' => 'Cajero', 'branch' => 'Sucursal Norte', ], ]; } function pos_category_meta(): array { return [ 'Frutas' => [ 'icon' => 'bi-apple', 'description' => 'Selecciona frutas frescas con un toque.', ], 'Verduras' => [ 'icon' => 'bi-flower1', 'description' => 'Verduras y hortalizas de salida rápida.', ], 'Lácteos' => [ 'icon' => 'bi-cup-hot', 'description' => 'Lácteos básicos para venta diaria.', ], 'Despensa' => [ 'icon' => 'bi-basket2', 'description' => 'Productos básicos y de reposición.', ], ]; } function pos_current_user(): ?array { return isset($_SESSION['pos_user']) && is_array($_SESSION['pos_user']) ? $_SESSION['pos_user'] : null; } function pos_is_admin(): bool { $user = pos_current_user(); return $user !== null && (($user['role'] ?? '') === 'admin'); } function pos_redirect(string $path): void { header('Location: ' . $path); exit; } function pos_flash_add(string $type, string $message): void { $_SESSION['pos_flashes'][] = [ 'type' => $type, 'message' => $message, ]; } function pos_pull_flashes(): array { $flashes = isset($_SESSION['pos_flashes']) && is_array($_SESSION['pos_flashes']) ? $_SESSION['pos_flashes'] : []; unset($_SESSION['pos_flashes']); return $flashes; } function pos_login_user(string $accessCode, string $pin): bool { $users = pos_demo_users(); $accessCode = strtoupper(trim($accessCode)); $pin = preg_replace('/\D+/', '', $pin) ?? ''; if (!isset($users[$accessCode])) { return false; } $candidate = $users[$accessCode]; if (!hash_equals((string)$candidate['pin'], (string)$pin)) { return false; } session_regenerate_id(true); $_SESSION['pos_user'] = [ 'code' => $accessCode, 'name' => (string)$candidate['name'], 'role' => (string)$candidate['role'], 'role_label' => (string)$candidate['role_label'], 'branch' => (string)$candidate['branch'], ]; return true; } function pos_logout_user(bool $clearCart = true): void { unset($_SESSION['pos_user']); if ($clearCart) { unset($_SESSION['pos_cart']); } session_regenerate_id(true); } function pos_cart(): array { return isset($_SESSION['pos_cart']) && is_array($_SESSION['pos_cart']) ? $_SESSION['pos_cart'] : []; } function pos_save_cart(array $cart): void { $normalized = []; foreach ($cart as $productId => $quantity) { $productId = (int)$productId; $quantity = (int)$quantity; if ($productId > 0 && $quantity > 0) { $normalized[$productId] = $quantity; } } ksort($normalized); $_SESSION['pos_cart'] = $normalized; } function pos_format_money(float $value): string { return '$' . number_format($value, 2, '.', ','); } function pos_format_datetime(?string $value): string { if (!$value) { return '—'; } $timestamp = strtotime($value); return $timestamp ? date('d/m/Y H:i', $timestamp) : '—'; } function pos_cart_summary(): array { $cart = pos_cart(); $products = pos_products_by_ids(array_keys($cart)); $items = []; $subtotal = 0.0; $itemCount = 0; foreach ($cart as $productId => $quantity) { $productId = (int)$productId; $quantity = (int)$quantity; if (!isset($products[$productId])) { continue; } $product = $products[$productId]; $price = (float)$product['price']; $stock = (int)$product['stock']; $lineTotal = $price * $quantity; $subtotal += $lineTotal; $itemCount += $quantity; $items[] = [ 'id' => $productId, 'name' => (string)$product['name'], 'category' => (string)$product['category'], 'price' => $price, 'stock' => $stock, 'unit_label' => (string)$product['unit_label'], 'quantity' => $quantity, 'line_total' => $lineTotal, 'max_reached' => $quantity >= $stock, 'low_stock' => $stock <= (int)$product['low_stock_threshold'], ]; } return [ 'items' => $items, 'subtotal' => $subtotal, 'total' => $subtotal, 'item_count' => $itemCount, ]; } function pos_complete_sale(): ?int { $user = pos_current_user(); $cart = pos_cart(); if ($user === null || $cart === []) { pos_flash_add('warning', 'La canasta está vacía. Agrega productos antes de registrar la venta.'); return null; } $pdo = db(); try { $pdo->beginTransaction(); $lockStmt = $pdo->prepare( "SELECT id, name, category, price, stock, low_stock_threshold, unit_label FROM pos_products WHERE id = :id AND is_active = 1 LIMIT 1 FOR UPDATE" ); $updateStmt = $pdo->prepare( "UPDATE pos_products SET stock = :stock WHERE id = :id LIMIT 1" ); $insertStmt = $pdo->prepare( "INSERT INTO pos_sales ( receipt_number, cashier_code, cashier_name, cashier_role, item_count, subtotal, total, items_json ) VALUES ( :receipt_number, :cashier_code, :cashier_name, :cashier_role, :item_count, :subtotal, :total, :items_json )" ); $items = []; $subtotal = 0.0; $itemCount = 0; foreach ($cart as $productId => $quantity) { $productId = (int)$productId; $quantity = (int)$quantity; if ($quantity < 1) { continue; } $lockStmt->execute(['id' => $productId]); $product = $lockStmt->fetch(); if (!$product) { throw new RuntimeException('Uno de los productos ya no está disponible.'); } $stock = (int)$product['stock']; if ($stock < $quantity) { throw new RuntimeException('Stock insuficiente para ' . (string)$product['name'] . '.'); } $newStock = $stock - $quantity; $updateStmt->execute([ 'stock' => $newStock, 'id' => $productId, ]); $price = (float)$product['price']; $lineTotal = $price * $quantity; $subtotal += $lineTotal; $itemCount += $quantity; $items[] = [ 'product_id' => $productId, 'name' => (string)$product['name'], 'category' => (string)$product['category'], 'unit_label' => (string)$product['unit_label'], 'price' => $price, 'quantity' => $quantity, 'line_total' => round($lineTotal, 2), ]; } if ($items === []) { throw new RuntimeException('La canasta está vacía.'); } $receiptNumber = 'VT-' . gmdate('ymd-His') . '-' . strtoupper(bin2hex(random_bytes(2))); $encodedItems = json_encode($items, JSON_UNESCAPED_UNICODE); if ($encodedItems === false) { $encodedItems = '[]'; } $insertStmt->execute([ 'receipt_number' => $receiptNumber, 'cashier_code' => (string)$user['code'], 'cashier_name' => (string)$user['name'], 'cashier_role' => (string)$user['role_label'], 'item_count' => $itemCount, 'subtotal' => $subtotal, 'total' => $subtotal, 'items_json' => $encodedItems, ]); $saleId = (int)$pdo->lastInsertId(); $pdo->commit(); unset($_SESSION['pos_cart']); $_SESSION['pos_last_receipt_id'] = $saleId; pos_flash_add('success', 'Venta registrada correctamente. Se generó un recibo simple.'); return $saleId; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } error_log('POS checkout error: ' . $exception->getMessage()); $message = $exception instanceof RuntimeException ? $exception->getMessage() : 'Intenta nuevamente en unos segundos.'; pos_flash_add('danger', 'No se pudo registrar la venta. ' . $message); return null; } } function pos_require_login(): array { $user = pos_current_user(); if ($user === null) { pos_flash_add('warning', 'Inicia sesión para acceder al punto de venta.'); pos_redirect('index.php'); } return $user; } function pos_handle_post_request(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { return; } $action = trim((string)($_POST['action'] ?? '')); if ($action === '') { return; } if ($action === 'login') { $accessCode = (string)($_POST['access_code'] ?? ''); $pin = (string)($_POST['pin'] ?? ''); if (pos_login_user($accessCode, $pin)) { pos_flash_add('success', 'Sesión iniciada correctamente.'); } else { pos_flash_add('danger', 'Código o PIN incorrecto. Usa uno de los accesos de prueba visibles.'); } pos_redirect('index.php'); } if ($action === 'logout') { pos_logout_user(); pos_flash_add('secondary', 'Sesión cerrada.'); pos_redirect('index.php'); } $user = pos_current_user(); if ($user === null) { pos_flash_add('warning', 'Debes iniciar sesión para continuar.'); pos_redirect('index.php'); } if ($action === 'add_to_cart') { $productId = (int)($_POST['product_id'] ?? 0); $product = pos_product_by_id($productId); if (!$product) { pos_flash_add('danger', 'No encontramos ese producto.'); pos_redirect('index.php#catalogo'); } $cart = pos_cart(); $currentQuantity = (int)($cart[$productId] ?? 0); $availableStock = (int)$product['stock']; if ($availableStock <= $currentQuantity) { pos_flash_add('warning', 'No hay más stock disponible para ' . (string)$product['name'] . '.'); pos_redirect('index.php#catalogo'); } $cart[$productId] = $currentQuantity + 1; pos_save_cart($cart); pos_flash_add('success', 'Producto agregado a la canasta.'); pos_redirect('index.php#carrito'); } if ($action === 'change_qty') { $productId = (int)($_POST['product_id'] ?? 0); $delta = (int)($_POST['delta'] ?? 0); $cart = pos_cart(); $product = pos_product_by_id($productId); if (!$product || !isset($cart[$productId])) { pos_flash_add('warning', 'Ese producto ya no está en la canasta.'); pos_redirect('index.php#carrito'); } $currentQuantity = (int)$cart[$productId]; $nextQuantity = $currentQuantity + $delta; if ($delta > 0 && $nextQuantity > (int)$product['stock']) { pos_flash_add('warning', 'Stock insuficiente para aumentar la cantidad.'); pos_redirect('index.php#carrito'); } if ($nextQuantity <= 0) { unset($cart[$productId]); pos_save_cart($cart); pos_flash_add('info', 'Producto quitado de la canasta.'); pos_redirect('index.php#carrito'); } $cart[$productId] = $nextQuantity; pos_save_cart($cart); pos_flash_add('success', 'Cantidad actualizada.'); pos_redirect('index.php#carrito'); } if ($action === 'remove_item') { $productId = (int)($_POST['product_id'] ?? 0); $cart = pos_cart(); if (isset($cart[$productId])) { unset($cart[$productId]); pos_save_cart($cart); pos_flash_add('info', 'Producto quitado de la canasta.'); } pos_redirect('index.php#carrito'); } if ($action === 'cancel_cart') { unset($_SESSION['pos_cart']); pos_flash_add('warning', 'Venta anulada. La canasta quedó vacía.'); pos_redirect('index.php#carrito'); } if ($action === 'adjust_stock') { if (!pos_is_admin()) { pos_flash_add('danger', 'Solo el administrador puede ajustar stock.'); pos_redirect('index.php#stock'); } $productId = (int)($_POST['product_id'] ?? 0); $delta = (int)($_POST['delta'] ?? 0); $allowed = [-5, -1, 1, 5]; if (!in_array($delta, $allowed, true)) { pos_flash_add('danger', 'Ajuste de stock inválido.'); pos_redirect('index.php#stock'); } $updated = pos_adjust_stock($productId, $delta); if ($updated === null) { pos_flash_add('danger', 'No se pudo actualizar el stock.'); pos_redirect('index.php#stock'); } $message = $delta > 0 ? 'Stock aumentado correctamente.' : 'Stock reducido correctamente.'; pos_flash_add('success', $message); pos_redirect('index.php#stock'); } if ($action === 'checkout') { $saleId = pos_complete_sale(); if ($saleId !== null) { pos_redirect('receipt.php?id=' . $saleId); } pos_redirect('index.php#carrito'); } }