diff --git a/db/migrations/0029_market_municipality_register.sql b/db/migrations/0029_market_municipality_register.sql new file mode 100644 index 0000000..3e5ae35 --- /dev/null +++ b/db/migrations/0029_market_municipality_register.sql @@ -0,0 +1,40 @@ +BEGIN; + +CREATE TABLE market_municipality ( + id BIGSERIAL PRIMARY KEY, + municipality_reference_id BIGINT UNIQUE REFERENCES market_municipality_reference(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + canton_code TEXT, + bfs_municipality_number INTEGER, + data_version DATE, + source TEXT NOT NULL DEFAULT 'local', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT chk_market_municipality_source CHECK (source IN ('reference', 'local')) +); + +CREATE UNIQUE INDEX uq_market_municipality_name + ON market_municipality (LOWER(TRIM(name))); +CREATE INDEX idx_market_municipality_name + ON market_municipality (name); + +INSERT INTO market_municipality ( + municipality_reference_id, name, canton_code, bfs_municipality_number, data_version, source, is_active +) +SELECT id, name, canton_code, bfs_municipality_number, data_version, 'reference', is_active +FROM market_municipality_reference; + +ALTER TABLE market_occurrence + DROP CONSTRAINT market_occurrence_municipality_reference_id_fkey; + +UPDATE market_occurrence o +SET municipality_reference_id = m.id +FROM market_municipality m +WHERE m.municipality_reference_id = o.municipality_reference_id; + +ALTER TABLE market_occurrence + ADD CONSTRAINT fk_market_occurrence_municipality + FOREIGN KEY (municipality_reference_id) REFERENCES market_municipality(id) ON DELETE RESTRICT; + +COMMIT; diff --git a/docs/modules/market_management/konzepte/market_management_db_ownership.md b/docs/modules/market_management/konzepte/market_management_db_ownership.md index 30019e9..7c97b85 100644 --- a/docs/modules/market_management/konzepte/market_management_db_ownership.md +++ b/docs/modules/market_management/konzepte/market_management_db_ownership.md @@ -14,6 +14,7 @@ Status: Verbindliches DB-Ownership-Konzept | `market_occurrence_day` | `market_management` | Marktmanagement-Service | | `market_occurrence_cost_item` | `market_management` | Marktmanagement-Service | | `market_municipality_reference` | `market_management` | Referenzdatenprozess | +| `market_municipality` | `market_management` | Marktmanagement-Service | | `market_mcp_idempotency` | `market_management` | MCP-Submodul | ## 2. Fremde Datenreferenzen @@ -44,6 +45,11 @@ Umsatz- und Artikelmengenaggregation. Der Gemeindereferenzbestand verwendet die BFS-Gemeindenummer als stabile fachliche Referenz und speichert den Datenstand zur Nachvollziehbarkeit. +Das Marktmanagement besitzt zusätzlich das eigene Gemeinderegister +`market_municipality`. Dieses Register enthält die offiziellen Gemeinden sowie +lokal neu angelegte Gemeinden und ist die einzige Auswahl- und Schreibbasis für +Gemeindezuordnungen von Marktdurchführungen. + Die Kategorie-/Marktartliste gehört zum Marktmanagement und wird durch Fabienne gepflegt. Hermes darf sie nur lesen. diff --git a/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md b/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md index 4bc89a4..4faba4e 100644 --- a/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md +++ b/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md @@ -120,7 +120,10 @@ Hermes darf Kategorien lesen und bei einem Markt referenzieren, aber keine Kategorien anlegen oder ändern. - `market_management.list_reference_data` - - liefert Kategorien, Kantone und aktive Gemeinden + - liefert Kategorien, Kantone und aktive Gemeinden aus dem Markt-Gemeinderegister +- `market_management.create_municipality` + - erforderlich: `name` + - legt eine lokale Gemeinde im Markt-Gemeinderegister an - `market_management.get_overview` - liefert serverseitig gefilterte Markt- und Marktdurchführungslisten @@ -136,8 +139,9 @@ Kategorien anlegen oder ändern. berechnete Tages- und Gesamterlöse - `market_management.update_occurrence` - erforderlich: `occurrence_id` - - änderbar: sämtliche GUI-Marktdurchführungsfelder einschließlich Veranstalter, - Kontaktperson, Kontakt-E-Mail und Kontakt-Telefon + - änderbar: sämtliche editierbaren GUI-Marktdurchführungsfelder einschließlich + Gemeinde, Kontaktperson, Kontakt-E-Mail und Kontakt-Telefon + - der vom Markt geerbte Veranstalter ist nicht editierbar - `market_management.archive_occurrence` - erforderlich: `occurrence_id` - `market_management.reactivate_occurrence` diff --git a/docs/modules/market_management/konzepte/market_management_technisches_konzept.md b/docs/modules/market_management/konzepte/market_management_technisches_konzept.md index 38e4116..fc42867 100644 --- a/docs/modules/market_management/konzepte/market_management_technisches_konzept.md +++ b/docs/modules/market_management/konzepte/market_management_technisches_konzept.md @@ -14,6 +14,7 @@ Tabellen: - `market_occurrence_day` - `market_occurrence_cost_item` - `market_municipality_reference` +- `market_municipality` - `market_mcp_idempotency` Bestehende Integrationsreferenz: @@ -176,9 +177,24 @@ Normativ: - Gemeinden werden über BFS-Gemeindenummern referenziert - Gemeindename und Kantonszuordnung stammen aus dem Referenzbestand - nur gültige Gemeinden stehen für neue Eingaben zur Auswahl + +### 3.8 `market_municipality` + +Eigenes Gemeinderegister des Marktmanagements. Es enthält sowohl aktive +Gemeinden aus dem offiziellen Referenzbestand als auch lokal neu angelegte +Gemeinden. + +Normativ: + +- jede aktive Gemeinde besitzt einen eindeutigen Namen +- Referenzgemeinden behalten ihre BFS-Gemeindenummer und den Datenstand +- lokal angelegte Gemeinden besitzen keine BFS-Referenz +- neue Gemeinden werden über das Marktmanagement angelegt und danach in der + Auswahl für weitere Marktdurchführungen angeboten +- eine Marktdurchführung referenziert genau einen Datensatz dieses Registers - der Datenstand wird versioniert gespeichert -### 3.8 `market_mcp_idempotency` +### 3.9 `market_mcp_idempotency` Technischer Idempotenzbestand für wiederholte Hermes-Schreibaufrufe. diff --git a/modules/market_management/mcp.php b/modules/market_management/mcp.php index ab9ff92..bc85185 100644 --- a/modules/market_management/mcp.php +++ b/modules/market_management/mcp.php @@ -81,6 +81,7 @@ function market_management_mcp_tools(): array ['name' => 'market_management.list_market_organizers', 'description' => 'Aktive Veranstalter lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]], ['name' => 'market_management.create_market_organizer', 'description' => 'Veranstalter mit optionalen Kontaktdaten anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string'], 'email' => ['type' => ['string', 'null'], 'format' => 'email'], 'phone' => ['type' => ['string', 'null']]]]], ['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]], + ['name' => 'market_management.create_municipality', 'description' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string']]]], ['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]], ['name' => 'market_management.create_occurrence', 'description' => 'Marktdurchführung mit mindestens einem Markttag 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' => 'Marktdurchführung mit Markt, Markttagen und berechneten Verkaufsdaten lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]], @@ -101,6 +102,7 @@ function market_management_mcp_is_write_tool(string $tool): bool return in_array($tool, [ 'market_management.create_market', 'market_management.update_market', 'market_management.create_market_organizer', + 'market_management.create_municipality', 'market_management.archive_market', 'market_management.reactivate_market', 'market_management.create_occurrence', 'market_management.update_occurrence', 'market_management.archive_occurrence', 'market_management.reactivate_occurrence', @@ -276,6 +278,10 @@ function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): a $arguments['phone'] ?? null ), 'market_management.list_reference_data' => market_management_list_reference_data($pdo, $arguments), + 'market_management.create_municipality' => market_management_create_municipality( + $pdo, + (string) ($arguments['name'] ?? '') + ), 'market_management.get_overview' => [ 'markets' => market_management_search_markets($pdo, $arguments), 'occurrences' => market_management_search_occurrences($pdo, $arguments), diff --git a/modules/market_management/service.php b/modules/market_management/service.php index e8f034d..e0fb088 100644 --- a/modules/market_management/service.php +++ b/modules/market_management/service.php @@ -55,6 +55,40 @@ function market_management_create_organizer(PDO $pdo, string $name, ?string $ema 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_require_positive_id(mixed $value, string $field): int { if (!is_int($value) && !ctype_digit((string) $value)) { @@ -501,10 +535,7 @@ function market_management_list_active_occurrence_options(PDO $pdo): array 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(); + $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']], @@ -622,6 +653,14 @@ function market_management_write_fields(array $input, array $definitions): array 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.'); @@ -658,6 +697,13 @@ function market_management_create_occurrence(PDO $pdo, array $input): array $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) . ') @@ -702,6 +748,14 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc 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' @@ -837,6 +891,14 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc 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', @@ -858,18 +920,37 @@ function market_management_update_occurrence(PDO $pdo, int $occurrenceId, array if (isset($params[':payment_status']) && !in_array($params[':payment_status'], ['paid', 'unpaid'], true)) { throw new InvalidArgumentException('Ungültiger Zahlungsstatus.'); } - if ($fields === []) { + if ($fields === [] && $newMunicipalityName === null) { throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.'); } - $fields[] = 'updated_at = NOW()'; - $params[':id'] = $occurrenceId; - $stmt = $pdo->prepare( - 'UPDATE market_occurrence SET ' . implode(', ', $fields) . ' WHERE id = :id RETURNING *' - ); - $stmt->execute($params); - $occurrence = $stmt->fetch(); - if (!is_array($occurrence)) { - throw new RuntimeException('Marktdurchführung nicht gefunden.'); + $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 *' + ); + $stmt->execute($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; } diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index be69108..7b3a229 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -1143,6 +1143,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM echo " const marketStatusLabel = (value) => ({ open: 'Offen', waitlist: 'Warteliste', registered: 'Angemeldet', confirmation_pending: 'Bestätigung ausstehend', confirmed: 'Bestätigt', cancelled: 'Abgesagt', completed: 'Abgeschlossen' }[value] || value || 'Nicht hinterlegt');"; echo " const marketStatusSegment = (value) => ({ waitlist: 'sg-card-segment--signal-red', registered: 'sg-card-segment--signal-yellow', confirmation_pending: 'sg-card-segment--signal-yellow', confirmed: 'sg-card-segment--signal-green' }[value] || 'sg-card-segment--gray');"; echo " const marketRow = (label, value) => '