| 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_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),
];
}