344 lines
22 KiB
PHP
344 lines
22 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/service.php';
|
|
|
|
function market_management_mcp_token(array $env): string
|
|
{
|
|
$token = env_value('MARKET_MCP_TOKEN', $env);
|
|
if ($token === '') {
|
|
throw new RuntimeException('MARKET_MCP_TOKEN ist nicht konfiguriert.');
|
|
}
|
|
return $token;
|
|
}
|
|
|
|
function market_management_mcp_authorize(array $env): string
|
|
{
|
|
$header = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? '');
|
|
if (!preg_match('/^Bearer\s+(.+)$/i', trim($header), $matches)) {
|
|
throw new RuntimeException('Bearer-Token fehlt.');
|
|
}
|
|
|
|
$token = trim($matches[1]);
|
|
$expected = market_management_mcp_token($env);
|
|
if ($token === '' || !hash_equals($expected, $token)) {
|
|
throw new RuntimeException('Bearer-Token ist ungültig.');
|
|
}
|
|
|
|
return 'bearer:' . hash('sha256', $token);
|
|
}
|
|
|
|
function market_management_mcp_tools(): array
|
|
{
|
|
$occurrenceProperties = [
|
|
'status' => ['type' => 'string'], 'place' => ['type' => ['string', 'null']], 'address' => ['type' => ['string', 'null']],
|
|
'municipality_reference_id' => ['type' => ['integer', 'null']], 'notes' => ['type' => ['string', 'null']],
|
|
'registration_deadline' => ['type' => ['string', 'null'], 'format' => 'date'],
|
|
'request_or_registration_date' => ['type' => ['string', 'null'], 'format' => 'date'],
|
|
'confirmation_date' => ['type' => ['string', 'null'], 'format' => 'date'],
|
|
'payment_deadline' => ['type' => ['string', 'null'], 'format' => 'date'],
|
|
'payment_status' => ['type' => ['string', 'null'], 'enum' => ['paid', 'unpaid']],
|
|
'payment_date' => ['type' => ['string', 'null'], 'format' => 'date'], 'paid_amount' => ['type' => ['number', 'null']],
|
|
'payment_method_code' => ['type' => ['string', 'null']], 'stand_size' => ['type' => ['string', 'null']],
|
|
'table_available' => ['type' => ['boolean', 'null']], 'table_rentable' => ['type' => ['boolean', 'null']],
|
|
'own_table_required' => ['type' => ['boolean', 'null']], 'shelter_available' => ['type' => ['boolean', 'null']],
|
|
'power_available' => ['type' => ['boolean', 'null']], 'water_available' => ['type' => ['boolean', 'null']],
|
|
'lighting_available' => ['type' => ['boolean', 'null']], 'other_infrastructure' => ['type' => ['string', 'null']],
|
|
'own_stand_requirements' => ['type' => ['string', 'null']], 'travel_address' => ['type' => ['string', 'null']],
|
|
'travel_distance_km' => ['type' => ['number', 'null']], 'travel_duration_minutes' => ['type' => ['integer', 'null']],
|
|
'parking_options' => ['type' => ['string', 'null']], 'delivery_access' => ['type' => ['string', 'null']],
|
|
'direct_unload_possible' => ['type' => ['boolean', 'null']], 'storage_options' => ['type' => ['string', 'null']],
|
|
'travel_notes' => ['type' => ['string', 'null']], 'cancellation_terms' => ['type' => ['string', 'null']],
|
|
'revenue_share_terms' => ['type' => ['string', 'null']], 'other_participation_conditions' => ['type' => ['string', 'null']],
|
|
'contract_folder_url' => ['type' => ['string', 'null'], 'format' => 'uri'],
|
|
'organization_contact_id' => ['type' => ['integer', 'null']], 'contact_person_id' => ['type' => ['integer', 'null']],
|
|
'contact_email' => ['type' => ['string', 'null'], 'format' => 'email'], 'contact_phone' => ['type' => ['string', 'null']],
|
|
];
|
|
$dayProperties = [
|
|
'date' => ['type' => 'string', 'format' => 'date'], 'observations' => ['type' => ['string', 'null']],
|
|
'stand_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'stand_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
|
|
'setup_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'setup_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
|
|
'delivery_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'delivery_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
|
|
'inherit_from_first_day' => ['type' => ['boolean', 'null']],
|
|
];
|
|
$searchProperties = [
|
|
'search' => ['type' => 'string'], 'market_definition_id' => ['type' => 'integer'], 'category_id' => ['type' => 'integer'],
|
|
'status' => ['type' => 'string'], 'place' => ['type' => 'string'], 'address' => ['type' => 'string'],
|
|
'municipality_reference_id' => ['type' => 'integer'], 'date_from' => ['type' => 'string', 'format' => 'date'],
|
|
'date_to' => ['type' => 'string', 'format' => 'date'], 'include_archived' => ['type' => 'boolean'],
|
|
'sort' => ['type' => 'string'], 'sort_direction' => ['type' => 'string', 'enum' => ['asc', 'desc']],
|
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'offset' => ['type' => 'integer', 'minimum' => 0],
|
|
];
|
|
return [
|
|
['name' => 'market_management.search_markets', 'description' => 'Märkte suchen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
|
|
['name' => 'market_management.search_occurrences', 'description' => 'Durchführungen suchen und auflisten', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
|
|
['name' => 'market_management.get_market', 'description' => 'Markt mit Durchführungen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
|
|
['name' => 'market_management.create_market', 'description' => 'Markt mit bestehender Kategorie und optionaler Website-URL anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id'], 'properties' => ['name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]],
|
|
['name' => 'market_management.update_market', 'description' => 'Markt inklusive Website-URL ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]],
|
|
['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.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', 'properties' => $searchProperties]],
|
|
['name' => 'market_management.create_occurrence', 'description' => 'Durchführung mit mindestens einem Tag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days'], 'properties' => ['market_definition_id' => ['type' => 'integer'], 'status' => ['type' => 'string'], 'days' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'object', 'required' => ['date'], 'properties' => $dayProperties]] + $occurrenceProperties]]],
|
|
['name' => 'market_management.get_occurrence', 'description' => 'Durchführung mit Markt, Tagen und berechneten Verkaufsdaten lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
|
|
['name' => 'market_management.update_occurrence', 'description' => 'Durchführung ändern', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer']] + $occurrenceProperties]],
|
|
['name' => 'market_management.archive_occurrence', 'description' => 'Durchführung archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]],
|
|
['name' => 'market_management.reactivate_occurrence', 'description' => 'Durchführung reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]],
|
|
['name' => 'market_management.create_day', 'description' => 'Durchführungstag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date'], 'properties' => ['occurrence_id' => ['type' => 'integer']] + $dayProperties]],
|
|
['name' => 'market_management.update_day', 'description' => 'Durchführungstag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']] + $dayProperties]],
|
|
['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_get_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): array
|
|
{
|
|
$occurrence = market_management_get_occurrence($pdo, $occurrenceId, $includeArchived);
|
|
if ($occurrence === null) {
|
|
throw new RuntimeException('Durchführung nicht gefunden.');
|
|
}
|
|
$market = market_management_get_market($pdo, (int) $occurrence['market_definition_id'], $includeArchived);
|
|
if ($market === null) {
|
|
throw new RuntimeException('Markt nicht gefunden.');
|
|
}
|
|
unset($market['occurrences']);
|
|
$occurrence['market'] = $market;
|
|
return $occurrence;
|
|
}
|
|
|
|
function market_management_mcp_get_market(PDO $pdo, int $marketId, bool $includeArchived = false): array
|
|
{
|
|
$market = market_management_get_market($pdo, $marketId, $includeArchived);
|
|
if ($market === null) {
|
|
throw new RuntimeException('Markt nicht gefunden.');
|
|
}
|
|
foreach ($market['occurrences'] as $index => $occurrence) {
|
|
$enriched = market_management_get_occurrence($pdo, (int) $occurrence['id'], $includeArchived);
|
|
if ($enriched !== null) {
|
|
$market['occurrences'][$index] = $enriched;
|
|
}
|
|
}
|
|
return $market;
|
|
}
|
|
|
|
function market_management_mcp_update_occurrence(PDO $pdo, int $occurrenceId, array $input): array
|
|
{
|
|
$contactFields = [];
|
|
foreach (['contact_email', 'contact_phone'] as $field) {
|
|
if (array_key_exists($field, $input)) {
|
|
$contactFields[substr($field, 8)] = $input[$field];
|
|
unset($input[$field]);
|
|
}
|
|
}
|
|
$organizerFields = [];
|
|
foreach (['organization_contact_id', 'contact_person_id'] as $field) {
|
|
if (array_key_exists($field, $input)) {
|
|
$organizerFields[$field] = $input[$field];
|
|
unset($input[$field]);
|
|
}
|
|
}
|
|
$existing = null;
|
|
if ($contactFields !== [] || $organizerFields !== []) {
|
|
$existing = market_management_get_occurrence($pdo, $occurrenceId, true);
|
|
if ($existing === null) {
|
|
throw new RuntimeException('Durchführung nicht gefunden.');
|
|
}
|
|
}
|
|
if ($organizerFields !== []) {
|
|
$organizer = $existing['organizer'] ?? [];
|
|
$organizerFields['occurrence_id'] = $occurrenceId;
|
|
$organizerFields['organization_contact_id'] ??= $organizer['organization_contact_id'] ?? null;
|
|
$organizerFields['contact_person_id'] ??= $organizer['contact_person_id'] ?? null;
|
|
market_management_set_organizer($pdo, $organizerFields);
|
|
}
|
|
if ($contactFields !== []) {
|
|
$contactId = (int) ($organizerFields['contact_person_id'] ?? $existing['contact_person_id'] ?? 0);
|
|
$hasContactValue = array_reduce($contactFields, static fn (bool $carry, mixed $value): bool => $carry || trim((string) $value) !== '', false);
|
|
if ($contactId > 0 && ($hasContactValue || array_key_exists('contact_person_id', $organizerFields))) {
|
|
update_contact_details($pdo, $contactId, $contactFields);
|
|
} elseif ($hasContactValue) {
|
|
throw new InvalidArgumentException('Für E-Mail oder Telefon muss eine Kontaktperson ausgewählt sein.');
|
|
}
|
|
}
|
|
if ($input !== []) {
|
|
market_management_update_occurrence($pdo, $occurrenceId, $input);
|
|
}
|
|
return market_management_mcp_get_occurrence($pdo, $occurrenceId, true);
|
|
}
|
|
|
|
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_mcp_get_market(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
|
|
(bool) ($arguments['include_archived'] ?? false)
|
|
),
|
|
'market_management.create_market' => market_management_create_market($pdo, $arguments),
|
|
'market_management.update_market' => market_management_update_market(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
|
|
$arguments
|
|
),
|
|
'market_management.archive_market' => market_management_set_market_archive(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
|
|
true
|
|
),
|
|
'market_management.reactivate_market' => market_management_set_market_archive(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
|
|
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_mcp_get_occurrence(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
|
|
(bool) ($arguments['include_archived'] ?? false)
|
|
),
|
|
'market_management.update_occurrence' => market_management_mcp_update_occurrence(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
|
|
$arguments
|
|
),
|
|
'market_management.archive_occurrence' => market_management_set_occurrence_archive(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
|
|
true
|
|
),
|
|
'market_management.reactivate_occurrence' => market_management_set_occurrence_archive(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
|
|
false
|
|
),
|
|
'market_management.create_day' => market_management_create_day($pdo, $arguments),
|
|
'market_management.update_day' => market_management_update_day(
|
|
$pdo,
|
|
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id'),
|
|
$arguments
|
|
),
|
|
'market_management.delete_day' => market_management_delete_day(
|
|
$pdo,
|
|
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')
|
|
),
|
|
default => throw new InvalidArgumentException('Unbekanntes MCP-Tool: ' . $tool),
|
|
};
|
|
}
|