39957-vm/pos_app.php
Flatlogic Bot 3cfcfefee3 v 0.1
2026-05-11 21:40:02 +00:00

514 lines
15 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/db/pos_bootstrap.php';
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
date_default_timezone_set('UTC');
pos_bootstrap();
function pos_project_name(): string
{
$name = trim((string)($_SERVER['PROJECT_NAME'] ?? ''));
return $name !== '' ? $name : 'Senior POS';
}
function pos_project_description(): string
{
$description = trim((string)($_SERVER['PROJECT_DESCRIPTION'] ?? ''));
return $description !== ''
? $description
: 'Punto de venta accesible con categorías, canasta visible, recibos simples y alertas de stock bajo.';
}
function pos_project_image_url(): string
{
return trim((string)($_SERVER['PROJECT_IMAGE_URL'] ?? ''));
}
function pos_demo_users(): array
{
return [
'ADMIN01' => [
'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');
}
}