From 9bde3375064775b50dc012ac07ab56dd2fd5c207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Sat, 19 Sep 2026 09:24:14 +0200 Subject: [PATCH] Vervollstaendige Marktmanagement-MCP-Abdeckung --- .../0024_market_mcp_idempotency_payload.sql | 6 + modules/market_management/mcp.php | 111 +++++++- modules/market_management/service.php | 247 ++++++++++++++++-- public/api/market-management-mcp.php | 2 +- 4 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 db/migrations/0024_market_mcp_idempotency_payload.sql diff --git a/db/migrations/0024_market_mcp_idempotency_payload.sql b/db/migrations/0024_market_mcp_idempotency_payload.sql new file mode 100644 index 0000000..5625ef9 --- /dev/null +++ b/db/migrations/0024_market_mcp_idempotency_payload.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE market_mcp_idempotency + ADD COLUMN result_payload JSONB; + +COMMIT; diff --git a/modules/market_management/mcp.php b/modules/market_management/mcp.php index 7962d52..3ced45b 100644 --- a/modules/market_management/mcp.php +++ b/modules/market_management/mcp.php @@ -32,13 +32,16 @@ function market_management_mcp_tools(): array { return [ ['name' => 'market_management.search_markets', 'description' => 'Märkte suchen', 'inputSchema' => ['type' => 'object']], + ['name' => 'market_management.search_occurrences', 'description' => 'Durchführungen suchen und auflisten', 'inputSchema' => ['type' => 'object']], ['name' => 'market_management.get_market', 'description' => 'Markt mit Durchführungen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]], ['name' => 'market_management.create_market', 'description' => 'Markt mit bestehender Kategorie anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id']]], ['name' => 'market_management.update_market', 'description' => 'Markt ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]], ['name' => 'market_management.archive_market', 'description' => 'Markt archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]], ['name' => 'market_management.reactivate_market', 'description' => 'Markt reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]], ['name' => 'market_management.list_categories', 'description' => 'Bestehende Kategorien lesen', 'inputSchema' => ['type' => 'object']], - ['name' => 'market_management.create_occurrence', 'description' => 'Durchführung anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id']]], + ['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object']], + ['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object']], + ['name' => 'market_management.create_occurrence', 'description' => 'Durchführung mit mindestens einem Tag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days']]], ['name' => 'market_management.get_occurrence', 'description' => 'Durchführung mit Tagen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]], ['name' => 'market_management.update_occurrence', 'description' => 'Durchführung ändern', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]], ['name' => 'market_management.archive_occurrence', 'description' => 'Durchführung archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]], @@ -47,15 +50,107 @@ function market_management_mcp_tools(): array ['name' => 'market_management.update_day', 'description' => 'Durchführungstag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id']]], ['name' => 'market_management.delete_day', 'description' => 'Durchführungstag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id']]], ['name' => 'market_management.set_organizer', 'description' => 'Veranstalter einer Durchführung setzen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'organization_contact_id', 'contact_person_id']]], + ['name' => 'market_management.delete_organizer', 'description' => 'Veranstalter einer Durchführung entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]], ['name' => 'market_management.create_cost_item', 'description' => 'Kostenposition anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'cost_type', 'gross_amount', 'tax_status']]], + ['name' => 'market_management.update_cost_item', 'description' => 'Kostenposition ändern', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id']]], ['name' => 'market_management.delete_cost_item', 'description' => 'Kostenposition entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id']]], ]; } +function market_management_mcp_is_write_tool(string $tool): bool +{ + return in_array($tool, [ + 'market_management.create_market', 'market_management.update_market', + 'market_management.archive_market', 'market_management.reactivate_market', + 'market_management.create_occurrence', 'market_management.update_occurrence', + 'market_management.archive_occurrence', 'market_management.reactivate_occurrence', + 'market_management.create_day', 'market_management.update_day', 'market_management.delete_day', + 'market_management.set_organizer', 'market_management.delete_organizer', + 'market_management.create_cost_item', 'market_management.update_cost_item', + 'market_management.delete_cost_item', + ], true); +} + +function market_management_mcp_call_idempotent(PDO $pdo, string $userSubject, string $tool, array $arguments): array +{ + if (!market_management_mcp_is_write_tool($tool)) { + return market_management_mcp_call($pdo, $tool, $arguments); + } + + $idempotencyKey = trim((string) ($arguments['idempotency_key'] ?? '')); + if ($idempotencyKey === '') { + throw new InvalidArgumentException('Schreibende MCP-Aufrufe benötigen idempotency_key.'); + } + $clientId = trim((string) ($arguments['client_id'] ?? 'hermes')); + if ($clientId === '') { + throw new InvalidArgumentException('client_id darf nicht leer sein.'); + } + $businessArguments = $arguments; + unset($businessArguments['idempotency_key'], $businessArguments['client_id']); + $requestHash = hash('sha256', json_encode([ + 'tool' => $tool, + 'arguments' => $businessArguments, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + + $pdo->beginTransaction(); + try { + $existingStmt = $pdo->prepare( + 'SELECT user_subject, request_hash, result_status, result_payload + FROM market_mcp_idempotency + WHERE client_id = :client_id AND idempotency_key = :idempotency_key + FOR UPDATE' + ); + $existingStmt->execute([':client_id' => $clientId, ':idempotency_key' => $idempotencyKey]); + $existing = $existingStmt->fetch(); + if (is_array($existing)) { + if ((string) $existing['user_subject'] !== $userSubject || (string) $existing['request_hash'] !== $requestHash) { + throw new InvalidArgumentException('Idempotency-Schlüssel wurde bereits für eine andere Anfrage verwendet.'); + } + if ((string) $existing['result_status'] === 'succeeded' && $existing['result_payload'] !== null) { + $pdo->commit(); + return (array) json_decode((string) $existing['result_payload'], true, 512, JSON_THROW_ON_ERROR); + } + throw new RuntimeException('Idempotency-Anfrage wird bereits verarbeitet oder ist fehlgeschlagen.'); + } + + $insert = $pdo->prepare( + 'INSERT INTO market_mcp_idempotency + (user_subject, client_id, idempotency_key, tool_name, request_hash, result_status, expires_at) + VALUES (:user_subject, :client_id, :idempotency_key, :tool_name, :request_hash, \'processing\', NOW() + INTERVAL \'24 hours\')' + ); + $insert->execute([ + ':user_subject' => $userSubject, + ':client_id' => $clientId, + ':idempotency_key' => $idempotencyKey, + ':tool_name' => $tool, + ':request_hash' => $requestHash, + ]); + $result = market_management_mcp_call($pdo, $tool, $businessArguments); + $update = $pdo->prepare( + 'UPDATE market_mcp_idempotency + SET result_status = \'succeeded\', result_payload = CAST(:result_payload AS JSONB) + WHERE client_id = :client_id AND idempotency_key = :idempotency_key' + ); + $update->execute([ + ':result_payload' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + ':client_id' => $clientId, + ':idempotency_key' => $idempotencyKey, + ]); + $pdo->commit(); + return $result; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } +} + function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): array { return match ($tool) { 'market_management.search_markets' => market_management_search_markets($pdo, $arguments), + 'market_management.search_occurrences' => market_management_search_occurrences($pdo, $arguments), 'market_management.get_market' => market_management_get_market( $pdo, market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'), @@ -78,6 +173,11 @@ function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): a false ), 'market_management.list_categories' => market_management_list_categories($pdo), + 'market_management.list_reference_data' => market_management_list_reference_data($pdo, $arguments), + 'market_management.get_overview' => [ + 'markets' => market_management_search_markets($pdo, $arguments), + 'occurrences' => market_management_search_occurrences($pdo, $arguments), + ], 'market_management.create_occurrence' => market_management_create_occurrence($pdo, $arguments), 'market_management.get_occurrence' => market_management_get_occurrence( $pdo, @@ -110,7 +210,16 @@ function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): a market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id') ), 'market_management.set_organizer' => market_management_set_organizer($pdo, $arguments), + 'market_management.delete_organizer' => market_management_delete_organizer( + $pdo, + market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id') + ), 'market_management.create_cost_item' => market_management_create_cost_item($pdo, $arguments), + 'market_management.update_cost_item' => market_management_update_cost_item( + $pdo, + market_management_require_positive_id($arguments['cost_item_id'] ?? null, 'cost_item_id'), + $arguments + ), 'market_management.delete_cost_item' => market_management_delete_cost_item( $pdo, market_management_require_positive_id($arguments['cost_item_id'] ?? null, 'cost_item_id') diff --git a/modules/market_management/service.php b/modules/market_management/service.php index 15a458d..c2feacf 100644 --- a/modules/market_management/service.php +++ b/modules/market_management/service.php @@ -118,10 +118,28 @@ 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]; + $params = [':limit' => $limit, ':offset' => $offset, ':include_archived' => $includeArchived]; if (!$includeArchived) { $where[] = 'm.archived_at IS NULL'; } @@ -129,18 +147,51 @@ function market_management_search_markets(PDO $pdo, array $input): array $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 (:include_archived OR 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 (:include_archived OR 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 (:include_archived OR 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 (:include_archived OR odate.archived_at IS NULL))'; + } $stmt = $pdo->prepare( 'SELECT m.id, m.name, m.category_id, c.code AS category_code, c.name AS category_name, - m.archived_at, m.created_at, m.updated_at + 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 (:include_archived OR o.archived_at IS NULL) + LEFT JOIN market_occurrence_day d ON d.occurrence_id = o.id WHERE ' . ($where === [] ? 'TRUE' : implode(' AND ', $where)) . ' - ORDER BY m.name ASC, m.id ASC - LIMIT :limit' + GROUP BY m.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, $key === ':limit' ? PDO::PARAM_INT : PDO::PARAM_STR); + $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(); @@ -162,10 +213,7 @@ function market_management_get_market(PDO $pdo, int $marketId, bool $includeArch } $occurrences = $pdo->prepare( - 'SELECT id, status, place, address, municipality_reference_id, notes, - registration_deadline, request_or_registration_date, confirmation_date, - payment_deadline, payment_status, payment_date, paid_amount, - contract_folder_url, archived_at, created_at, updated_at + 'SELECT * FROM market_occurrence WHERE market_definition_id = :market_id AND (:include_archived OR archived_at IS NULL) @@ -173,9 +221,94 @@ function market_management_get_market(PDO $pdo, int $marketId, bool $includeArch ); $occurrences->execute([':market_id' => $marketId, ':include_archived' => $includeArchived]); $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_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); + $params[':include_archived'] = $includeArchived; + if (!$includeArchived) { + $where[] = 'o.archived_at IS NULL'; + } + 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, c.code, c.name + ORDER BY earliest_date 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_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 = []; @@ -274,6 +407,10 @@ function market_management_create_occurrence(PDO $pdo, array $input): array 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', @@ -296,13 +433,35 @@ function market_management_create_occurrence(PDO $pdo, array $input): array $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); - $stmt = $pdo->prepare( - 'INSERT INTO market_occurrence (' . implode(', ', $columns) . ') - VALUES (' . implode(', ', $values) . ') - RETURNING *' - ); - $stmt->execute($params); - return (array) $stmt->fetch(); + $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 @@ -322,6 +481,12 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc ); $days->execute([':occurrence_id' => $occurrenceId]); $occurrence['days'] = $days->fetchAll(); + $organizer = $pdo->prepare('SELECT * FROM market_occurrence_organizer WHERE occurrence_id = :occurrence_id'); + $organizer->execute([':occurrence_id' => $occurrenceId]); + $occurrence['organizer'] = $organizer->fetch() ?: null; + $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(); return $occurrence; } @@ -465,6 +630,17 @@ function market_management_set_organizer(PDO $pdo, array $input): array 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'); @@ -505,3 +681,42 @@ function market_management_delete_cost_item(PDO $pdo, int $costItemId): array } 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; +} diff --git a/public/api/market-management-mcp.php b/public/api/market-management-mcp.php index 70e61de..d00ce01 100644 --- a/public/api/market-management-mcp.php +++ b/public/api/market-management-mcp.php @@ -39,7 +39,7 @@ try { $result = [ 'content' => [[ 'type' => 'text', - 'text' => json_encode(market_management_mcp_call($pdo, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + 'text' => json_encode(market_management_mcp_call_idempotent($pdo, $userSubject, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), ]], 'isError' => false, ];