diff --git a/docs/modules/market_management/processes/main/market_management.occurrence_status_completion.md b/docs/modules/market_management/processes/main/market_management.occurrence_status_completion.md new file mode 100644 index 0000000..6482b07 --- /dev/null +++ b/docs/modules/market_management/processes/main/market_management.occurrence_status_completion.md @@ -0,0 +1,111 @@ +# market_management.occurrence_status_completion +Stand: 2026-09-19 +Status: Verbindliche Prozess-Spezifikation + +## 1. Zweck + +Setzt aktive Durchführungen am täglichen Cron-Lauf automatisch auf +`completed`, sobald der Kalendertag nach ihrem letzten Durchführungstag erreicht +ist. Abgesagte Durchführungen werden nicht verändert. + +## 2. Prozess-Einbettung + +Täglicher synchroner Cron-Teilprozess des zentralen System-Cron-Entrypoints. +Die fachliche Statusänderung gehört zum owning Modul `market_management`. + +## 3. Input + +- aktuelles Datum in der Zeitzone `Europe/Zurich` +- keine vorbereiteten Fachdaten; der Prozess liest seine Daten selbst aus der + Datenbank + +## 4. Exakter Prozessablauf + +1. Einen technischen Lauf in `public.process_runs` mit dem aktuellen Datum + starten. +2. Aus `market_occurrence` und `market_occurrence_day` aktive Durchführungen + mit mindestens einem Tag bestimmen. +3. Durchführungen ausschliessen, deren Status `cancelled` oder bereits + `completed` ist. +4. Für jede verbleibende Durchführung den letzten Durchführungstag bestimmen. +5. Wenn dieser Tag vor dem aktuellen Datum liegt, den Status atomar auf + `completed` setzen. +6. Den technischen Lauf mit Anzahl und IDs der geänderten Durchführungen + abschliessen. + +Gelesen werden `market_occurrence` und `market_occurrence_day`. Geschrieben +werden `market_occurrence.status`, `market_occurrence.updated_at` und der +technische Lauf in `public.process_runs`. + +## 5. Batch, Betriebsmodell und Einbettung + +Ein täglicher synchroner Cron-Lauf; ein SQL-Update verarbeitet den vollständigen +aktuellen Scope atomar. Die bestehende Cron-Sperre verhindert parallele Läufe. + +## 6. Output + +Ein kompakter Laufstatus mit aktuellem Datum, Anzahl und IDs der auf +`completed` gesetzten Durchführungen sowie der technischen Lauf-ID. + +## 7. Erfolgskriterien + +- Eine Durchführung wird am Tag nach ihrem letzten Durchführungstag auf + `completed` gesetzt. +- Abgesagte und bereits abgeschlossene Durchführungen bleiben unverändert. +- Durchführungen ohne Durchführungstag werden nicht verändert. +- Wiederholte Cron-Läufe sind idempotent. +- `public.process_runs` enthält einen abgeschlossenen technischen Lauf. + +## 8. Fehlerschranke + +Ein technischer Fehler bricht den Lauf ab, rollt die fachliche Transaktion +zurück und markiert `process_runs` als `failed`. + +## 9. Fachliche Betriebsregel + +Die Prüfung erfolgt einmal täglich über den bestehenden Cron. Die Statusänderung +ist unabhängig von GUI-Aufrufen und OTC-Bestellungen. + +## 10. Sub-Prozess-Referenzen + +Keine zusätzlichen Sub-Prozesse. + +## 11. End-to-End Sub-Prozess-Kette + +System-Cron → Statusabschlussprozess → `market_occurrence` und +`public.process_runs`. + +## 12. Sub-Prozess-Wiederverwendung + +Der bestehende System-Cron-Entrypoint und die bestehende Cron-Sperre werden +wiederverwendet. Ein zusätzlicher Scheduler wird nicht eingeführt. + +## 13. Step-Liste + +- Lauf starten +- eligible Durchführungen bestimmen und abschliessen +- Laufstatus persistieren + +## 14. Parallelisierung + +Keine Parallelisierung; der deterministische SQL-Batch läuft synchron und +atomar. + +## 15. Output-Payload + +```json +{ + "status": "done", + "as_of_date": "YYYY-MM-DD", + "completed_count": 0, + "completed_occurrence_ids": [], + "technical_run_id": 0 +} +``` + +## 16. Done-Kriterien + +- Der tägliche Cron ruft den Prozess genau einmal auf. +- Die Statusregel ist serverseitig implementiert. +- Der Prozess schreibt `process_runs` vollständig. +- Wiederholte Läufe erzeugen keine erneuten Statusänderungen. diff --git a/modules/market_management/service.php b/modules/market_management/service.php index ef7cc39..23cc621 100644 --- a/modules/market_management/service.php +++ b/modules/market_management/service.php @@ -123,6 +123,91 @@ function market_management_category_exists(PDO $pdo, int $categoryId): bool 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'] ?? '')); diff --git a/modules/system/naurua-erp-cron.php b/modules/system/naurua-erp-cron.php index 3dd2235..4f51eae 100644 --- a/modules/system/naurua-erp-cron.php +++ b/modules/system/naurua-erp-cron.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../shared/db.php'; require_once __DIR__ . '/../erp/lager/forecast.php'; require_once __DIR__ . '/../erp/import-integration/shopify-webhook-worker.php'; require_once __DIR__ . '/../erp/import-integration/shopify-delta-run.php'; +require_once __DIR__ . '/../market_management/service.php'; if (PHP_SAPI !== 'cli') { http_response_code(404); @@ -42,6 +43,7 @@ try { } }, 'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo), + 'market_management.occurrence_status_completion' => static fn (): array => market_management_auto_complete_occurrences($pdo), ]; $results = []; foreach ($jobs as $name => $job) {