514 lines
15 KiB
PHP
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');
|
|
}
|
|
}
|