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) => '
' + marketEscape(label) + '
' + marketDisplay(value) + '
';"; + echo " const initializeMunicipalityFields = (scope) => { scope.querySelectorAll('select[name=\"municipality_reference_id\"]').forEach((select) => { if (!select.querySelector('option[value=\"__new__\"]')) { const option = document.createElement('option'); option.value = '__new__'; option.textContent = 'Neue Gemeinde'; select.append(option); } const form = select.closest('form'); if (!form) { return; } let fields = form.querySelector('[data-market-new-municipality]'); if (!fields) { fields = document.createElement('div'); fields.className = 'sg-form-sections-card__field-group'; fields.dataset.marketNewMunicipality = ''; fields.hidden = true; fields.innerHTML = ''; select.closest('.sg-form-sections-card__field-group')?.after(fields); } const sync = () => { const visible = select.value === '__new__'; fields.hidden = !visible; const input = fields.querySelector('[data-market-new-municipality-name]'); if (input) { input.disabled = !visible; input.setAttribute('aria-disabled', String(!visible)); } }; if (select.dataset.municipalityBound !== 'true') { select.addEventListener('change', sync); select.dataset.municipalityBound = 'true'; } sync(); }); };"; echo " const marketCard = (title, body, headerClass = 'sg-card-segment--darkgreen', actions = '', scope = '', id = '', values = null) => { const header = '
' + title + '
' + (actions ? '
' + actions + '
' : '') + '
'; return '
' + header + '
' + body + '
' + (scope ? '' : '') + '
'; };"; echo " const marketStatusCard = (id, values) => { const headerClass = marketStatusSegment(values.status); const textClass = headerClass === 'sg-card-segment--gray' ? '' : 'sg-text-on-dark'; const actions = marketEditButton('status', id); const header = '
Status: ' + marketEscape(marketStatusLabel(values.status)) + '
' + actions + '
'; return '
' + header + '
'; };"; echo " const marketEditButton = () => '';"; @@ -1180,6 +1181,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM echo " const deleteMarketOccurrence = async (submit) => { if (submit.disabled) { return; } const card = submit.closest('[data-shared-editable-action-card]'); const input = card?.querySelector('[data-shared-editable-card-delete-input]'); const status = card?.querySelector('[data-market-delete-status]'); const occurrenceId = card?.dataset.marketDeleteOccurrenceId || ''; if (!card || !input || input.value !== 'DELETE' || !occurrenceId) { return; } if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'delete', id: occurrenceId, confirmation: input.value }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } closeMarketDrawer(); if (marketDetailOpen) { restoreMarketList(); } await loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getCurrentLimit() }); } catch (error) { if (status) { status.textContent = marketEscape(error.message); } submit.disabled = false; submit.setAttribute('aria-disabled', 'false'); } };"; echo " const deleteMarketDay = async (submit) => { if (submit.disabled) { return; } const card = submit.closest('[data-shared-editable-action-card]'); const input = card?.querySelector('[data-shared-editable-card-delete-input]'); const status = card?.querySelector('[data-market-day-delete-status]'); const dayId = card?.dataset.marketDayDeleteId || ''; if (!card || !input || input.value !== 'DELETE' || !dayId) { return; } if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'delete_day', id: dayId, confirmation: input.value }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } submit.disabled = false; submit.setAttribute('aria-disabled', 'false'); } };"; echo " const marketDayDeleteObserver = new MutationObserver(() => enhanceMarketDayDeleteControls(contentRoot)); marketDayDeleteObserver.observe(contentRoot, { childList: true, subtree: true });"; + echo " const municipalityFieldObserver = new MutationObserver(() => initializeMunicipalityFields(contentRoot)); municipalityFieldObserver.observe(contentRoot, { childList: true, subtree: true }); initializeMunicipalityFields(contentRoot);"; echo " if (!marketsBindingsInstalled) {"; echo " marketsBindingsInstalled = true;"; echo " document.addEventListener('click', (event) => { const dayAdd = event.target.closest('[data-market-day-add]'); if (dayAdd) { event.preventDefault(); addMarketDayDraft(); return; } const editButton = event.target.closest('[data-shared-editable-card-edit]'); if (editButton) { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); return; } const cancelButton = event.target.closest('[data-shared-editable-card-cancel]'); if (cancelButton) { if (cancelButton.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]')) { window.setTimeout(discardMarketDayDraft, 0); } else { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); } } });"; diff --git a/public/assets/styles.portal.css b/public/assets/styles.portal.css index c559133..0e93315 100644 --- a/public/assets/styles.portal.css +++ b/public/assets/styles.portal.css @@ -358,6 +358,10 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) { display: none; } +.sg-card-list-page-drawer [data-market-new-municipality][hidden] { + display: none; +} + [data-shared-editable-card-read][hidden], [data-shared-editable-card-form][hidden] { display: none;