| Server IP : 188.114.96.3 / Your IP : 216.73.216.143 Web Server : Apache System : Linux sv058.home.net.pl 6.8.2 husak_dirty_pages_fix #1 SMP PREEMPT_DYNAMIC Tue Apr 2 22:06:51 CEST 2024 x86_64 User : ( 3194769) PHP Version : 8.5.12 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /polana/includes/ |
Upload File : |
<?php
declare(strict_types=1);
function ensure_tips_kiosk_schema(): void
{
static $ensured = false;
if ($ensured) {
return;
}
ensure_tips_schema();
ensure_operations_schema();
db()->exec(
"CREATE TABLE IF NOT EXISTS tip_kiosk_devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
token_hash CHAR(64) NOT NULL,
device_name VARCHAR(120) DEFAULT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_by_user_id INT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME DEFAULT NULL,
disabled_at DATETIME DEFAULT NULL,
UNIQUE KEY uq_tip_kiosk_token (token_hash),
INDEX idx_tip_kiosk_active (active, created_at),
CONSTRAINT fk_tip_kiosk_device_user
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
);
db()->exec(
"CREATE TABLE IF NOT EXISTS tip_entries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tip_date DATE NOT NULL,
waiter_user_id INT UNSIGNED NOT NULL,
payment_method VARCHAR(12) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
kiosk_device_id BIGINT UNSIGNED DEFAULT NULL,
voided_at DATETIME DEFAULT NULL,
voided_by_user_id INT UNSIGNED DEFAULT NULL,
void_reason VARCHAR(255) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tip_entry_date (tip_date, voided_at),
INDEX idx_tip_entry_waiter (waiter_user_id, tip_date),
INDEX idx_tip_entry_method (tip_date, payment_method),
CONSTRAINT fk_tip_entry_waiter
FOREIGN KEY (waiter_user_id) REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_tip_entry_device
FOREIGN KEY (kiosk_device_id) REFERENCES tip_kiosk_devices(id) ON DELETE SET NULL,
CONSTRAINT fk_tip_entry_void_by
FOREIGN KEY (voided_by_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
);
$ensured = true;
}
function tip_kiosk_cookie_name(): string
{
return 'POLANA_TIP_KIOSK';
}
function tip_kiosk_start_session(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$sessionDir = dirname(__DIR__) . '/storage/sessions';
if (!is_dir($sessionDir)) {
@mkdir($sessionDir, 0700, true);
}
if (is_dir($sessionDir) && is_writable($sessionDir)) {
session_save_path($sessionDir);
}
session_name('POLANA_TIP_KIOSK_SESSION');
$secure = (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
(($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') ||
(($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '') === 'on')
);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/tipy/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
}
function tip_kiosk_csrf_token(): string
{
tip_kiosk_start_session();
if (empty($_SESSION['tip_kiosk_csrf'])) {
$_SESSION['tip_kiosk_csrf'] = bin2hex(random_bytes(32));
}
return (string)$_SESSION['tip_kiosk_csrf'];
}
function tip_kiosk_verify_csrf(): void
{
tip_kiosk_start_session();
$posted = (string)($_POST['csrf_token'] ?? '');
$stored = (string)($_SESSION['tip_kiosk_csrf'] ?? '');
if ($stored === '' || $posted === '' || !hash_equals($stored, $posted)) {
throw new RuntimeException('Sesja terminala wygasła. Spróbuj ponownie.');
}
}
function tip_kiosk_device(): ?array
{
ensure_tips_kiosk_schema();
$token = (string)($_COOKIE[tip_kiosk_cookie_name()] ?? '');
if (strlen($token) < 32) {
return null;
}
$stmt = db()->prepare(
"SELECT *
FROM tip_kiosk_devices
WHERE token_hash=? AND active=1
LIMIT 1"
);
$stmt->execute([hash('sha256', $token)]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
db()->prepare('UPDATE tip_kiosk_devices SET last_seen_at=NOW() WHERE id=?')
->execute([(int)$row['id']]);
return $row;
}
function tip_kiosk_activate_device(string $name, int $actorUserId): array
{
ensure_tips_kiosk_schema();
$name = trim($name);
if ($name === '') {
$name = 'Terminal napiwków';
}
$name = mb_substr($name, 0, 120, 'UTF-8');
$token = bin2hex(random_bytes(32));
$hash = hash('sha256', $token);
$stmt = db()->prepare(
'INSERT INTO tip_kiosk_devices (token_hash,device_name,active,created_by_user_id,last_seen_at)
VALUES (?,?,1,?,NOW())'
);
$stmt->execute([$hash, $name, $actorUserId]);
$secure = (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
(($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') ||
(($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '') === 'on')
);
setcookie(tip_kiosk_cookie_name(), $token, [
'expires' => time() + 365 * 86400,
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Strict',
]);
$_COOKIE[tip_kiosk_cookie_name()] = $token;
return [
'id' => (int)db()->lastInsertId(),
'name' => $name,
];
}
function tip_kiosk_disable_current_device(): void
{
ensure_tips_kiosk_schema();
$token = (string)($_COOKIE[tip_kiosk_cookie_name()] ?? '');
if ($token !== '') {
db()->prepare(
'UPDATE tip_kiosk_devices
SET active=0,disabled_at=NOW()
WHERE token_hash=?'
)->execute([hash('sha256', $token)]);
}
setcookie(tip_kiosk_cookie_name(), '', [
'expires' => time() - 3600,
'path' => '/',
'httponly' => true,
'samesite' => 'Strict',
]);
unset($_COOKIE[tip_kiosk_cookie_name()]);
}
function tip_kiosk_disable_device(int $deviceId): void
{
ensure_tips_kiosk_schema();
db()->prepare(
'UPDATE tip_kiosk_devices SET active=0,disabled_at=NOW() WHERE id=?'
)->execute([$deviceId]);
}
function tip_kiosk_devices(): array
{
ensure_tips_kiosk_schema();
return db()->query(
"SELECT d.*,u.username,u.name
FROM tip_kiosk_devices d
LEFT JOIN users u ON u.id=d.created_by_user_id
ORDER BY d.active DESC,d.id DESC"
)->fetchAll();
}
function staff_pin_is_unique(string $pin, int $excludeUserId = 0): bool
{
ensure_operations_schema();
if (!preg_match('/^\d{4}$/', $pin)) {
return false;
}
$stmt = db()->prepare(
"SELECT id,staff_pin_hash
FROM users
WHERE active=1
AND role IN ('waiter','kitchen')
AND staff_pin_hash IS NOT NULL
AND staff_pin_hash<>''
AND id<>?"
);
$stmt->execute([$excludeUserId]);
foreach ($stmt->fetchAll() as $row) {
if (password_verify($pin, (string)$row['staff_pin_hash'])) {
return false;
}
}
return true;
}
function tip_kiosk_set_waiter_pin(int $userId, string $pin): void
{
ensure_tips_kiosk_schema();
$pin = preg_replace('/\D+/', '', $pin);
if (!preg_match('/^\d{4}$/', $pin)) {
throw new RuntimeException('PIN musi mieć dokładnie 4 cyfry.');
}
$stmt = db()->prepare("SELECT id FROM users WHERE id=? AND active=1 AND role='waiter' LIMIT 1");
$stmt->execute([$userId]);
if (!$stmt->fetch()) {
throw new RuntimeException('Nie znaleziono aktywnego kelnera.');
}
if (!staff_pin_is_unique($pin, $userId)) {
throw new RuntimeException('Ten PIN jest już używany przez innego pracownika.');
}
db()->prepare('UPDATE users SET staff_pin_hash=?,staff_pin_updated_at=NOW() WHERE id=?')
->execute([password_hash($pin, PASSWORD_DEFAULT), $userId]);
}
function tip_kiosk_clear_waiter_pin(int $userId): void
{
ensure_tips_kiosk_schema();
db()->prepare("UPDATE users SET staff_pin_hash=NULL,staff_pin_updated_at=NOW() WHERE id=? AND role='waiter'")
->execute([$userId]);
}
function tip_kiosk_verify_waiter_pin(string $pin): ?array
{
ensure_operations_schema();
if (!preg_match('/^\d{4}$/', $pin)) {
return null;
}
$rows = db()->query(
"SELECT id,username,name,role,staff_pin_hash
FROM users
WHERE active=1
AND role='waiter'
AND staff_pin_hash IS NOT NULL
AND staff_pin_hash<>''"
)->fetchAll();
$matches = [];
foreach ($rows as $row) {
if (password_verify($pin, (string)$row['staff_pin_hash'])) {
$matches[] = $row;
}
}
// PIN musi jednoznacznie wskazać jednego kelnera.
return count($matches) === 1 ? $matches[0] : null;
}
function tip_kiosk_set_active_waiter(array $waiter): void
{
tip_kiosk_start_session();
$_SESSION['tip_kiosk_waiter_id'] = (int)$waiter['id'];
$_SESSION['tip_kiosk_waiter_name'] = (string)($waiter['name'] ?: $waiter['username']);
$_SESSION['tip_kiosk_waiter_expires'] = time() + 60;
}
function tip_kiosk_active_waiter(): ?array
{
tip_kiosk_start_session();
$id = (int)($_SESSION['tip_kiosk_waiter_id'] ?? 0);
$expires = (int)($_SESSION['tip_kiosk_waiter_expires'] ?? 0);
if ($id < 1 || $expires < time()) {
tip_kiosk_clear_active_waiter();
return null;
}
$stmt = db()->prepare(
"SELECT id,username,name,role
FROM users
WHERE id=? AND active=1 AND role='waiter'
LIMIT 1"
);
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function tip_kiosk_clear_active_waiter(): void
{
tip_kiosk_start_session();
unset(
$_SESSION['tip_kiosk_waiter_id'],
$_SESSION['tip_kiosk_waiter_name'],
$_SESSION['tip_kiosk_waiter_expires']
);
}
function tip_kiosk_flash(string $message, string $type = 'success'): void
{
tip_kiosk_start_session();
$_SESSION['tip_kiosk_flash'] = [
'message' => $message,
'type' => $type,
];
}
function tip_kiosk_consume_flash(): ?array
{
tip_kiosk_start_session();
$flash = $_SESSION['tip_kiosk_flash'] ?? null;
unset($_SESSION['tip_kiosk_flash']);
return is_array($flash) ? $flash : null;
}
function tip_kiosk_sums(string $date): array
{
ensure_tips_kiosk_schema();
$stmt = db()->prepare(
"SELECT
COALESCE(SUM(CASE WHEN payment_method='cash' AND voided_at IS NULL THEN amount ELSE 0 END),0) AS cash_amount,
COALESCE(SUM(CASE WHEN payment_method='card' AND voided_at IS NULL THEN amount ELSE 0 END),0) AS card_amount,
COALESCE(SUM(CASE WHEN voided_at IS NULL THEN amount ELSE 0 END),0) AS total_amount,
SUM(CASE WHEN voided_at IS NULL THEN 1 ELSE 0 END) AS entry_count
FROM tip_entries
WHERE tip_date=?"
);
$stmt->execute([$date]);
$row = $stmt->fetch() ?: [];
return [
'cash' => (float)($row['cash_amount'] ?? 0),
'card' => (float)($row['card_amount'] ?? 0),
'total' => (float)($row['total_amount'] ?? 0),
'count' => (int)($row['entry_count'] ?? 0),
];
}
function tip_kiosk_entries(string $date, int $limit = 250): array
{
ensure_tips_kiosk_schema();
$limit = max(1, min(1000, $limit));
$stmt = db()->prepare(
"SELECT e.*,u.username,u.name,d.device_name,
vu.username AS void_username,vu.name AS void_name
FROM tip_entries e
JOIN users u ON u.id=e.waiter_user_id
LEFT JOIN tip_kiosk_devices d ON d.id=e.kiosk_device_id
LEFT JOIN users vu ON vu.id=e.voided_by_user_id
WHERE e.tip_date=?
ORDER BY e.id DESC
LIMIT $limit"
);
$stmt->execute([$date]);
return $stmt->fetchAll();
}
function tip_kiosk_sync_pool(string $date, int $creatorUserId = 0): ?array
{
ensure_tips_kiosk_schema();
if (tip_month_is_closed(substr($date, 0, 7))) {
throw new RuntimeException('Miesiąc jest zamknięty.');
}
$existing = tip_pool_by_date($date, 'all_day');
if ($existing && $existing['status'] === 'settled') {
throw new RuntimeException('Dzisiejsze rozliczenie jest już zamknięte.');
}
if (
$existing &&
(string)$existing['source_type'] !== 'kiosk' &&
tip_pool_total($existing) > 0.001
) {
throw new RuntimeException('Ten dzień ma już ręczne rozliczenie. Manager musi je najpierw uporządkować.');
}
$staff = schedule_entries_for_date($date);
$ids = [];
foreach ($staff as $person) {
if (in_array((string)$person['role'], ['waiter','kitchen'], true)) {
$ids[] = (int)$person['user_id'];
}
}
$ids = array_values(array_unique(array_filter($ids)));
if (!$ids) {
throw new RuntimeException('Brak obsady w grafiku na ten dzień.');
}
$sum = tip_kiosk_sums($date);
$total = round($sum['cash'] + $sum['card'], 2);
$pdo = db();
if ($total <= 0) {
if ($existing && (string)$existing['source_type'] === 'kiosk') {
$pdo->prepare(
"UPDATE tip_pools
SET cash_amount=0,card_amount=0,other_amount=0,note='Terminal napiwków',updated_at=NOW()
WHERE id=?"
)->execute([(int)$existing['id']]);
$pdo->prepare('DELETE FROM tip_pool_participants WHERE pool_id=?')
->execute([(int)$existing['id']]);
}
return $existing ? tip_pool_by_id((int)$existing['id']) : null;
}
$allocation = tip_pool_allocate_by_groups($total, $ids);
if ($existing) {
$poolId = (int)$existing['id'];
$pdo->prepare(
"UPDATE tip_pools
SET cash_amount=?,card_amount=?,other_amount=0,note='Terminal napiwków',
source_type='kiosk',source_file=NULL,source_original_name=NULL,
status='open',settled_at=NULL,settled_by_user_id=NULL,updated_at=NOW()
WHERE id=?"
)->execute([$sum['cash'], $sum['card'], $poolId]);
$pdo->prepare('DELETE FROM tip_pool_participants WHERE pool_id=?')
->execute([$poolId]);
} else {
$pdo->prepare(
"INSERT INTO tip_pools
(tip_date,shift_code,cash_amount,card_amount,other_amount,note,source_type,status,created_by_user_id)
VALUES (?,'all_day',?,?,0,'Terminal napiwków','kiosk','open',?)"
)->execute([
$date,
$sum['cash'],
$sum['card'],
$creatorUserId > 0 ? $creatorUserId : null,
]);
$poolId = (int)$pdo->lastInsertId();
}
$insert = $pdo->prepare(
"INSERT INTO tip_pool_participants
(pool_id,user_id,points,share_amount,payout_status)
VALUES (?,?,1.00,?,'pending')"
);
foreach ($allocation['shares'] as $userId => $share) {
$insert->execute([$poolId, (int)$userId, (float)$share['share']]);
}
return tip_pool_by_id($poolId);
}
function tip_kiosk_add_entry(
int $waiterUserId,
string $method,
float $amount,
int $deviceId
): int {
ensure_tips_kiosk_schema();
if (!in_array($method, ['cash','card'], true)) {
throw new RuntimeException('Wybierz karta albo gotówka.');
}
$amount = round($amount, 2);
if ($amount <= 0 || $amount > 5000) {
throw new RuntimeException('Nieprawidłowa kwota napiwku.');
}
$date = date('Y-m-d');
if (tip_month_is_closed(substr($date, 0, 7))) {
throw new RuntimeException('Dzisiejsze rozliczenie jest już zamknięte.');
}
$pool = tip_pool_by_date($date, 'all_day');
if ($pool && $pool['status'] === 'settled') {
throw new RuntimeException('Dzisiejsze rozliczenie jest już zamknięte.');
}
// Kelner może wbijać napiwki tylko w dniu, w którym jest w grafiku.
if (!schedule_entries_for_date($date, $waiterUserId)) {
throw new RuntimeException('Nie jesteś dziś wpisany do grafiku.');
}
// Nie otwieramy transakcji wokół synchronizacji puli, bo starsze wersje
// ensure_tips_schema()/ensure_schedule_schema() wykonują DDL, a MySQL robi
// przy DDL implicit COMMIT. Zamiast tego stosujemy bezpieczne cofnięcie wpisu.
$pdo = db();
$stmt = $pdo->prepare(
"INSERT INTO tip_entries
(tip_date,waiter_user_id,payment_method,amount,kiosk_device_id)
VALUES (?,?,?,?,?)"
);
$stmt->execute([$date, $waiterUserId, $method, $amount, $deviceId]);
$entryId = (int)$pdo->lastInsertId();
try {
tip_kiosk_sync_pool($date, $waiterUserId);
return $entryId;
} catch (Throwable $e) {
// Jeśli nie udało się przeliczyć puli, nie zostawiamy "osieroconego" tipa.
$pdo->prepare('DELETE FROM tip_entries WHERE id=?')->execute([$entryId]);
throw $e;
}
}
function tip_kiosk_void_entry(int $entryId, int $actorUserId, string $reason): void
{
ensure_tips_kiosk_schema();
$reason = trim($reason);
if (mb_strlen($reason, 'UTF-8') < 3) {
throw new RuntimeException('Podaj krótki powód cofnięcia wpisu.');
}
$stmt = db()->prepare(
"SELECT * FROM tip_entries WHERE id=? LIMIT 1"
);
$stmt->execute([$entryId]);
$entry = $stmt->fetch();
if (!$entry) {
throw new RuntimeException('Nie znaleziono wpisu.');
}
if (!empty($entry['voided_at'])) {
throw new RuntimeException('Ten wpis jest już cofnięty.');
}
$pool = tip_pool_by_date((string)$entry['tip_date']);
if ($pool && $pool['status'] === 'settled') {
throw new RuntimeException('Dzień jest zamknięty. Najpierw otwórz go ponownie.');
}
$pdo = db();
$pdo->prepare(
"UPDATE tip_entries
SET voided_at=NOW(),voided_by_user_id=?,void_reason=?
WHERE id=?"
)->execute([$actorUserId, $reason, $entryId]);
try {
tip_kiosk_sync_pool((string)$entry['tip_date'], $actorUserId);
} catch (Throwable $e) {
// Synchronizacja nie wyszła – przywracamy wpis do stanu aktywnego.
$pdo->prepare(
"UPDATE tip_entries
SET voided_at=NULL,voided_by_user_id=NULL,void_reason=NULL
WHERE id=?"
)->execute([$entryId]);
throw $e;
}
}