]*>||<\/?strong\s*>)/iu', $value, -1, PREG_SPLIT_DELIM_CAPTURE); if ($tokens === false) { return htmlspecialchars($value, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8'); } $result = ''; $anchorOpen = false; foreach ($tokens as $token) { if (preg_match('/^$/iu', $token)) { $result .= '
'; continue; } if (preg_match('/^$/iu', $token)) { $result .= ''; continue; } if (preg_match('/^<\/strong\s*>$/iu', $token)) { $result .= ''; continue; } if (preg_match('/^<\/a\s*>$/iu', $token)) { if ($anchorOpen) { $result .= ''; $anchorOpen = false; } else { $result .= htmlspecialchars($token, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8'); } continue; } if (preg_match('/^]*\bhref\s*=\s*(["\'])(.*?)\1[^>]*>$/isu', $token, $matches) && market_management_is_allowed_link_url($matches[2])) { if ($anchorOpen) { $result .= ''; } $href = htmlspecialchars($matches[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $result .= ''; $anchorOpen = true; continue; } $result .= htmlspecialchars($token, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8'); } if ($anchorOpen) { $result .= ''; } return $result; } function market_management_sanitize_multiline_fields(array &$record): void { foreach (MARKET_MANAGEMENT_MULTILINE_FIELDS as $field) { if (array_key_exists($field, $record) && is_string($record[$field])) { $record[$field] = market_management_sanitize_multiline_html($record[$field]); } } } function market_management_sanitize_occurrence_payload(array &$occurrence): void { market_management_sanitize_multiline_fields($occurrence); foreach ($occurrence['days'] ?? [] as &$day) { if (is_array($day)) { market_management_sanitize_multiline_fields($day); } } unset($day); foreach ($occurrence['cost_items'] ?? [] as &$costItem) { if (is_array($costItem)) { market_management_sanitize_multiline_fields($costItem); } } unset($costItem); } function market_management_list_organizers(PDO $pdo): array { return $pdo->query( "SELECT id, name, email, phone FROM market_organizer WHERE status = 'active' ORDER BY name ASC, id ASC" )->fetchAll() ?: []; } function market_management_get_organizer(PDO $pdo, int $organizerId): ?array { $stmt = $pdo->prepare( "SELECT id, name, email, phone FROM market_organizer WHERE id = :id AND status = 'active'" ); $stmt->execute([':id' => $organizerId]); $organizer = $stmt->fetch(); return is_array($organizer) ? $organizer : null; } function market_management_create_organizer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array { $name = trim($name); if ($name === '') { throw new InvalidArgumentException('Veranstaltername fehlt.'); } $stmt = $pdo->prepare( 'INSERT INTO market_organizer (name, email, phone) VALUES (:name, :email, :phone) RETURNING id, name, email, phone' ); $stmt->execute([':name' => $name, ':email' => $email ?: null, ':phone' => $phone ?: null]); return (array) $stmt->fetch(); } function market_management_list_municipalities(PDO $pdo): array { return $pdo->query( "SELECT id, name, canton_code, bfs_municipality_number, data_version, source FROM market_municipality WHERE is_active = TRUE ORDER BY name ASC, id ASC" )->fetchAll() ?: []; } function market_management_create_municipality(PDO $pdo, string $name): array { $name = trim($name); if ($name === '') { throw new InvalidArgumentException('Gemeindename fehlt.'); } $stmt = $pdo->prepare( "SELECT 1 FROM market_municipality WHERE is_active = TRUE AND LOWER(TRIM(name)) = LOWER(TRIM(:name))" ); $stmt->execute([':name' => $name]); if ($stmt->fetchColumn() !== false) { throw new InvalidArgumentException('Diese Gemeinde ist bereits vorhanden.'); } $stmt = $pdo->prepare( "INSERT INTO market_municipality (name, source) VALUES (:name, 'local') RETURNING id, name, canton_code, bfs_municipality_number, data_version, source" ); $stmt->execute([':name' => $name]); return (array) $stmt->fetch(); } function market_management_list_cost_types(PDO $pdo): array { return $pdo->query( 'SELECT id, code, name, is_system, is_active FROM market_cost_type WHERE is_active = TRUE ORDER BY is_system DESC, name ASC, id ASC' )->fetchAll() ?: []; } function market_management_get_cost_type(PDO $pdo, int $costTypeId): ?array { $stmt = $pdo->prepare( 'SELECT id, code, name, is_system, is_active FROM market_cost_type WHERE id = :id AND is_active = TRUE' ); $stmt->execute([':id' => $costTypeId]); $type = $stmt->fetch(); return is_array($type) ? $type : null; } function market_management_get_cost_type_id(PDO $pdo, string $code): int { $stmt = $pdo->prepare('SELECT id FROM market_cost_type WHERE code = :code AND is_active = TRUE'); $stmt->execute([':code' => $code]); $id = $stmt->fetchColumn(); if ($id === false) { throw new RuntimeException('Standard-Kostenart fehlt.'); } return (int) $id; } function market_management_create_cost_type(PDO $pdo, string $name, ?string $code = null): array { $name = trim($name); $code = trim((string) ($code ?? '')); if ($name === '') { throw new InvalidArgumentException('Kostenartenname fehlt.'); } if ($code === '') { $code = strtolower(trim((string) preg_replace('/[^a-z0-9]+/i', '_', $name), '_')); } if ($code === '') { throw new InvalidArgumentException('Code der Kostenart fehlt.'); } $stmt = $pdo->prepare( 'INSERT INTO market_cost_type (code, name) VALUES (:code, :name) RETURNING id, code, name, is_system, is_active' ); $stmt->execute([':code' => $code, ':name' => $name]); return (array) $stmt->fetch(); } function market_management_require_positive_id(mixed $value, string $field): int { if (!is_int($value) && !ctype_digit((string) $value)) { throw new InvalidArgumentException($field . ' muss eine positive Ganzzahl sein.'); } $id = (int) $value; if ($id <= 0) { throw new InvalidArgumentException($field . ' muss eine positive Ganzzahl sein.'); } return $id; } function market_management_optional_id(array $input, string $field): ?int { if (!array_key_exists($field, $input) || $input[$field] === null || trim((string) $input[$field]) === '') { return null; } return market_management_require_positive_id($input[$field], $field); } function market_management_optional_string(array $input, string $field): ?string { if (!array_key_exists($field, $input) || $input[$field] === null) { return null; } $value = trim((string) $input[$field]); return $value === '' ? null : $value; } function market_management_optional_bool(array $input, string $field): ?bool { if (!array_key_exists($field, $input) || $input[$field] === null) { return null; } if (is_bool($input[$field])) { return $input[$field]; } if ($input[$field] === 'true' || $input[$field] === '1' || $input[$field] === 1) { return true; } if ($input[$field] === 'false' || $input[$field] === '0' || $input[$field] === 0) { return false; } throw new InvalidArgumentException($field . ' muss boolean sein.'); } function market_management_optional_number(array $input, string $field): ?string { if (!array_key_exists($field, $input) || $input[$field] === null || $input[$field] === '') { return null; } $value = parse_number($input[$field]); if ($value === null || $value < 0) { throw new InvalidArgumentException($field . ' muss eine nichtnegative Zahl sein.'); } return number_format($value, 2, '.', ''); } function market_management_optional_integer(array $input, string $field): ?int { if (!array_key_exists($field, $input) || $input[$field] === null || $input[$field] === '') { return null; } $value = parse_number($input[$field]); if ($value === null || $value < 0 || floor($value) !== $value) { throw new InvalidArgumentException($field . ' muss eine nichtnegative Ganzzahl sein.'); } return (int) $value; } function market_management_optional_url(array $input, string $field): ?string { $value = market_management_optional_string($input, $field); if ($value === null) { return null; } if (filter_var($value, FILTER_VALIDATE_URL) === false || !preg_match('/^https?:\/\//i', $value)) { throw new InvalidArgumentException($field . ' muss eine gültige HTTP(S)-URL sein.'); } return $value; } function market_management_category_exists(PDO $pdo, int $categoryId): bool { $stmt = $pdo->prepare('SELECT 1 FROM market_category WHERE id = :id AND is_active = TRUE'); $stmt->execute([':id' => $categoryId]); return $stmt->fetchColumn() !== false; } function market_management_auto_complete_occurrences(PDO $pdo, ?DateTimeImmutable $asOfDate = null): array { $asOfDate ??= new DateTimeImmutable('today', new DateTimeZone('Europe/Zurich')); $processName = 'market_management.occurrence_status_completion'; $scope = ['as_of_date' => $asOfDate->format('Y-m-d')]; $runStmt = $pdo->prepare( "INSERT INTO process_runs (process_name, status, scope_json, requested_by) VALUES (:process_name, 'running', :scope_json::jsonb, 'cron') RETURNING id" ); $runStmt->execute([ ':process_name' => $processName, ':scope_json' => json_encode($scope, JSON_THROW_ON_ERROR), ]); $runId = (int) $runStmt->fetchColumn(); try { $pdo->beginTransaction(); $stmt = $pdo->prepare( "WITH eligible AS ( SELECT o.id FROM market_occurrence o INNER JOIN market_occurrence_day d ON d.occurrence_id = o.id WHERE o.archived_at IS NULL AND o.status <> 'cancelled' AND o.status <> 'completed' GROUP BY o.id HAVING MAX(d.date) < :as_of_date::DATE ) UPDATE market_occurrence o SET status = 'completed', updated_at = NOW() FROM eligible e WHERE o.id = e.id RETURNING o.id" ); $stmt->execute([':as_of_date' => $asOfDate->format('Y-m-d')]); $completedIds = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN)); $pdo->commit(); $result = [ 'status' => 'done', 'as_of_date' => $asOfDate->format('Y-m-d'), 'completed_count' => count($completedIds), 'completed_occurrence_ids' => $completedIds, 'technical_run_id' => $runId, ]; $finishStmt = $pdo->prepare( 'UPDATE process_runs SET status = :status, result_json = :result_json::jsonb, finished_at = NOW() WHERE id = :id' ); $finishStmt->execute([ ':status' => 'done', ':result_json' => json_encode($result, JSON_THROW_ON_ERROR), ':id' => $runId, ]); return $result; } catch (Throwable $error) { if ($pdo->inTransaction()) { $pdo->rollBack(); } $result = [ 'status' => 'failed', 'as_of_date' => $asOfDate->format('Y-m-d'), 'completed_count' => 0, 'completed_occurrence_ids' => [], 'technical_run_id' => $runId, ]; $finishStmt = $pdo->prepare( 'UPDATE process_runs SET status = :status, result_json = :result_json::jsonb, finished_at = NOW(), error_code = :error_code, error_message = :error_message WHERE id = :id' ); $finishStmt->execute([ ':status' => 'failed', ':result_json' => json_encode($result, JSON_THROW_ON_ERROR), ':error_code' => 'occurrence_status_completion_failed', ':error_message' => $error->getMessage(), ':id' => $runId, ]); throw $error; } } function market_management_create_market(PDO $pdo, array $input): array { $name = trim((string) ($input['name'] ?? '')); $categoryId = market_management_require_positive_id($input['category_id'] ?? null, 'category_id'); $organizerId = market_management_require_positive_id($input['organizer_id'] ?? null, 'organizer_id'); $websiteUrl = market_management_optional_url($input, 'website_url'); if ($name === '') { throw new InvalidArgumentException('name ist erforderlich.'); } if (!market_management_category_exists($pdo, $categoryId)) { throw new InvalidArgumentException('category_id verweist nicht auf eine aktive Kategorie.'); } if (market_management_get_organizer($pdo, $organizerId) === null) { throw new InvalidArgumentException('organizer_id verweist nicht auf einen aktiven Veranstalter.'); } $stmt = $pdo->prepare( 'INSERT INTO market_definition (name, category_id, organizer_id, website_url) VALUES (:name, :category_id, :organizer_id, :website_url) RETURNING id, name, category_id, organizer_id, website_url, archived_at, created_at, updated_at' ); $stmt->execute([':name' => $name, ':category_id' => $categoryId, ':organizer_id' => $organizerId, ':website_url' => $websiteUrl]); return (array) $stmt->fetch(); } function market_management_search_markets(PDO $pdo, array $input): array { $search = market_management_optional_string($input, 'search'); $includeArchived = (bool) ($input['include_archived'] ?? false); $categoryId = array_key_exists('category_id', $input) ? market_management_require_positive_id($input['category_id'], 'category_id') : null; $status = array_key_exists('status', $input) ? market_management_validate_status($input['status']) : null; $place = market_management_optional_string($input, 'place'); $municipalityId = array_key_exists('municipality_reference_id', $input) ? market_management_require_positive_id($input['municipality_reference_id'], 'municipality_reference_id') : null; $dateFrom = market_management_optional_string($input, 'date_from'); $dateTo = market_management_optional_string($input, 'date_to'); $limit = min(100, max(1, (int) ($input['limit'] ?? 50))); $offset = max(0, (int) ($input['offset'] ?? 0)); $sort = (string) ($input['sort'] ?? 'name'); $sortDirection = strtolower((string) ($input['sort_direction'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC'; $sortColumn = match ($sort) { 'earliest_date' => 'MIN(d.date)', 'updated_at' => 'm.updated_at', default => 'm.name', }; $where = []; $params = [':limit' => $limit, ':offset' => $offset]; $occurrenceArchivePredicate = $includeArchived ? 'TRUE' : 'o.archived_at IS NULL'; if (!$includeArchived) { $where[] = 'm.archived_at IS NULL'; } if ($search !== null) { $where[] = 'm.name ILIKE :search'; $params[':search'] = '%' . $search . '%'; } if ($categoryId !== null) { $where[] = 'm.category_id = :category_id'; $params[':category_id'] = $categoryId; } if ($status !== null) { $where[] = 'EXISTS (SELECT 1 FROM market_occurrence os WHERE os.market_definition_id = m.id AND os.status = :status AND ' . ($includeArchived ? 'TRUE' : 'os.archived_at IS NULL') . ')'; $params[':status'] = $status; } if ($place !== null) { $where[] = 'EXISTS (SELECT 1 FROM market_occurrence op WHERE op.market_definition_id = m.id AND op.place ILIKE :place AND ' . ($includeArchived ? 'TRUE' : 'op.archived_at IS NULL') . ')'; $params[':place'] = '%' . $place . '%'; } if ($municipalityId !== null) { $where[] = 'EXISTS (SELECT 1 FROM market_occurrence om WHERE om.market_definition_id = m.id AND om.municipality_reference_id = :municipality_id AND ' . ($includeArchived ? 'TRUE' : 'om.archived_at IS NULL') . ')'; $params[':municipality_id'] = $municipalityId; } if ($dateFrom !== null || $dateTo !== null) { $dateConditions = ['od.occurrence_id = odate.id']; if ($dateFrom !== null) { $dateConditions[] = 'od.date >= :date_from'; $params[':date_from'] = $dateFrom; } if ($dateTo !== null) { $dateConditions[] = 'od.date <= :date_to'; $params[':date_to'] = $dateTo; } $where[] = 'EXISTS (SELECT 1 FROM market_occurrence odate INNER JOIN market_occurrence_day od ON ' . implode(' AND ', $dateConditions) . ' WHERE odate.market_definition_id = m.id AND ' . ($includeArchived ? 'TRUE' : 'odate.archived_at IS NULL') . ')'; } $stmt = $pdo->prepare( 'SELECT m.id, m.name, m.category_id, m.organizer_id, m.website_url, c.code AS category_code, c.name AS category_name, p.name AS organizer_name, m.archived_at, m.created_at, m.updated_at, MIN(d.date) AS earliest_date, MAX(d.date) AS latest_date, COUNT(DISTINCT o.id) AS occurrence_count FROM market_definition m INNER JOIN market_category c ON c.id = m.category_id INNER JOIN market_organizer p ON p.id = m.organizer_id LEFT JOIN market_occurrence o ON o.market_definition_id = m.id AND ' . $occurrenceArchivePredicate . ' LEFT JOIN market_occurrence_day d ON d.occurrence_id = o.id WHERE ' . ($where === [] ? 'TRUE' : implode(' AND ', $where)) . ' GROUP BY m.id, m.category_id, m.organizer_id, c.code, c.name, p.name ORDER BY ' . $sortColumn . ' ' . $sortDirection . ', m.id ASC LIMIT :limit OFFSET :offset' ); foreach ($params as $key => $value) { $stmt->bindValue($key, $value, in_array($key, [':limit', ':offset', ':category_id', ':municipality_id'], true) ? PDO::PARAM_INT : PDO::PARAM_STR); } $stmt->execute(); return $stmt->fetchAll(); } function market_management_get_market(PDO $pdo, int $marketId, bool $includeArchived = false): ?array { $marketArchivePredicate = $includeArchived ? 'TRUE' : 'm.archived_at IS NULL'; $stmt = $pdo->prepare( 'SELECT m.id, m.name, m.category_id, m.organizer_id, m.website_url, c.code AS category_code, c.name AS category_name, p.name AS organizer_name, m.archived_at, m.created_at, m.updated_at FROM market_definition m INNER JOIN market_category c ON c.id = m.category_id INNER JOIN market_organizer p ON p.id = m.organizer_id WHERE m.id = :id AND ' . $marketArchivePredicate ); $stmt->execute([':id' => $marketId]); $market = $stmt->fetch(); if (!is_array($market)) { return null; } $occurrenceArchivePredicate = $includeArchived ? 'TRUE' : 'archived_at IS NULL'; $occurrences = $pdo->prepare( 'SELECT * FROM market_occurrence WHERE market_definition_id = :market_id AND ' . $occurrenceArchivePredicate . ' ORDER BY id ASC' ); $occurrences->execute([':market_id' => $marketId]); $market['occurrences'] = $occurrences->fetchAll(); foreach ($market['occurrences'] as &$occurrence) { $occurrenceId = (int) $occurrence['id']; $organizer = $pdo->prepare('SELECT * FROM market_occurrence_organizer WHERE occurrence_id = :occurrence_id'); $organizer->execute([':occurrence_id' => $occurrenceId]); $occurrence['organizer'] = $organizer->fetch() ?: null; $days = $pdo->prepare('SELECT * FROM market_occurrence_day WHERE occurrence_id = :occurrence_id ORDER BY date ASC, id ASC'); $days->execute([':occurrence_id' => $occurrenceId]); $occurrence['days'] = $days->fetchAll(); $costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC'); $costs->execute([':occurrence_id' => $occurrenceId]); $occurrence['cost_items'] = $costs->fetchAll(); market_management_sanitize_occurrence_payload($occurrence); } unset($occurrence); return $market; } function market_management_list_active_market_definitions(PDO $pdo): array { $stmt = $pdo->query( 'SELECT m.id, m.name, m.category_id, m.organizer_id, c.name AS category_name FROM market_definition m INNER JOIN market_category c ON c.id = m.category_id WHERE m.archived_at IS NULL ORDER BY m.name ASC, m.id ASC' ); return $stmt->fetchAll() ?: []; } function market_management_search_occurrences(PDO $pdo, array $input): array { $where = []; $params = [':limit' => min(100, max(1, (int) ($input['limit'] ?? 50))), ':offset' => max(0, (int) ($input['offset'] ?? 0))]; $includeArchived = (bool) ($input['include_archived'] ?? false); $search = market_management_optional_string($input, 'search'); $sort = (string) ($input['sort'] ?? 'earliest_date'); $sortDirection = strtolower((string) ($input['sort_direction'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC'; $sortColumn = match ($sort) { 'market_name' => 'm.name', 'place' => 'o.place', 'registration_deadline' => 'o.registration_deadline', 'status' => 'o.status', default => 'MIN(d.date)', }; if (!$includeArchived) { $where[] = 'o.archived_at IS NULL'; } if ($search !== null) { $where[] = '(m.name ILIKE :search_name OR o.place ILIKE :search_place OR o.status ILIKE :search_status OR CASE o.status WHEN \'open\' THEN \'Offen\' WHEN \'waitlist\' THEN \'Warteliste\' WHEN \'registered\' THEN \'Angemeldet\' WHEN \'confirmation_pending\' THEN \'Bestätigung ausstehend\' WHEN \'confirmed\' THEN \'Bestätigt\' WHEN \'cancelled\' THEN \'Abgesagt\' WHEN \'completed\' THEN \'Abgeschlossen\' ELSE o.status END ILIKE :search_status_label OR EXISTS (SELECT 1 FROM market_occurrence_day sd WHERE sd.occurrence_id = o.id AND TO_CHAR(sd.date, \'DD.MM.YYYY\') ILIKE :search_date))'; $searchValue = '%' . $search . '%'; $params[':search_name'] = $searchValue; $params[':search_place'] = $searchValue; $params[':search_status'] = $searchValue; $params[':search_status_label'] = $searchValue; $params[':search_date'] = $searchValue; } if (array_key_exists('market_definition_id', $input)) { $where[] = 'o.market_definition_id = :market_definition_id'; $params[':market_definition_id'] = market_management_require_positive_id($input['market_definition_id'], 'market_definition_id'); } if (array_key_exists('status', $input)) { $where[] = 'o.status = :status'; $params[':status'] = market_management_validate_status($input['status']); } foreach (['place' => 'o.place', 'address' => 'o.address'] as $key => $column) { $value = market_management_optional_string($input, $key); if ($value !== null) { $where[] = $column . ' ILIKE :' . $key; $params[':' . $key] = '%' . $value . '%'; } } if (array_key_exists('municipality_reference_id', $input)) { $where[] = 'o.municipality_reference_id = :municipality_reference_id'; $params[':municipality_reference_id'] = market_management_require_positive_id($input['municipality_reference_id'], 'municipality_reference_id'); } foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) { $value = market_management_optional_string($input, $key); if ($value !== null) { $where[] = 'EXISTS (SELECT 1 FROM market_occurrence_day fd WHERE fd.occurrence_id = o.id AND fd.date ' . $operator . ' :' . $key . ')'; $params[':' . $key] = $value; } } $stmt = $pdo->prepare( 'SELECT o.*, m.name AS market_name, m.category_id, c.code AS category_code, c.name AS category_name, MIN(d.date) AS earliest_date, MAX(d.date) AS latest_date, COUNT(d.id) AS day_count, COALESCE(( SELECT SUM(CASE WHEN line.line_status = \'cancelled\' THEN 0 ELSE GREATEST(line.qty - line.qty_cancelled, 0) END) FROM sales_order sale INNER JOIN sales_order_line line ON line.sales_order_id = sale.id WHERE sale.market_occurrence_id = o.id AND sale.market_occurrence_day_id IS NOT NULL AND sale.order_source = \'direct\' AND sale.order_status <> \'cancelled\' ), 0) AS sold_item_count, COALESCE(( SELECT SUM(CASE WHEN line.line_status = \'cancelled\' OR line.line_total IS NULL THEN 0 ELSE line.line_total * GREATEST(line.qty - line.qty_cancelled, 0) / NULLIF(line.qty, 0) END) FROM sales_order sale INNER JOIN sales_order_line line ON line.sales_order_id = sale.id WHERE sale.market_occurrence_id = o.id AND sale.market_occurrence_day_id IS NOT NULL AND sale.order_source = \'direct\' AND sale.order_status <> \'cancelled\' ), 0) AS sold_gross_revenue FROM market_occurrence o INNER JOIN market_definition m ON m.id = o.market_definition_id INNER JOIN market_category c ON c.id = m.category_id LEFT JOIN market_occurrence_day d ON d.occurrence_id = o.id WHERE ' . ($where === [] ? 'TRUE' : implode(' AND ', $where)) . ' GROUP BY o.id, m.name, m.category_id, c.code, c.name ORDER BY ' . $sortColumn . ' ' . $sortDirection . ' NULLS LAST, o.id ASC LIMIT :limit OFFSET :offset' ); foreach ($params as $key => $value) { $stmt->bindValue($key, $value, in_array($key, [':limit', ':offset', ':market_definition_id', ':municipality_reference_id'], true) ? PDO::PARAM_INT : PDO::PARAM_STR); } $stmt->execute(); return $stmt->fetchAll(); } function market_management_list_active_occurrence_options(PDO $pdo): array { $stmt = $pdo->query( 'SELECT o.id AS occurrence_id, o.market_definition_id, m.name AS market_name, o.status, d.id AS day_id, d.date FROM market_occurrence o INNER JOIN market_definition m ON m.id = o.market_definition_id INNER JOIN market_occurrence_day d ON d.occurrence_id = o.id WHERE m.archived_at IS NULL AND o.archived_at IS NULL ORDER BY m.name ASC, o.id ASC, d.date ASC, d.id ASC' ); return $stmt->fetchAll(); } function market_management_list_reference_data(PDO $pdo, array $input): array { $result = ['categories' => market_management_list_categories($pdo)]; $municipalities = market_management_list_municipalities($pdo); $result['municipalities'] = $municipalities; $result['cantons'] = array_values(array_map( static fn (array $row): array => ['code' => (string) $row['canton_code']], array_values(array_reduce($municipalities, static function (array $carry, array $row): array { $carry[(string) $row['canton_code']] = $row; return $carry; }, [])) )); return $result; } function market_management_update_market(PDO $pdo, int $marketId, array $input): array { $sets = []; $params = [':id' => $marketId]; if (array_key_exists('name', $input)) { $name = trim((string) $input['name']); if ($name === '') { throw new InvalidArgumentException('name darf nicht leer sein.'); } $sets[] = 'name = :name'; $params[':name'] = $name; } if (array_key_exists('category_id', $input)) { $categoryId = market_management_require_positive_id($input['category_id'], 'category_id'); if (!market_management_category_exists($pdo, $categoryId)) { throw new InvalidArgumentException('category_id verweist nicht auf eine aktive Kategorie.'); } $sets[] = 'category_id = :category_id'; $params[':category_id'] = $categoryId; } if (array_key_exists('organizer_id', $input)) { $organizerId = market_management_require_positive_id($input['organizer_id'], 'organizer_id'); if (market_management_get_organizer($pdo, $organizerId) === null) { throw new InvalidArgumentException('organizer_id verweist nicht auf einen aktiven Veranstalter.'); } $sets[] = 'organizer_id = :organizer_id'; $params[':organizer_id'] = $organizerId; } if (array_key_exists('website_url', $input)) { $sets[] = 'website_url = :website_url'; $params[':website_url'] = market_management_optional_url($input, 'website_url'); } if ($sets === []) { throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); } $sets[] = 'updated_at = NOW()'; $stmt = $pdo->prepare( 'UPDATE market_definition SET ' . implode(', ', $sets) . ' WHERE id = :id RETURNING id, name, category_id, organizer_id, website_url, archived_at, created_at, updated_at' ); $stmt->execute($params); $market = $stmt->fetch(); if (!is_array($market)) { throw new RuntimeException('Markt nicht gefunden.'); } return $market; } function market_management_set_market_archive(PDO $pdo, int $marketId, bool $archive): array { $stmt = $pdo->prepare( 'UPDATE market_definition SET archived_at = ' . ($archive ? 'COALESCE(archived_at, NOW())' : 'NULL') . ', updated_at = NOW() WHERE id = :id RETURNING id, name, category_id, organizer_id, website_url, archived_at, created_at, updated_at' ); $stmt->execute([':id' => $marketId]); $market = $stmt->fetch(); if (!is_array($market)) { throw new RuntimeException('Markt nicht gefunden.'); } return $market; } function market_management_list_categories(PDO $pdo): array { return $pdo->query( 'SELECT id, code, name FROM market_category WHERE is_active = TRUE ORDER BY name ASC, id ASC' )->fetchAll(); } function market_management_validate_status(mixed $status): string { $value = trim((string) $status); if (!in_array($value, MARKET_MANAGEMENT_STATUSES, true)) { throw new InvalidArgumentException('Ungültiger Marktdurchführungsstatus.'); } return $value; } function market_management_write_fields(array $input, array $definitions): array { $fields = []; $params = []; foreach ($definitions as $field => $type) { if (!array_key_exists($field, $input)) { continue; } $value = match ($type) { 'string' => market_management_optional_string($input, $field), 'bool' => market_management_optional_bool($input, $field), 'number' => market_management_optional_number($input, $field), 'integer' => market_management_optional_integer($input, $field), 'id' => market_management_require_positive_id($input[$field], $field), 'nullable_id' => market_management_optional_id($input, $field), default => throw new LogicException('Unbekannter Feldtyp.'), }; if ($type === 'string' && in_array($field, MARKET_MANAGEMENT_MULTILINE_FIELDS, true)) { $value = market_management_sanitize_multiline_html($value); } $fields[] = $field . ' = :' . $field; $params[':' . $field] = $value; } return [$fields, $params]; } function market_management_execute_typed(PDOStatement $stmt, array $params): void { foreach ($params as $name => $value) { $type = is_bool($value) ? PDO::PARAM_BOOL : (is_int($value) ? PDO::PARAM_INT : (is_null($value) ? PDO::PARAM_NULL : PDO::PARAM_STR)); $stmt->bindValue($name, $value, $type); } $stmt->execute(); } function market_management_create_occurrence(PDO $pdo, array $input): array { $newMunicipalityName = null; if (($input['municipality_reference_id'] ?? null) === '__new__') { $newMunicipalityName = trim((string) ($input['municipality_name'] ?? '')); if ($newMunicipalityName === '') { throw new InvalidArgumentException('Gemeindename fehlt.'); } unset($input['municipality_reference_id'], $input['municipality_name']); } $marketId = market_management_require_positive_id($input['market_definition_id'] ?? null, 'market_definition_id'); if (market_management_get_market($pdo, $marketId, true) === null) { throw new InvalidArgumentException('market_definition_id verweist nicht auf einen Markt.'); } $status = array_key_exists('status', $input) ? market_management_validate_status($input['status']) : 'open'; $days = $input['days'] ?? null; if (!is_array($days) || count($days) < 1) { throw new InvalidArgumentException('Mindestens ein Markttag ist erforderlich.'); } $definitions = [ 'place' => 'string', 'address' => 'string', 'municipality_reference_id' => 'id', 'notes' => 'string', 'registration_deadline' => 'string', 'request_or_registration_date' => 'string', 'confirmation_date' => 'string', 'payment_deadline' => 'string', 'stand_size' => 'string', 'table_available' => 'bool', 'table_rentable' => 'bool', 'own_table_required' => 'bool', 'shelter_available' => 'bool', 'power_available' => 'bool', 'water_available' => 'bool', 'lighting_available' => 'bool', 'other_infrastructure' => 'string', 'own_stand_requirements' => 'string', 'travel_address' => 'string', 'travel_distance_km' => 'number', 'travel_duration_minutes' => 'integer', 'parking_options' => 'string', 'delivery_access' => 'string', 'direct_unload_possible' => 'bool', 'storage_options' => 'string', 'travel_notes' => 'string', 'payment_status' => 'string', 'payment_date' => 'string', 'paid_amount' => 'number', 'payment_method_code' => 'string', 'cancellation_terms' => 'string', 'revenue_share_terms' => 'string', 'other_participation_conditions' => 'string', 'contract_folder_url' => 'string', ]; [$fields, $params] = market_management_write_fields($input, $definitions); if (isset($params[':payment_status']) && !in_array($params[':payment_status'], ['paid', 'unpaid'], true)) { throw new InvalidArgumentException('Ungültiger Zahlungsstatus.'); } array_unshift($fields, 'market_definition_id = :market_definition_id', 'status = :status'); $params[':market_definition_id'] = $marketId; $params[':status'] = $status; $columns = array_map(static fn (string $field): string => explode(' = ', $field, 2)[0], $fields); $values = array_map(static fn (string $field): string => explode(' = ', $field, 2)[1], $fields); $startedTransaction = !$pdo->inTransaction(); if ($startedTransaction) { $pdo->beginTransaction(); } try { if ($newMunicipalityName !== null) { $municipality = market_management_create_municipality($pdo, $newMunicipalityName); $fields[] = 'municipality_reference_id = :municipality_reference_id'; $params[':municipality_reference_id'] = (int) $municipality['id']; $columns = array_map(static fn (string $field): string => explode(' = ', $field, 2)[0], $fields); $values = array_map(static fn (string $field): string => explode(' = ', $field, 2)[1], $fields); } $stmt = $pdo->prepare( 'INSERT INTO market_occurrence (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ') RETURNING *' ); market_management_execute_typed($stmt, $params); $occurrence = (array) $stmt->fetch(); foreach ($days as $day) { if (!is_array($day)) { throw new InvalidArgumentException('Jeder Markttag muss ein Objekt sein.'); } $day['occurrence_id'] = (int) $occurrence['id']; market_management_create_day($pdo, $day); } market_management_set_occurrence_contact($pdo, [ 'occurrence_id' => (int) $occurrence['id'], 'contact_person_name' => $input['contact_person_name'] ?? null, 'contact_email' => $input['contact_email'] ?? null, 'contact_phone' => $input['contact_phone'] ?? null, ]); $grossCost = market_management_optional_number($input, 'gross_cost') ?? '0.00'; $taxStatus = trim((string) ($input['tax_status'] ?? 'taxable')); $taxRate = market_management_optional_number($input, 'tax_rate') ?? ($taxStatus === 'tax_free' ? '0.00' : '8.10'); market_management_create_cost_item($pdo, [ 'occurrence_id' => (int) $occurrence['id'], 'cost_type_id' => market_management_get_cost_type_id($pdo, 'stand_fee'), 'description' => market_management_optional_string($input, 'cost_items_text'), 'gross_amount' => $grossCost, 'tax_status' => $taxStatus, 'tax_rate' => $taxRate, ]); if ($startedTransaction) { $pdo->commit(); } } catch (Throwable $e) { if ($startedTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $e; } return market_management_get_occurrence($pdo, (int) $occurrence['id'], true) ?? $occurrence; } function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): ?array { $archivePredicate = $includeArchived ? 'TRUE' : 'archived_at IS NULL'; $stmt = $pdo->prepare( 'SELECT * FROM market_occurrence WHERE id = :id AND ' . $archivePredicate ); $stmt->execute([':id' => $occurrenceId]); $occurrence = $stmt->fetch(); if (!is_array($occurrence)) { return null; } $municipality = $pdo->prepare( 'SELECT id, name, canton_code, bfs_municipality_number, data_version, source FROM market_municipality WHERE id = :id AND is_active = TRUE' ); $municipality->execute([':id' => $occurrence['municipality_reference_id'] ?? null]); $municipality = $municipality->fetch(); $occurrence['municipality'] = is_array($municipality) ? $municipality : null; $occurrence['municipality_name'] = is_array($municipality) ? (string) $municipality['name'] : null; $days = $pdo->prepare( 'SELECT * FROM market_occurrence_day WHERE occurrence_id = :occurrence_id ORDER BY date ASC, id ASC' ); $days->execute([':occurrence_id' => $occurrenceId]); $occurrence['days'] = $days->fetchAll(); $salesByDayStmt = $pdo->prepare( 'WITH line_totals AS ( SELECT sales_order_id, SUM(CASE WHEN line_status = \'cancelled\' THEN 0 ELSE GREATEST(qty - qty_cancelled, 0) END) AS sold_item_count ,SUM(CASE WHEN line_status = \'cancelled\' OR line_total IS NULL THEN 0 ELSE line_total * GREATEST(qty - qty_cancelled, 0) / NULLIF(qty, 0) END) AS gross_revenue FROM sales_order_line GROUP BY sales_order_id ) SELECT so.market_occurrence_day_id AS day_id, COUNT(*) AS sales_count, COALESCE(SUM(COALESCE(lt.gross_revenue, 0)), 0) AS gross_revenue, COALESCE(SUM(COALESCE(lt.sold_item_count, 0)), 0) AS sold_item_count FROM sales_order so LEFT JOIN line_totals lt ON lt.sales_order_id = so.id WHERE so.market_occurrence_id = :occurrence_id AND so.market_occurrence_day_id IS NOT NULL AND so.order_source = \'direct\' AND so.order_status <> \'cancelled\' GROUP BY so.market_occurrence_day_id' ); $salesByDayStmt->execute([':occurrence_id' => $occurrenceId]); $salesByDay = []; foreach ($salesByDayStmt->fetchAll() as $salesRow) { $salesByDay[(int) $salesRow['day_id']] = $salesRow; } $occurrenceGrossRevenue = 0.0; $occurrenceSoldItemCount = 0.0; $occurrenceSalesCount = 0; foreach ($occurrence['days'] as &$day) { $salesRow = $salesByDay[(int) $day['id']] ?? null; if ($salesRow === null || (int) $salesRow['sales_count'] === 0) { $day['gross_revenue'] = null; $day['sold_item_count'] = null; continue; } $day['gross_revenue'] = (float) $salesRow['gross_revenue']; $day['sold_item_count'] = (float) $salesRow['sold_item_count']; $occurrenceGrossRevenue += (float) $salesRow['gross_revenue']; $occurrenceSoldItemCount += (float) $salesRow['sold_item_count']; $occurrenceSalesCount += (int) $salesRow['sales_count']; } unset($day); $occurrence['sales_summary'] = $occurrenceSalesCount === 0 ? [ 'sales_count' => 0, 'gross_revenue' => null, 'sold_item_count' => null, 'average_revenue_per_day' => null, 'average_revenue_per_item' => null, ] : [ 'sales_count' => $occurrenceSalesCount, 'gross_revenue' => $occurrenceGrossRevenue, 'sold_item_count' => $occurrenceSoldItemCount, 'average_revenue_per_day' => count($occurrence['days']) > 0 ? $occurrenceGrossRevenue / count($occurrence['days']) : null, 'average_revenue_per_item' => $occurrenceSoldItemCount > 0 ? $occurrenceGrossRevenue / $occurrenceSoldItemCount : null, ]; if (count($occurrence['days']) > 1) { $firstDay = $occurrence['days'][0]; foreach ($occurrence['days'] as $index => $day) { if ($index > 0 && (bool) ($day['inherit_from_first_day'] ?? false)) { foreach (MARKET_OCCURRENCE_DAY_INHERITED_FIELDS as $field) { $occurrence['days'][$index][$field] = $firstDay[$field] ?? null; } } } } $organizer = $pdo->prepare('SELECT * FROM market_occurrence_organizer WHERE occurrence_id = :occurrence_id'); $organizer->execute([':occurrence_id' => $occurrenceId]); $organizer = $organizer->fetch(); if (is_array($organizer)) { $market = market_management_get_market($pdo, (int) $occurrence['market_definition_id'], true); $organizer['organization'] = is_array($market) ? market_management_get_organizer($pdo, (int) $market['organizer_id']) : null; $organizer['contact_person'] = [ 'first_name' => (string) ($organizer['contact_person_name'] ?? ''), 'last_name' => '', 'email' => (string) ($organizer['contact_email'] ?? ''), 'phone' => (string) ($organizer['contact_phone'] ?? ''), ]; } $occurrence['organizer'] = is_array($organizer) ? $organizer : null; $occurrence['organizer_id'] = is_array($occurrence['organizer']) && is_array($occurrence['organizer']['organization'] ?? null) ? (int) $occurrence['organizer']['organization']['id'] : null; $occurrence['organization_contact_id'] = $occurrence['organizer_id']; $contactPersonName = (string) ($occurrence['organizer']['contact_person_name'] ?? ''); $occurrence['contact_person_id'] = $contactPersonName; $referenceData = market_management_list_reference_data($pdo, []); $occurrence['reference_options'] = [ 'categories' => market_management_list_categories($pdo), 'municipalities' => $referenceData['municipalities'] ?? [], 'market_organizers' => market_management_list_organizers($pdo), 'parties' => market_management_list_organizers($pdo), 'contacts' => $contactPersonName === '' ? [] : [[ 'id' => $contactPersonName, 'first_name' => $contactPersonName, 'last_name' => '', 'party_name' => '', ]], 'cost_types' => market_management_list_cost_types($pdo), ]; $costs = $pdo->prepare( 'SELECT item.*, type.code AS cost_type_code, type.name AS cost_type FROM market_occurrence_cost_item item INNER JOIN market_cost_type type ON type.id = item.cost_type_id WHERE item.occurrence_id = :occurrence_id ORDER BY item.id ASC' ); $costs->execute([':occurrence_id' => $occurrenceId]); $occurrence['cost_items'] = $costs->fetchAll(); $occurrence['cost_items_text'] = null; foreach ($occurrence['cost_items'] as $costItem) { $description = trim((string) ($costItem['description'] ?? '')); if ($description !== '') { $occurrence['cost_items_text'] = $description; break; } } $costSummary = $pdo->prepare( 'SELECT COALESCE(SUM(gross_amount), 0) AS gross_amount, COALESCE(SUM(net_amount), 0) AS net_amount, COALESCE(SUM(tax_amount), 0) AS tax_amount FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id' ); $costSummary->execute([':occurrence_id' => $occurrenceId]); $summary = $costSummary->fetch() ?: ['gross_amount' => 0, 'net_amount' => 0, 'tax_amount' => 0]; $occurrence['cost_summary'] = [ 'gross_amount' => (float) ($summary['gross_amount'] ?? 0), 'net_amount' => (float) ($summary['net_amount'] ?? 0), 'tax_amount' => (float) ($summary['tax_amount'] ?? 0), 'open_amount' => $occurrence['paid_amount'] === null ? null : round((float) $summary['gross_amount'] - (float) $occurrence['paid_amount'], 2), ]; return $occurrence; } function market_management_update_occurrence(PDO $pdo, int $occurrenceId, array $input): array { $newMunicipalityName = null; if (($input['municipality_reference_id'] ?? null) === '__new__') { $newMunicipalityName = trim((string) ($input['municipality_name'] ?? '')); if ($newMunicipalityName === '') { throw new InvalidArgumentException('Gemeindename fehlt.'); } unset($input['municipality_reference_id'], $input['municipality_name']); } $definitions = [ 'status' => 'string', 'place' => 'string', 'address' => 'string', 'municipality_reference_id' => 'nullable_id', 'notes' => 'string', 'registration_deadline' => 'string', 'request_or_registration_date' => 'string', 'confirmation_date' => 'string', 'payment_deadline' => 'string', 'stand_size' => 'string', 'table_available' => 'bool', 'table_rentable' => 'bool', 'own_table_required' => 'bool', 'shelter_available' => 'bool', 'power_available' => 'bool', 'water_available' => 'bool', 'lighting_available' => 'bool', 'other_infrastructure' => 'string', 'own_stand_requirements' => 'string', 'travel_address' => 'string', 'travel_distance_km' => 'number', 'travel_duration_minutes' => 'integer', 'parking_options' => 'string', 'delivery_access' => 'string', 'direct_unload_possible' => 'bool', 'storage_options' => 'string', 'travel_notes' => 'string', 'payment_status' => 'string', 'payment_date' => 'string', 'paid_amount' => 'number', 'payment_method_code' => 'string', 'cancellation_terms' => 'string', 'revenue_share_terms' => 'string', 'other_participation_conditions' => 'string', 'contract_folder_url' => 'string', ]; [$fields, $params] = market_management_write_fields($input, $definitions); if (array_key_exists('status', $input)) { $params[':status'] = market_management_validate_status($input['status']); } if (isset($params[':payment_status']) && !in_array($params[':payment_status'], ['paid', 'unpaid'], true)) { throw new InvalidArgumentException('Ungültiger Zahlungsstatus.'); } if ($fields === [] && $newMunicipalityName === null) { throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); } $startedTransaction = $newMunicipalityName !== null && !$pdo->inTransaction(); if ($startedTransaction) { $pdo->beginTransaction(); } try { if ($newMunicipalityName !== null) { $municipality = market_management_create_municipality($pdo, $newMunicipalityName); $fields[] = 'municipality_reference_id = :municipality_reference_id'; $params[':municipality_reference_id'] = (int) $municipality['id']; } $fields[] = 'updated_at = NOW()'; $params[':id'] = $occurrenceId; $stmt = $pdo->prepare( 'UPDATE market_occurrence SET ' . implode(', ', $fields) . ' WHERE id = :id RETURNING *' ); market_management_execute_typed($stmt, $params); $occurrence = $stmt->fetch(); if (!is_array($occurrence)) { throw new RuntimeException('Marktdurchführung nicht gefunden.'); } if ($startedTransaction) { $pdo->commit(); } } catch (Throwable $error) { if ($startedTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $error; } return $occurrence; } function market_management_validate_day_date(PDO $pdo, int $occurrenceId, string $date, ?int $dayId = null): string { $value = trim($date); $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value); if ($value === '' || $parsed === false || $parsed->format('Y-m-d') !== $value) { throw new InvalidArgumentException('date muss ein gültiges Datum im Format YYYY-MM-DD sein.'); } $previousDate = null; $nextDate = null; if ($dayId !== null) { $currentStmt = $pdo->prepare( 'SELECT occurrence_id, date FROM market_occurrence_day WHERE id = :id' ); $currentStmt->execute([':id' => $dayId]); $currentDay = $currentStmt->fetch(); if (!is_array($currentDay) || (int) $currentDay['occurrence_id'] !== $occurrenceId) { throw new RuntimeException('Markttag nicht gefunden.'); } $neighborParams = [':occurrence_id' => $occurrenceId, ':day_id' => $dayId, ':current_date' => $currentDay['date']]; $previousStmt = $pdo->prepare( 'SELECT MAX(date) FROM market_occurrence_day WHERE occurrence_id = :occurrence_id AND id <> :day_id AND date < :current_date' ); $previousStmt->execute($neighborParams); $previousDate = $previousStmt->fetchColumn() ?: null; $nextStmt = $pdo->prepare( 'SELECT MIN(date) FROM market_occurrence_day WHERE occurrence_id = :occurrence_id AND id <> :day_id AND date > :current_date' ); $nextStmt->execute($neighborParams); $nextDate = $nextStmt->fetchColumn() ?: null; } else { $latestStmt = $pdo->prepare( 'SELECT MAX(date) FROM market_occurrence_day WHERE occurrence_id = :occurrence_id' ); $latestStmt->execute([':occurrence_id' => $occurrenceId]); $previousDate = $latestStmt->fetchColumn() ?: null; } if ($previousDate !== null && $value <= (string) $previousDate) { throw new InvalidArgumentException('Das Datum muss nach dem vorherigen Markttag liegen.'); } if ($nextDate !== null && $value >= (string) $nextDate) { throw new InvalidArgumentException('Das Datum muss vor dem nachfolgenden Markttag liegen.'); } return $value; } function market_management_create_day(PDO $pdo, array $input): array { $occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id'); if (market_management_get_occurrence($pdo, $occurrenceId, true) === null) { throw new InvalidArgumentException('occurrence_id verweist nicht auf eine Marktdurchführung.'); } $date = market_management_validate_day_date($pdo, $occurrenceId, (string) ($input['date'] ?? '')); $definitions = [ 'stand_start_time' => 'string', 'stand_end_time' => 'string', 'setup_start_time' => 'string', 'setup_end_time' => 'string', 'delivery_start_time' => 'string', 'delivery_end_time' => 'string', 'observations' => 'string', ]; [$fields, $params] = market_management_write_fields($input, $definitions); array_unshift($fields, 'occurrence_id = :occurrence_id', 'date = :date'); $params[':occurrence_id'] = $occurrenceId; $params[':date'] = $date; $columns = array_map(static fn (string $field): string => explode(' = ', $field, 2)[0], $fields); $values = array_map(static fn (string $field): string => explode(' = ', $field, 2)[1], $fields); $stmt = $pdo->prepare( 'INSERT INTO market_occurrence_day (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ') RETURNING *' ); $stmt->execute($params); return (array) $stmt->fetch(); } function market_management_update_day(PDO $pdo, int $dayId, array $input): array { $definitions = [ 'inherit_from_first_day' => 'bool', 'date' => 'string', 'stand_start_time' => 'string', 'stand_end_time' => 'string', 'setup_start_time' => 'string', 'setup_end_time' => 'string', 'delivery_start_time' => 'string', 'delivery_end_time' => 'string', 'observations' => 'string', ]; [$fields, $params] = market_management_write_fields($input, $definitions); if ($fields === []) { throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); } $currentStmt = $pdo->prepare('SELECT occurrence_id, date FROM market_occurrence_day WHERE id = :id'); $currentStmt->execute([':id' => $dayId]); $currentDay = $currentStmt->fetch(); if (!is_array($currentDay)) { throw new RuntimeException('Markttag nicht gefunden.'); } $occurrenceId = (int) $currentDay['occurrence_id']; if (array_key_exists('date', $input)) { $params[':date'] = market_management_validate_day_date($pdo, $occurrenceId, (string) $input['date'], $dayId); } if (array_key_exists('inherit_from_first_day', $input) && $params[':inherit_from_first_day']) { $firstStmt = $pdo->prepare( 'SELECT id FROM market_occurrence_day WHERE occurrence_id = :occurrence_id ORDER BY date ASC, id ASC LIMIT 1' ); $firstStmt->execute([':occurrence_id' => $occurrenceId]); if ((int) $firstStmt->fetchColumn() === $dayId) { throw new InvalidArgumentException('Tag 1 kann nicht von sich selbst erben.'); } } if (array_key_exists('inherit_from_first_day', $input) && !$params[':inherit_from_first_day']) { $sourceStmt = $pdo->prepare( 'SELECT ' . implode(', ', MARKET_OCCURRENCE_DAY_INHERITED_FIELDS) . ' FROM market_occurrence_day WHERE occurrence_id = :occurrence_id AND id <> :day_id ORDER BY date ASC, id ASC LIMIT 1' ); $sourceStmt->execute([':occurrence_id' => (int) $occurrenceId, ':day_id' => $dayId]); $sourceDay = $sourceStmt->fetch(); if (!is_array($sourceDay)) { throw new InvalidArgumentException('Für die Übernahme muss Tag 1 vorhanden sein.'); } foreach (MARKET_OCCURRENCE_DAY_INHERITED_FIELDS as $field) { $fields[] = $field . ' = :' . $field; $params[':' . $field] = $sourceDay[$field] ?? null; } } $fields[] = 'updated_at = NOW()'; $params[':id'] = $dayId; $stmt = $pdo->prepare( 'UPDATE market_occurrence_day SET ' . implode(', ', $fields) . ' WHERE id = :id RETURNING *' ); $stmt->execute($params); $day = $stmt->fetch(); if (!is_array($day)) { throw new RuntimeException('Markttag nicht gefunden.'); } return $day; } function market_management_set_occurrence_archive(PDO $pdo, int $occurrenceId, bool $archive): array { $stmt = $pdo->prepare( 'UPDATE market_occurrence SET archived_at = ' . ($archive ? 'COALESCE(archived_at, NOW())' : 'NULL') . ', updated_at = NOW() WHERE id = :id RETURNING *' ); $stmt->execute([':id' => $occurrenceId]); $occurrence = $stmt->fetch(); if (!is_array($occurrence)) { throw new RuntimeException('Marktdurchführung nicht gefunden.'); } return $occurrence; } function market_management_delete_day(PDO $pdo, int $dayId): array { $dayStmt = $pdo->prepare( 'SELECT id, occurrence_id, date FROM market_occurrence_day WHERE id = :id' ); $dayStmt->execute([':id' => $dayId]); $day = $dayStmt->fetch(); if (!is_array($day)) { throw new RuntimeException('Markttag nicht gefunden.'); } $countStmt = $pdo->prepare( 'SELECT COUNT(*) FROM market_occurrence_day WHERE occurrence_id = :occurrence_id' ); $countStmt->execute([':occurrence_id' => (int) $day['occurrence_id']]); if ((int) $countStmt->fetchColumn() <= 1) { throw new InvalidArgumentException('Die Marktdurchführung muss mindestens einen Markttag behalten.'); } $firstDayStmt = $pdo->prepare( 'SELECT id FROM market_occurrence_day WHERE occurrence_id = :occurrence_id ORDER BY date ASC, id ASC LIMIT 1' ); $firstDayStmt->execute([':occurrence_id' => (int) $day['occurrence_id']]); if ((int) $firstDayStmt->fetchColumn() === $dayId) { throw new InvalidArgumentException('Der erste Markttag kann nicht gelöscht werden.'); } $salesStmt = $pdo->prepare( 'SELECT COUNT(*) FROM sales_order WHERE market_occurrence_day_id = :day_id' ); $salesStmt->execute([':day_id' => $dayId]); if ((int) $salesStmt->fetchColumn() > 0) { throw new InvalidArgumentException('Ein Markttag mit Verkäufen kann nicht gelöscht werden.'); } $stmt = $pdo->prepare('DELETE FROM market_occurrence_day WHERE id = :id RETURNING id, occurrence_id, date'); $stmt->execute([':id' => $dayId]); $day = $stmt->fetch(); if (!is_array($day)) { throw new RuntimeException('Markttag nicht gefunden.'); } return $day; } function market_management_set_occurrence_contact(PDO $pdo, array $input): array { $occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id'); $name = array_key_exists('contact_person_name', $input) ? market_management_optional_string($input, 'contact_person_name') : null; $email = array_key_exists('contact_email', $input) ? market_management_optional_string($input, 'contact_email') : null; $phone = array_key_exists('contact_phone', $input) ? market_management_optional_string($input, 'contact_phone') : null; $stmt = $pdo->prepare( 'INSERT INTO market_occurrence_organizer (occurrence_id, contact_person_name, contact_email, contact_phone) VALUES (:occurrence_id, :contact_person_name, :contact_email, :contact_phone) ON CONFLICT (occurrence_id) DO UPDATE SET contact_person_name = EXCLUDED.contact_person_name, contact_email = EXCLUDED.contact_email, contact_phone = EXCLUDED.contact_phone, updated_at = NOW() RETURNING *' ); $stmt->execute([ ':occurrence_id' => $occurrenceId, ':contact_person_name' => $name, ':contact_email' => $email, ':contact_phone' => $phone, ]); return (array) $stmt->fetch(); } function market_management_create_cost_item(PDO $pdo, array $input): array { $occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id'); $costTypeId = market_management_require_positive_id($input['cost_type_id'] ?? null, 'cost_type_id'); $grossAmount = market_management_optional_number($input, 'gross_amount'); $taxStatus = trim((string) ($input['tax_status'] ?? '')); $taxRate = market_management_optional_number($input, 'tax_rate') ?? '0.00'; if ($grossAmount === null || !in_array($taxStatus, ['tax_free', 'taxable'], true)) { throw new InvalidArgumentException('cost_type_id, gross_amount und tax_status sind erforderlich.'); } if (($taxStatus === 'tax_free' && $taxRate !== '0.00') || ($taxStatus === 'taxable' && (float) $taxRate <= 0)) { throw new InvalidArgumentException('tax_rate passt nicht zu tax_status.'); } $stmt = $pdo->prepare( 'INSERT INTO market_occurrence_cost_item (occurrence_id, cost_type_id, description, gross_amount, tax_status, tax_rate) VALUES (:occurrence_id, :cost_type_id, :description, :gross_amount, :tax_status, :tax_rate) RETURNING *' ); if (market_management_get_cost_type($pdo, $costTypeId) === null) { throw new InvalidArgumentException('Kostenart nicht gefunden oder inaktiv.'); } $stmt->execute([ ':occurrence_id' => $occurrenceId, ':cost_type_id' => $costTypeId, ':description' => market_management_sanitize_multiline_html(market_management_optional_string($input, 'description')), ':gross_amount' => $grossAmount, ':tax_status' => $taxStatus, ':tax_rate' => $taxRate, ]); $item = (array) $stmt->fetch(); $item['cost_type'] = market_management_get_cost_type($pdo, $costTypeId)['name']; return $item; } function market_management_delete_cost_item(PDO $pdo, int $costItemId): array { $stmt = $pdo->prepare('SELECT id, occurrence_id FROM market_occurrence_cost_item WHERE id = :id'); $stmt->execute([':id' => $costItemId]); $item = $stmt->fetch(); if (!is_array($item)) { throw new RuntimeException('Kostenposition nicht gefunden.'); } $first = $pdo->prepare('SELECT MIN(id) FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id'); $first->execute([':occurrence_id' => $item['occurrence_id']]); if ((int) $first->fetchColumn() === $costItemId) { throw new InvalidArgumentException('Die erste Standkostenposition darf nicht gelöscht werden.'); } $stmt = $pdo->prepare('DELETE FROM market_occurrence_cost_item WHERE id = :id RETURNING id, occurrence_id'); $stmt->execute([':id' => $costItemId]); $item = $stmt->fetch(); if (!is_array($item)) { throw new RuntimeException('Kostenposition nicht gefunden.'); } return $item; } function market_management_update_cost_item(PDO $pdo, int $costItemId, array $input): array { $sets = []; $params = [':id' => $costItemId]; if (array_key_exists('cost_type_id', $input)) { $costTypeId = market_management_require_positive_id($input['cost_type_id'], 'cost_type_id'); if (market_management_get_cost_type($pdo, $costTypeId) === null) { throw new InvalidArgumentException('Kostenart nicht gefunden oder inaktiv.'); } $sets[] = 'cost_type_id = :cost_type_id'; $params[':cost_type_id'] = $costTypeId; } foreach (['description' => 'string', 'gross_amount' => 'number', 'tax_status' => 'string', 'tax_rate' => 'number'] as $field => $type) { if (!array_key_exists($field, $input)) { continue; } $sets[] = $field . ' = :' . $field; $params[':' . $field] = $type === 'number' ? market_management_optional_number($input, $field) : ($field === 'description' ? market_management_sanitize_multiline_html(market_management_optional_string($input, $field)) : market_management_optional_string($input, $field)); } if (isset($params[':tax_status']) && !in_array($params[':tax_status'], ['tax_free', 'taxable'], true)) { throw new InvalidArgumentException('Ungültiger Steuerstatus.'); } if ($sets === []) { throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); } $sets[] = 'updated_at = NOW()'; $stmt = $pdo->prepare( 'UPDATE market_occurrence_cost_item SET ' . implode(', ', $sets) . ' WHERE id = :id RETURNING *' ); $stmt->execute($params); $item = $stmt->fetch(); if (!is_array($item)) { throw new RuntimeException('Kostenposition nicht gefunden.'); } $item['cost_type'] = market_management_get_cost_type($pdo, (int) $item['cost_type_id'])['name']; return $item; } function market_management_update_occurrence_simple_costs(PDO $pdo, int $occurrenceId, array $input): array { $itemStmt = $pdo->prepare( 'SELECT id, gross_amount, tax_status, tax_rate, description FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC LIMIT 1' ); $itemStmt->execute([':occurrence_id' => $occurrenceId]); $existingItem = $itemStmt->fetch(); if (!is_array($existingItem)) { $grossAmount = market_management_optional_number($input, 'gross_cost') ?? '0.00'; $taxStatus = trim((string) ($input['tax_status'] ?? 'taxable')); $taxRate = market_management_optional_number($input, 'tax_rate') ?? ($taxStatus === 'tax_free' ? '0.00' : '8.10'); $item = market_management_create_cost_item($pdo, [ 'occurrence_id' => $occurrenceId, 'cost_type_id' => market_management_get_cost_type_id($pdo, 'stand_fee'), 'description' => array_key_exists('cost_items_text', $input) ? market_management_sanitize_multiline_html(market_management_optional_string($input, 'cost_items_text')) : null, 'gross_amount' => $grossAmount, 'tax_status' => $taxStatus, 'tax_rate' => $taxRate, ]); } else { $costFields = []; if (array_key_exists('gross_cost', $input)) { $costFields['gross_amount'] = market_management_optional_number($input, 'gross_cost'); } if (array_key_exists('cost_items_text', $input)) { $costFields['description'] = market_management_sanitize_multiline_html(market_management_optional_string($input, 'cost_items_text')); } if (array_key_exists('tax_status', $input)) { $costFields['tax_status'] = trim((string) $input['tax_status']); if ($costFields['tax_status'] === 'tax_free' && !array_key_exists('tax_rate', $input)) { $costFields['tax_rate'] = '0.00'; } } if (array_key_exists('tax_rate', $input)) { $costFields['tax_rate'] = market_management_optional_number($input, 'tax_rate'); } $item = $costFields === [] ? (array) $existingItem : market_management_update_cost_item($pdo, (int) $existingItem['id'], $costFields); } return $item; }