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'); $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.'); } $stmt = $pdo->prepare( 'INSERT INTO market_definition (name, category_id, website_url) VALUES (:name, :category_id, :website_url) RETURNING id, name, category_id, website_url, archived_at, created_at, updated_at' ); $stmt->execute([':name' => $name, ':category_id' => $categoryId, ':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.website_url, c.code AS category_code, c.name AS category_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 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, c.code, c.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.website_url, c.code AS category_code, c.name AS category_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 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(); } 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, 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', '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 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 = $pdo->query( 'SELECT id, bfs_municipality_number, name, canton_code, data_version FROM market_municipality_reference WHERE is_active = TRUE ORDER BY name ASC, id ASC' )->fetchAll(); $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('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, 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, 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 Durchfü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.'), }; $fields[] = $field . ' = :' . $field; $params[':' . $field] = $value; } return [$fields, $params]; } function market_management_create_occurrence(PDO $pdo, array $input): array { $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 Durchführungstag 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 { $stmt = $pdo->prepare( 'INSERT INTO market_occurrence (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ') RETURNING *' ); $stmt->execute($params); $occurrence = (array) $stmt->fetch(); foreach ($days as $day) { if (!is_array($day)) { throw new InvalidArgumentException('Jeder Durchführungstag muss ein Objekt sein.'); } $day['occurrence_id'] = (int) $occurrence['id']; market_management_create_day($pdo, $day); } 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; } $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)) { $organizer['organization'] = get_party_reference($pdo, (int) $organizer['organization_contact_id']); $organizer['contact_person'] = get_contact_reference($pdo, (int) $organizer['contact_person_id']); } $occurrence['organizer'] = is_array($organizer) ? $organizer : null; $occurrence['organization_contact_id'] = is_array($organizer) ? (int) $organizer['organization_contact_id'] : null; $occurrence['contact_person_id'] = is_array($organizer) ? (int) $organizer['contact_person_id'] : null; $referenceData = market_management_list_reference_data($pdo, []); $occurrence['reference_options'] = [ 'categories' => market_management_list_categories($pdo), 'municipalities' => $referenceData['municipalities'] ?? [], 'parties' => get_party_options($pdo), 'contacts' => get_contact_options($pdo), ]; $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(); $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 { $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 === []) { throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); } $fields[] = 'updated_at = NOW()'; $params[':id'] = $occurrenceId; $stmt = $pdo->prepare( 'UPDATE market_occurrence SET ' . implode(', ', $fields) . ' WHERE id = :id RETURNING *' ); $stmt->execute($params); $occurrence = $stmt->fetch(); if (!is_array($occurrence)) { throw new RuntimeException('Durchführung nicht gefunden.'); } 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('Durchführungstag 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 Durchführungstag liegen.'); } if ($nextDate !== null && $value >= (string) $nextDate) { throw new InvalidArgumentException('Das Datum muss vor dem nachfolgenden Durchführungstag 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 Durchfü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('Durchführungstag 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('Durchführungstag 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('Durchführung nicht gefunden.'); } return $occurrence; } function market_management_delete_day(PDO $pdo, int $dayId): array { $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('Durchführungstag nicht gefunden.'); } return $day; } function market_management_set_organizer(PDO $pdo, array $input): array { $occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id'); $organizationId = market_management_require_positive_id($input['organization_contact_id'] ?? null, 'organization_contact_id'); $contactPersonId = market_management_require_positive_id($input['contact_person_id'] ?? null, 'contact_person_id'); $stmt = $pdo->prepare( 'INSERT INTO market_occurrence_organizer (occurrence_id, organization_contact_id, contact_person_id) VALUES (:occurrence_id, :organization_contact_id, :contact_person_id) ON CONFLICT (occurrence_id) DO UPDATE SET organization_contact_id = EXCLUDED.organization_contact_id, contact_person_id = EXCLUDED.contact_person_id, updated_at = NOW() RETURNING *' ); $stmt->execute([ ':occurrence_id' => $occurrenceId, ':organization_contact_id' => $organizationId, ':contact_person_id' => $contactPersonId, ]); return (array) $stmt->fetch(); } function market_management_delete_organizer(PDO $pdo, int $occurrenceId): array { $stmt = $pdo->prepare('DELETE FROM market_occurrence_organizer WHERE occurrence_id = :occurrence_id RETURNING id, occurrence_id'); $stmt->execute([':occurrence_id' => $occurrenceId]); $organizer = $stmt->fetch(); if (!is_array($organizer)) { throw new RuntimeException('Veranstalter nicht gefunden.'); } return $organizer; } function market_management_create_cost_item(PDO $pdo, array $input): array { $occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id'); $costType = trim((string) ($input['cost_type'] ?? '')); $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 ($costType === '' || $grossAmount === null || !in_array($taxStatus, ['tax_free', 'taxable'], true)) { throw new InvalidArgumentException('cost_type, 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, description, gross_amount, tax_status, tax_rate) VALUES (:occurrence_id, :cost_type, :description, :gross_amount, :tax_status, :tax_rate) RETURNING *' ); $stmt->execute([ ':occurrence_id' => $occurrenceId, ':cost_type' => $costType, ':description' => market_management_optional_string($input, 'description'), ':gross_amount' => $grossAmount, ':tax_status' => $taxStatus, ':tax_rate' => $taxRate, ]); return (array) $stmt->fetch(); } function market_management_delete_cost_item(PDO $pdo, int $costItemId): array { $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', $input)) { $costType = trim((string) $input['cost_type']); if ($costType === '') { throw new InvalidArgumentException('cost_type darf nicht leer sein.'); } $sets[] = 'cost_type = :cost_type'; $params[':cost_type'] = $costType; } 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) : 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.'); } return $item; }