Files
erp_naurua/modules/market_management/service.php
T

1584 lines
68 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/../shared/db.php';
const MARKET_MANAGEMENT_STATUSES = [
'open',
'waitlist',
'registered',
'confirmation_pending',
'confirmed',
'cancelled',
'completed',
];
const MARKET_OCCURRENCE_DAY_INHERITED_FIELDS = [
'stand_start_time', 'stand_end_time', 'setup_start_time', 'setup_end_time',
'delivery_start_time', 'delivery_end_time',
];
const MARKET_MANAGEMENT_MULTILINE_FIELDS = [
'address', 'notes', 'registration_note', 'other_infrastructure', 'own_stand_requirements',
'travel_address', 'parking_options', 'delivery_access', 'storage_options',
'travel_notes', 'cancellation_terms', 'revenue_share_terms',
'other_participation_conditions', 'observations', 'cost_items_text',
'description',
];
function market_management_is_allowed_link_url(string $url): bool
{
$parts = parse_url($url);
if (!is_array($parts)
|| !in_array(($parts['scheme'] ?? ''), ['http', 'https'], true)
|| ($parts['host'] ?? '') === ''
|| isset($parts['user'], $parts['pass'])) {
return false;
}
if (($parts['host'] ?? '') !== 'erpnaurua.imhochrain.ch' || ($parts['path'] ?? '') !== '/files.php') {
return true;
}
if (isset($parts['port'], $parts['fragment']) || !preg_match('/^path=([^&]+)$/', (string) ($parts['query'] ?? ''), $matches)) {
return false;
}
$relativePath = rawurldecode($matches[1]);
if ($relativePath === '' || str_contains($relativePath, "\0") || str_contains($relativePath, '\\') || str_starts_with($relativePath, '/')) {
return false;
}
foreach (explode('/', $relativePath) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
function market_management_sanitize_multiline_html(?string $value): ?string
{
if ($value === null || $value === '') {
return $value;
}
$tokens = preg_split('/(<\/?a\b[^>]*>|<br\s*\/?>|<\/?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('/^<br\s*\/?>$/iu', $token)) {
$result .= '<br>';
continue;
}
if (preg_match('/^<strong\s*>$/iu', $token)) {
$result .= '<strong>';
continue;
}
if (preg_match('/^<\/strong\s*>$/iu', $token)) {
$result .= '</strong>';
continue;
}
if (preg_match('/^<\/a\s*>$/iu', $token)) {
if ($anchorOpen) {
$result .= '</a>';
$anchorOpen = false;
} else {
$result .= htmlspecialchars($token, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
continue;
}
if (preg_match('/^<a\b[^>]*\bhref\s*=\s*(["\'])(.*?)\1[^>]*>$/isu', $token, $matches)
&& market_management_is_allowed_link_url($matches[2])) {
if ($anchorOpen) {
$result .= '</a>';
}
$href = htmlspecialchars($matches[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$result .= '<a class="sg-hyperlink" href="' . $href . '" target="_blank" rel="noopener noreferrer">';
$anchorOpen = true;
continue;
}
$result .= htmlspecialchars($token, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if ($anchorOpen) {
$result .= '</a>';
}
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 (!preg_match('/^https?:\/\//i', $value)) {
$value = 'https://' . $value;
}
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',
'category_name' => 'c.name',
'organizer_name' => 'p.name',
'website_url' => 'm.website_url',
'occurrence_count' => 'COUNT(DISTINCT o.id)',
'status' => 'CASE WHEN m.archived_at IS NULL THEN 0 ELSE 1 END',
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', 'registration_note' => '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', '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', 'registration_note' => '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',
'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;
}