782 lines
29 KiB
PHP
782 lines
29 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
function ledger_normalize_email_norms(array $emails): array
|
|
{
|
|
$normalized = [];
|
|
foreach ($emails as $email) {
|
|
$emailNorm = strtolower(trim((string)$email));
|
|
if ($emailNorm !== '') {
|
|
$normalized[$emailNorm] = true;
|
|
}
|
|
}
|
|
|
|
return array_keys($normalized);
|
|
}
|
|
|
|
/**
|
|
* @return list<int>
|
|
*/
|
|
function ledger_normalize_ids(array $ids): array
|
|
{
|
|
$normalized = [];
|
|
foreach ($ids as $id) {
|
|
$id = (int)$id;
|
|
if ($id > 0) {
|
|
$normalized[$id] = true;
|
|
}
|
|
}
|
|
|
|
return array_keys($normalized);
|
|
}
|
|
|
|
function ledger_current_year(): int
|
|
{
|
|
return (int)date('Y');
|
|
}
|
|
|
|
function ledger_default_tenant_slug(): string
|
|
{
|
|
$tenantSlug = app_env('M4_DEFAULT_TENANT_SLUG');
|
|
if ($tenantSlug === null || trim($tenantSlug) === '') {
|
|
$tenantSlug = app_env('M3_DEFAULT_TENANT_SLUG', 'default');
|
|
}
|
|
|
|
return trim((string)$tenantSlug) !== '' ? trim((string)$tenantSlug) : 'default';
|
|
}
|
|
|
|
/**
|
|
* @return array{id: int, name: string, slug: string}|null
|
|
*/
|
|
function ledger_fetch_tenant_by_slug(PDO $pdo, string $slug): ?array
|
|
{
|
|
$stmt = $pdo->prepare('SELECT id, name, slug FROM tenants WHERE slug = ? LIMIT 1');
|
|
$stmt->execute([trim($slug)]);
|
|
$tenant = $stmt->fetch();
|
|
|
|
if ($tenant === false) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int)$tenant['id'],
|
|
'name' => (string)$tenant['name'],
|
|
'slug' => (string)$tenant['slug'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{id: int, name: string, slug: string}|null
|
|
*/
|
|
function ledger_fetch_default_tenant(PDO $pdo): ?array
|
|
{
|
|
return ledger_fetch_tenant_by_slug($pdo, ledger_default_tenant_slug());
|
|
}
|
|
|
|
/**
|
|
* @return array{total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}
|
|
*/
|
|
function ledger_empty_totals(): array
|
|
{
|
|
return [
|
|
'total_payments_cents' => 0,
|
|
'total_costs_cents' => 0,
|
|
'total_marks' => 0,
|
|
'balance_cents' => 0,
|
|
'year_payments_cents' => 0,
|
|
'year_costs_cents' => 0,
|
|
'year_marks' => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}
|
|
*/
|
|
function ledger_fetch_tenant_totals(PDO $pdo, int $tenantId, ?int $year = null): array
|
|
{
|
|
$year = $year ?? ledger_current_year();
|
|
$stmt = $pdo->prepare(
|
|
"SELECT
|
|
COALESCE(SUM(CASE WHEN type = 'payment' THEN amount_cents ELSE 0 END), 0) AS total_payments_cents,
|
|
COALESCE(SUM(CASE WHEN type = 'consumption' THEN -amount_cents ELSE 0 END), 0) AS total_costs_cents,
|
|
COALESCE(SUM(CASE WHEN type = 'consumption' THEN marks_count ELSE 0 END), 0) AS total_marks,
|
|
COALESCE(SUM(amount_cents), 0) AS balance_cents,
|
|
COALESCE(SUM(CASE WHEN type = 'payment' AND YEAR(booked_at) = ? THEN amount_cents ELSE 0 END), 0) AS year_payments_cents,
|
|
COALESCE(SUM(CASE WHEN type = 'consumption' AND YEAR(booked_at) = ? THEN -amount_cents ELSE 0 END), 0) AS year_costs_cents,
|
|
COALESCE(SUM(CASE WHEN type = 'consumption' AND YEAR(booked_at) = ? THEN marks_count ELSE 0 END), 0) AS year_marks
|
|
FROM ledger_entries
|
|
WHERE tenant_id = ?
|
|
AND voided_at IS NULL"
|
|
);
|
|
$stmt->execute([$year, $year, $year, $tenantId]);
|
|
$row = $stmt->fetch();
|
|
|
|
if ($row === false) {
|
|
return ledger_empty_totals();
|
|
}
|
|
|
|
return [
|
|
'total_payments_cents' => (int)$row['total_payments_cents'],
|
|
'total_costs_cents' => (int)$row['total_costs_cents'],
|
|
'total_marks' => (int)$row['total_marks'],
|
|
'balance_cents' => (int)$row['balance_cents'],
|
|
'year_payments_cents' => (int)$row['year_payments_cents'],
|
|
'year_costs_cents' => (int)$row['year_costs_cents'],
|
|
'year_marks' => (int)$row['year_marks'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Options:
|
|
* - year: int
|
|
* - active_only: bool|null
|
|
* - participant_ids: list<int>
|
|
* - legacy_mitarbeiter_ids: list<int>
|
|
* - email_norms: list<string>
|
|
*
|
|
* @return list<array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
|
|
*/
|
|
function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $options = []): array
|
|
{
|
|
$year = isset($options['year']) ? (int)$options['year'] : ledger_current_year();
|
|
$where = ['p.tenant_id = ?'];
|
|
$params = [$year, $year, $year, $tenantId];
|
|
|
|
if (array_key_exists('active_only', $options) && $options['active_only'] !== null) {
|
|
$where[] = 'p.active = ?';
|
|
$params[] = $options['active_only'] ? 1 : 0;
|
|
}
|
|
|
|
if (array_key_exists('participant_ids', $options)) {
|
|
$participantIds = is_array($options['participant_ids'])
|
|
? ledger_normalize_ids($options['participant_ids'])
|
|
: [];
|
|
if ($participantIds === []) {
|
|
return [];
|
|
}
|
|
$where[] = 'p.id IN (' . implode(',', array_fill(0, count($participantIds), '?')) . ')';
|
|
array_push($params, ...$participantIds);
|
|
}
|
|
|
|
if (array_key_exists('legacy_mitarbeiter_ids', $options)) {
|
|
$legacyIds = is_array($options['legacy_mitarbeiter_ids'])
|
|
? ledger_normalize_ids($options['legacy_mitarbeiter_ids'])
|
|
: [];
|
|
if ($legacyIds === []) {
|
|
return [];
|
|
}
|
|
$where[] = 'p.legacy_mitarbeiter_id IN (' . implode(',', array_fill(0, count($legacyIds), '?')) . ')';
|
|
array_push($params, ...$legacyIds);
|
|
}
|
|
|
|
if (array_key_exists('email_norms', $options)) {
|
|
$emailNorms = is_array($options['email_norms'])
|
|
? ledger_normalize_email_norms($options['email_norms'])
|
|
: [];
|
|
if ($emailNorms === []) {
|
|
return [];
|
|
}
|
|
$where[] = 'p.email_norm IN (' . implode(',', array_fill(0, count($emailNorms), '?')) . ')';
|
|
array_push($params, ...$emailNorms);
|
|
}
|
|
|
|
if (array_key_exists('user_ids', $options)) {
|
|
$userIds = is_array($options['user_ids'])
|
|
? ledger_normalize_ids($options['user_ids'])
|
|
: [];
|
|
if ($userIds === []) {
|
|
return [];
|
|
}
|
|
$where[] = 'p.user_id IN (' . implode(',', array_fill(0, count($userIds), '?')) . ')';
|
|
array_push($params, ...$userIds);
|
|
}
|
|
|
|
$sql = "
|
|
SELECT
|
|
p.id AS participant_id,
|
|
p.tenant_id,
|
|
p.legacy_mitarbeiter_id,
|
|
p.display_name,
|
|
p.email,
|
|
p.email_norm,
|
|
p.paypal_name,
|
|
p.active,
|
|
COALESCE(SUM(CASE WHEN le.type = 'payment' THEN le.amount_cents ELSE 0 END), 0) AS total_payments_cents,
|
|
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN -le.amount_cents ELSE 0 END), 0) AS total_costs_cents,
|
|
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN le.marks_count ELSE 0 END), 0) AS total_marks,
|
|
COALESCE(SUM(le.amount_cents), 0) AS balance_cents,
|
|
COALESCE(SUM(CASE WHEN le.type = 'payment' AND YEAR(le.booked_at) = ? THEN le.amount_cents ELSE 0 END), 0) AS year_payments_cents,
|
|
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN -le.amount_cents ELSE 0 END), 0) AS year_costs_cents,
|
|
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN le.marks_count ELSE 0 END), 0) AS year_marks
|
|
FROM participants p
|
|
LEFT JOIN ledger_entries le
|
|
ON le.tenant_id = p.tenant_id
|
|
AND le.participant_id = p.id
|
|
AND le.voided_at IS NULL
|
|
WHERE " . implode(' AND ', $where) . "
|
|
GROUP BY p.id, p.tenant_id, p.legacy_mitarbeiter_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active
|
|
ORDER BY p.display_name, p.id";
|
|
|
|
$stmt = $pdo->prepare($sql);
|
|
$stmt->execute($params);
|
|
|
|
$summaries = [];
|
|
while ($row = $stmt->fetch()) {
|
|
$summaries[] = [
|
|
'participant_id' => (int)$row['participant_id'],
|
|
'tenant_id' => (int)$row['tenant_id'],
|
|
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
|
|
'display_name' => (string)$row['display_name'],
|
|
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
|
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
|
|
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
|
|
'active' => (int)$row['active'] === 1,
|
|
'total_payments_cents' => (int)$row['total_payments_cents'],
|
|
'total_costs_cents' => (int)$row['total_costs_cents'],
|
|
'total_marks' => (int)$row['total_marks'],
|
|
'balance_cents' => (int)$row['balance_cents'],
|
|
'year_payments_cents' => (int)$row['year_payments_cents'],
|
|
'year_costs_cents' => (int)$row['year_costs_cents'],
|
|
'year_marks' => (int)$row['year_marks'],
|
|
];
|
|
}
|
|
|
|
return $summaries;
|
|
}
|
|
|
|
/**
|
|
* @return array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
|
|
*/
|
|
function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $participantId, ?int $year = null): ?array
|
|
{
|
|
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, [
|
|
'participant_ids' => [$participantId],
|
|
'year' => $year ?? ledger_current_year(),
|
|
]);
|
|
|
|
return $summaries[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @return array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
|
|
*/
|
|
function ledger_fetch_participant_summary_by_legacy_id(PDO $pdo, int $tenantId, int $legacyMitarbeiterId, ?int $year = null): ?array
|
|
{
|
|
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, [
|
|
'legacy_mitarbeiter_ids' => [$legacyMitarbeiterId],
|
|
'year' => $year ?? ledger_current_year(),
|
|
]);
|
|
|
|
return $summaries[0] ?? null;
|
|
}
|
|
|
|
function ledger_mirror_legacy_consumption(PDO $pdo, int $tenantId, int $legacyConsumptionId): void
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO ledger_entries
|
|
(tenant_id, participant_id, type, amount_cents, marks_count,
|
|
unit_price_cents, booked_at, source, note, legacy_table, legacy_id)
|
|
SELECT
|
|
p.tenant_id,
|
|
p.id,
|
|
'consumption',
|
|
-CAST(ROUND(v.Kosten * 100) AS SIGNED),
|
|
v.AnzahlStriche,
|
|
CAST(ROUND(v.KostenproStrich * 100) AS SIGNED),
|
|
v.Datum,
|
|
CASE
|
|
WHEN v.Eintragsart = 2 THEN 'legacy_web'
|
|
ELSE 'legacy_manual'
|
|
END,
|
|
CASE
|
|
WHEN v.Eintragsart IS NULL THEN NULL
|
|
ELSE CONCAT('Legacy Eintragsart: ', v.Eintragsart)
|
|
END,
|
|
'kl_Kaffeeverbrauch',
|
|
v.VerbrauchID
|
|
FROM kl_Kaffeeverbrauch v
|
|
JOIN participants p
|
|
ON p.tenant_id = ?
|
|
AND p.legacy_mitarbeiter_id = v.MitarbeiterID
|
|
WHERE v.VerbrauchID = ?
|
|
ON DUPLICATE KEY UPDATE
|
|
participant_id = VALUES(participant_id),
|
|
type = VALUES(type),
|
|
amount_cents = VALUES(amount_cents),
|
|
marks_count = VALUES(marks_count),
|
|
unit_price_cents = VALUES(unit_price_cents),
|
|
booked_at = VALUES(booked_at),
|
|
source = VALUES(source),
|
|
note = VALUES(note)"
|
|
);
|
|
$stmt->execute([$tenantId, $legacyConsumptionId]);
|
|
|
|
$check = $pdo->prepare(
|
|
"SELECT COUNT(*)
|
|
FROM ledger_entries
|
|
WHERE tenant_id = ?
|
|
AND legacy_table = 'kl_Kaffeeverbrauch'
|
|
AND legacy_id = ?"
|
|
);
|
|
$check->execute([$tenantId, $legacyConsumptionId]);
|
|
if ((int)$check->fetchColumn() !== 1) {
|
|
throw new RuntimeException('Der Legacy-Strich konnte nicht ins Ledger gespiegelt werden.');
|
|
}
|
|
}
|
|
|
|
function ledger_is_default_tenant(PDO $pdo, int $tenantId): bool
|
|
{
|
|
$tenant = ledger_fetch_default_tenant($pdo);
|
|
|
|
return $tenant !== null && (int)$tenant['id'] === $tenantId;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{participant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, legacy_mitarbeiter_id: ?int, user_id: ?int, role: ?string, membership_status: ?string}>
|
|
*/
|
|
function ledger_fetch_participants_for_admin(PDO $pdo, int $tenantId): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT
|
|
p.id AS participant_id,
|
|
p.display_name,
|
|
p.email,
|
|
p.email_norm,
|
|
p.paypal_name,
|
|
p.active,
|
|
p.legacy_mitarbeiter_id,
|
|
p.user_id,
|
|
tm.role,
|
|
tm.status AS membership_status
|
|
FROM participants p
|
|
LEFT JOIN tenant_memberships tm
|
|
ON tm.tenant_id = p.tenant_id
|
|
AND tm.user_id = p.user_id
|
|
WHERE p.tenant_id = ?
|
|
ORDER BY p.display_name, p.id"
|
|
);
|
|
$stmt->execute([$tenantId]);
|
|
|
|
$rows = [];
|
|
while ($row = $stmt->fetch()) {
|
|
$rows[] = [
|
|
'participant_id' => (int)$row['participant_id'],
|
|
'display_name' => (string)$row['display_name'],
|
|
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
|
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
|
|
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
|
|
'active' => (int)$row['active'] === 1,
|
|
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
|
|
'user_id' => $row['user_id'] !== null ? (int)$row['user_id'] : null,
|
|
'role' => $row['role'] !== null ? (string)$row['role'] : null,
|
|
'membership_status' => $row['membership_status'] !== null ? (string)$row['membership_status'] : null,
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Creates a tenant-scoped participant. For the default tenant, a matching
|
|
* kl_Mitarbeiter row is dual-written too, so the still-legacy bulk entry
|
|
* pages (stricheintragen.php, einzahlung.php) keep listing the member; other
|
|
* tenants have no legacy shadow table and get a participants-only row.
|
|
*
|
|
* @throws Throwable on constraint violations (e.g. duplicate email)
|
|
*/
|
|
function ledger_create_participant(PDO $pdo, int $tenantId, string $displayName, string $email, ?string $paypalName, bool $active): int
|
|
{
|
|
$displayName = trim($displayName);
|
|
$email = trim($email);
|
|
$emailNorm = strtolower($email);
|
|
$paypalName = $paypalName !== null ? trim($paypalName) : null;
|
|
$paypalName = $paypalName !== '' ? $paypalName : null;
|
|
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$legacyMitarbeiterId = null;
|
|
if (ledger_is_default_tenant($pdo, $tenantId)) {
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO kl_Mitarbeiter (Name, Email, paypalname, aktiv, admin) VALUES (?, ?, ?, ?, 0)'
|
|
);
|
|
$stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0]);
|
|
$legacyMitarbeiterId = (int)$pdo->lastInsertId();
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO participants
|
|
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
$stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]);
|
|
$participantId = (int)$pdo->lastInsertId();
|
|
|
|
$pdo->commit();
|
|
|
|
return $participantId;
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId, string $displayName, string $email, ?string $paypalName, bool $active): bool
|
|
{
|
|
$displayName = trim($displayName);
|
|
$email = trim($email);
|
|
$emailNorm = strtolower($email);
|
|
$paypalName = $paypalName !== null ? trim($paypalName) : null;
|
|
$paypalName = $paypalName !== '' ? $paypalName : null;
|
|
|
|
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
|
|
$stmt->execute([$participantId, $tenantId]);
|
|
$row = $stmt->fetch();
|
|
if ($row === false) {
|
|
return false;
|
|
}
|
|
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
|
|
|
|
try {
|
|
$pdo->beginTransaction();
|
|
|
|
if ($legacyMitarbeiterId !== null) {
|
|
$stmt = $pdo->prepare(
|
|
'UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = ?, aktiv = ? WHERE MitarbeiterID = ?'
|
|
);
|
|
$stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]);
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'UPDATE participants
|
|
SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ?
|
|
WHERE id = ? AND tenant_id = ?'
|
|
);
|
|
$stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]);
|
|
|
|
$pdo->commit();
|
|
|
|
return true;
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participantId, bool $active): bool
|
|
{
|
|
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
|
|
$stmt->execute([$participantId, $tenantId]);
|
|
$row = $stmt->fetch();
|
|
if ($row === false) {
|
|
return false;
|
|
}
|
|
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
|
|
|
|
try {
|
|
$pdo->beginTransaction();
|
|
|
|
if ($legacyMitarbeiterId !== null) {
|
|
$pdo->prepare('UPDATE kl_Mitarbeiter SET aktiv = ? WHERE MitarbeiterID = ?')
|
|
->execute([$active ? 1 : 0, $legacyMitarbeiterId]);
|
|
}
|
|
|
|
$pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?')
|
|
->execute([$active ? 1 : 0, $participantId, $tenantId]);
|
|
|
|
$pdo->commit();
|
|
|
|
return true;
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Anonymizes a participant (GDPR-style erasure) instead of deleting them:
|
|
* name and email are replaced with a non-identifying placeholder and the
|
|
* participant is deactivated, but their ledger history stays intact for
|
|
* the tenant's bookkeeping. For the default tenant, the linked
|
|
* kl_Mitarbeiter row is anonymized the same way (its email column is
|
|
* NOT NULL UNIQUE, so it gets a placeholder instead of NULL).
|
|
*/
|
|
function ledger_anonymize_participant(PDO $pdo, int $tenantId, int $participantId): bool
|
|
{
|
|
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
|
|
$stmt->execute([$participantId, $tenantId]);
|
|
$row = $stmt->fetch();
|
|
if ($row === false) {
|
|
return false;
|
|
}
|
|
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
|
|
$placeholderName = 'Gelöschter Teilnehmer #' . $participantId;
|
|
$placeholderEmail = "deleted-participant-{$participantId}@invalid.local";
|
|
|
|
try {
|
|
$pdo->beginTransaction();
|
|
|
|
if ($legacyMitarbeiterId !== null) {
|
|
$pdo->prepare('UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = NULL, aktiv = 0 WHERE MitarbeiterID = ?')
|
|
->execute([$placeholderName, $placeholderEmail, $legacyMitarbeiterId]);
|
|
}
|
|
|
|
$pdo->prepare(
|
|
'UPDATE participants
|
|
SET display_name = ?, email = NULL, email_norm = NULL, paypal_name = NULL, active = 0, user_id = NULL
|
|
WHERE id = ? AND tenant_id = ?'
|
|
)->execute([$placeholderName, $participantId, $tenantId]);
|
|
|
|
$pdo->commit();
|
|
|
|
return true;
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO ledger_entries
|
|
(tenant_id, participant_id, type, amount_cents, marks_count,
|
|
unit_price_cents, booked_at, source, note, legacy_table, legacy_id)
|
|
SELECT
|
|
p.tenant_id,
|
|
p.id,
|
|
'payment',
|
|
CAST(ROUND(e.Betrag * 100) AS SIGNED),
|
|
NULL,
|
|
NULL,
|
|
e.Datum,
|
|
'legacy_payment',
|
|
NULL,
|
|
'kl_Einzahlungen',
|
|
e.EinzahlungsID
|
|
FROM kl_Einzahlungen e
|
|
JOIN participants p
|
|
ON p.tenant_id = ?
|
|
AND p.legacy_mitarbeiter_id = e.MitarbeiterID
|
|
WHERE e.EinzahlungsID = ?
|
|
ON DUPLICATE KEY UPDATE
|
|
participant_id = VALUES(participant_id),
|
|
type = VALUES(type),
|
|
amount_cents = VALUES(amount_cents),
|
|
marks_count = VALUES(marks_count),
|
|
unit_price_cents = VALUES(unit_price_cents),
|
|
booked_at = VALUES(booked_at),
|
|
source = VALUES(source),
|
|
note = VALUES(note)"
|
|
);
|
|
$stmt->execute([$tenantId, $legacyPaymentId]);
|
|
|
|
$check = $pdo->prepare(
|
|
"SELECT COUNT(*)
|
|
FROM ledger_entries
|
|
WHERE tenant_id = ?
|
|
AND legacy_table = 'kl_Einzahlungen'
|
|
AND legacy_id = ?"
|
|
);
|
|
$check->execute([$tenantId, $legacyPaymentId]);
|
|
if ((int)$check->fetchColumn() !== 1) {
|
|
throw new RuntimeException('Die Legacy-Einzahlung konnte nicht ins Ledger gespiegelt werden.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Marks the ledger entry mirrored from a legacy row as voided instead of
|
|
* deleting it, so corrections stay auditable. Idempotent: voiding an
|
|
* already-voided or missing entry is a no-op and returns false.
|
|
*/
|
|
function ledger_void_entry_by_legacy_id(PDO $pdo, int $tenantId, string $legacyTable, int $legacyId): bool
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"UPDATE ledger_entries
|
|
SET voided_at = NOW()
|
|
WHERE tenant_id = ?
|
|
AND legacy_table = ?
|
|
AND legacy_id = ?
|
|
AND voided_at IS NULL"
|
|
);
|
|
$stmt->execute([$tenantId, $legacyTable, $legacyId]);
|
|
|
|
return $stmt->rowCount() > 0;
|
|
}
|
|
|
|
/**
|
|
* Records a consumption entry straight into the ledger, without any legacy
|
|
* kl_Kaffeeverbrauch row. Used for tenants that have no legacy shadow table
|
|
* (every tenant except the migrated default tenant).
|
|
*/
|
|
function ledger_record_consumption(PDO $pdo, int $tenantId, int $participantId, int $marks, int $unitPriceCents, string $source, ?int $createdByUserId = null): int
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO ledger_entries
|
|
(tenant_id, participant_id, type, amount_cents, marks_count, unit_price_cents, booked_at, source, created_by_user_id)
|
|
SELECT ?, id, 'consumption', ?, ?, ?, NOW(), ?, ?
|
|
FROM participants
|
|
WHERE id = ? AND tenant_id = ?"
|
|
);
|
|
$stmt->execute([$tenantId, -($marks * $unitPriceCents), $marks, $unitPriceCents, $source, $createdByUserId, $participantId, $tenantId]);
|
|
|
|
if ($stmt->rowCount() !== 1) {
|
|
throw new RuntimeException('Der Strich-Eintrag konnte nicht gespeichert werden.');
|
|
}
|
|
|
|
return (int)$pdo->lastInsertId();
|
|
}
|
|
|
|
/**
|
|
* Records a payment entry straight into the ledger, without any legacy
|
|
* kl_Einzahlungen row. Used for tenants that have no legacy shadow table.
|
|
*/
|
|
function ledger_record_payment(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $source, ?int $createdByUserId = null): int
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO ledger_entries
|
|
(tenant_id, participant_id, type, amount_cents, booked_at, source, created_by_user_id)
|
|
SELECT ?, id, 'payment', ?, NOW(), ?, ?
|
|
FROM participants
|
|
WHERE id = ? AND tenant_id = ?"
|
|
);
|
|
$stmt->execute([$tenantId, $amountCents, $source, $createdByUserId, $participantId, $tenantId]);
|
|
|
|
if ($stmt->rowCount() !== 1) {
|
|
throw new RuntimeException('Die Einzahlung konnte nicht gespeichert werden.');
|
|
}
|
|
|
|
return (int)$pdo->lastInsertId();
|
|
}
|
|
|
|
/**
|
|
* Ledger-native equivalent of the legacy "100-Tage-Liste" front/back split:
|
|
* active participants whose summed marks within the trailing window are at
|
|
* or above the ten-mark threshold ($atLeastTen), or below it. Used for
|
|
* tenants without a legacy kl_Kaffeeverbrauch history to filter against.
|
|
*
|
|
* @return list<array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
|
|
*/
|
|
function ledger_fetch_participants_by_window_marks(PDO $pdo, int $tenantId, int $windowDays, bool $atLeastTen): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT p.id
|
|
FROM participants p
|
|
LEFT JOIN ledger_entries le
|
|
ON le.tenant_id = p.tenant_id
|
|
AND le.participant_id = p.id
|
|
AND le.type = 'consumption'
|
|
AND le.voided_at IS NULL
|
|
AND le.booked_at >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
WHERE p.tenant_id = ?
|
|
AND p.active = 1
|
|
GROUP BY p.id
|
|
HAVING COALESCE(SUM(le.marks_count), 0) " . ($atLeastTen ? '>= 10' : '< 10')
|
|
);
|
|
$stmt->execute([$windowDays, $tenantId]);
|
|
$ids = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
|
|
|
if ($ids === []) {
|
|
return [];
|
|
}
|
|
|
|
return ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => $ids]);
|
|
}
|
|
|
|
/**
|
|
* Options:
|
|
* - participant_id: int
|
|
* - type: string
|
|
* - include_voided: bool
|
|
* - limit: int
|
|
*
|
|
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, legacy_table: ?string, legacy_id: ?int, voided_at: ?string}>
|
|
*/
|
|
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
|
|
{
|
|
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
|
|
$limit = max(1, min($limit, 500));
|
|
$where = ['le.tenant_id = ?'];
|
|
$params = [$tenantId];
|
|
|
|
if (empty($options['include_voided'])) {
|
|
$where[] = 'le.voided_at IS NULL';
|
|
}
|
|
|
|
if (!empty($options['participant_id'])) {
|
|
$where[] = 'le.participant_id = ?';
|
|
$params[] = (int)$options['participant_id'];
|
|
}
|
|
|
|
if (!empty($options['type'])) {
|
|
$where[] = 'le.type = ?';
|
|
$params[] = trim((string)$options['type']);
|
|
}
|
|
|
|
$sql = "
|
|
SELECT
|
|
le.id,
|
|
le.tenant_id,
|
|
le.participant_id,
|
|
p.display_name,
|
|
le.type,
|
|
le.amount_cents,
|
|
le.marks_count,
|
|
le.unit_price_cents,
|
|
le.booked_at,
|
|
le.source,
|
|
le.note,
|
|
le.legacy_table,
|
|
le.legacy_id,
|
|
le.voided_at
|
|
FROM ledger_entries le
|
|
JOIN participants p
|
|
ON p.tenant_id = le.tenant_id
|
|
AND p.id = le.participant_id
|
|
WHERE " . implode(' AND ', $where) . "
|
|
ORDER BY le.booked_at DESC, le.id DESC
|
|
LIMIT {$limit}";
|
|
|
|
$stmt = $pdo->prepare($sql);
|
|
$stmt->execute($params);
|
|
|
|
$entries = [];
|
|
while ($row = $stmt->fetch()) {
|
|
$entries[] = [
|
|
'id' => (int)$row['id'],
|
|
'tenant_id' => (int)$row['tenant_id'],
|
|
'participant_id' => (int)$row['participant_id'],
|
|
'display_name' => (string)$row['display_name'],
|
|
'type' => (string)$row['type'],
|
|
'amount_cents' => (int)$row['amount_cents'],
|
|
'marks_count' => $row['marks_count'] !== null ? (int)$row['marks_count'] : null,
|
|
'unit_price_cents' => $row['unit_price_cents'] !== null ? (int)$row['unit_price_cents'] : null,
|
|
'booked_at' => (string)$row['booked_at'],
|
|
'source' => (string)$row['source'],
|
|
'note' => $row['note'] !== null ? (string)$row['note'] : null,
|
|
'legacy_table' => $row['legacy_table'] !== null ? (string)$row['legacy_table'] : null,
|
|
'legacy_id' => $row['legacy_id'] !== null ? (int)$row['legacy_id'] : null,
|
|
'voided_at' => $row['voided_at'] !== null ? (string)$row['voided_at'] : null,
|
|
];
|
|
}
|
|
|
|
return $entries;
|
|
}
|