144 lines
9.6 KiB
PHP
144 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
/**
|
|
* Was angefangen und nicht fertig ist.
|
|
*
|
|
* Eine Liste im Code und nicht in der Datenbank: sie beschreibt den Zustand
|
|
* DIESER Fassung. Ein Punkt verschwindet, wenn die Arbeit im selben Commit
|
|
* fertig wird, der ihn streicht — und damit kann die Liste nicht behaupten,
|
|
* etwas sei offen, das es längst nicht mehr ist. Eine Tabelle könnte das sehr
|
|
* wohl, und niemand pflegt sie.
|
|
*
|
|
* Jeder Eintrag nennt drei Dinge, weil ohne sie niemand damit arbeiten kann:
|
|
* wo es steht, was fehlt, und was passiert, wenn es so bleibt. Der letzte Punkt
|
|
* ist der wichtigste — er entscheidet die Reihenfolge, nicht das Gefühl.
|
|
*/
|
|
final class OpenWork
|
|
{
|
|
/** Kunden zahlen dafür, es passiert aber nichts. */
|
|
public const SOLD = 'sold';
|
|
|
|
/** Gebaut, aber nie unter echten Bedingungen bewiesen. */
|
|
public const UNPROVEN = 'unproven';
|
|
|
|
/** Halb gebaut: ein Teil trägt, ein anderer fehlt. */
|
|
public const PARTIAL = 'partial';
|
|
|
|
/** Beschlossen, noch nicht angefangen. */
|
|
public const PLANNED = 'planned';
|
|
|
|
/**
|
|
* @return list<array{title: string, state: string, where: string, missing: string, risk: string}>
|
|
*/
|
|
public static function all(): array
|
|
{
|
|
return [
|
|
[
|
|
'title' => 'Zweiter Sicherungsort ist aus dem Verkauf genommen',
|
|
'state' => self::PLANNED,
|
|
'where' => 'App\\Services\\Billing\\AddonCatalogue::EXTRA_BACKUPS_ON_SALE',
|
|
'missing' => 'Das Modul kostete 6 € im Monat und versprach „eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert" — es gab dafür aber keinen Provisionierungsschritt, der ein zweites Sicherungsziel anlegt, nur die nächtliche vzdump-Sicherung auf die lokale Ablage des Hosts (App\\Provisioning\\Steps\\Customer\\RegisterBackup). Preistafel und Abrechnungsseite bieten das Modul jetzt nicht mehr an — der Code-Schalter ist die Konstante `EXTRA_BACKUPS_ON_SALE`, eigenständig von `OFFICE_ON_SALE`, weil dieses Modul an einem anderen Server hängt (dem künftigen Speicherserver, nicht dem Dokumentenserver) und darum zu einem anderen Zeitpunkt zurückkommt. Auf der Entwicklungsmaschine enthielt `subscription_addons` zum Zeitpunkt dieser Änderung keine einzige Zeile mit `extra_backups` — ob im Live-Betrieb schon gebucht wurde, ist von hier aus nicht sichtbar; sollte doch, entscheidet der Betreiber, was mit diesen Verträgen geschieht, diese Sperre betrifft nur NEUE Buchungen.',
|
|
'risk' => 'Kein akuter Schaden mehr — weder die Preistafel noch die Abrechnungsseite versprechen etwas, das nicht eingelöst wird. Das zweite Sicherungsziel selbst ist an keiner Stelle im Repository angefangen; das bleibt die Arbeit, die vor dem Wiederanschalten noch aussteht.',
|
|
],
|
|
[
|
|
'title' => 'Office (ONLYOFFICE über einen gemeinsamen Dokumentenserver) ist aus dem Verkauf genommen',
|
|
'state' => self::PLANNED,
|
|
'where' => 'App\\Services\\Billing\\AddonCatalogue::OFFICE_ON_SALE, database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php',
|
|
'missing' => 'Die Preistafel versprach „Office im Browser" an drei Stellen — der Aufzählung „Was enthalten ist", dem Paketmerkmal `office` in Team und Business, und dem Zusatzmodul `collabora_pro` für 22,80 € im Monat —, obwohl weder die goldene Vorlage (deploy/bootstrap/assets/docker-compose.yml enthält nur Nextcloud und die Datenbank) noch ein Bereitstellungsschritt je ein Office installiert haben. Alle drei Stellen sind jetzt stillgelegt, aber nicht gelöscht: der Code-Schalter ist die Konstante `OFFICE_ON_SALE` (dokumentiert an ihrer eigenen Stelle mit den zwei Handgriffen, die ein Wiederanschalten braucht — die Konstante auf true, und in der Konsole eine neue Planversion für Team und Business veröffentlichen, die `office` wieder führt). Wer die beiden Pakete vor dieser Umschaltung gekauft hat, behält `office` in seinem eingefrorenen Vertrag unverändert — dieser Punkt betrifft ausschließlich, was NEU beworben wird. `collabora_pro` hat bislang niemand gebucht (subscription_addons enthielt zum Zeitpunkt dieser Änderung keine einzige Zeile dazu), es gibt also keinen laufenden Vertrag, den eine spätere Entscheidung träfe. Am Tag nach der ersten Schliessung fiel auf, dass sie nur die Preistafel betraf: ein eingeloggter Team- oder Business-Kunde hätte `collabora_pro` weiterhin über die Abrechnungsseite buchen können, weil AddonCatalogue den Preis unverändert kannte und Billing::purchase() nie danach gefragt hat. Die Konstante wanderte deshalb vom LandingController in AddonCatalogue, die einzige Stelle, die beide Seiten lesen — Preistafel UND Kauf-Knopf weisen `collabora_pro` jetzt aus demselben Schalter ab.',
|
|
'risk' => 'Kein akuter Schaden mehr — weder die Preistafel noch die Abrechnungsseite versprechen etwas, das nicht eingelöst wird. Der eigentliche Dokumentenserver ist an keiner Stelle im Repository angefangen; das bleibt die Arbeit, die vor dem Wiederanschalten noch aussteht.',
|
|
],
|
|
[
|
|
'title' => 'Der Rettungssystem-Weg liegt still',
|
|
'state' => self::PARTIAL,
|
|
'where' => 'deploy/bootstrap/clupilot-bootstrap.sh, App\\Livewire\\Admin\\ReissueTakeover',
|
|
'missing' => 'Zwei Wege, einen Host aufzunehmen, existieren nebeneinander: die Provisionierungs-Pipeline auf einem vorinstallierten Debian (das ist der gefahrene) und das Bootstrap-Skript, das im Rettungssystem Proxmox von der ISO auf ZFS legt. Der zweite ist vollständig gebaut und wird nicht benutzt.',
|
|
'risk' => 'Kein akuter Schaden — aber es ist ein zweiter, ungetesteter Pfad, den beim nächsten Umbau jemand für den aktuellen hält.',
|
|
],
|
|
[
|
|
'title' => 'Hostnamen-Abnahme auf der echten Installation steht aus',
|
|
'state' => self::UNPROVEN,
|
|
'where' => 'database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php',
|
|
'missing' => 'Die Migration ist gegen echtes MariaDB in allen Fehlerfällen geprüft — aber auf einer Wegwerf-Datenbank. Die Abnahme aus dem Entwurf (pve-fns-1 wird zu fsn-01, der fsn-Zähler springt auf 2) wurde auf dem Live-Server nie nachgesehen.',
|
|
'risk' => 'Hosts ohne DNS-Namen behalten ihren getippten Namen. Für die zeigt die Detailseite eine Adresse an, die nie jemand veröffentlicht hat.',
|
|
],
|
|
[
|
|
'title' => 'Support-Mail geht nicht raus',
|
|
'state' => self::PARTIAL,
|
|
'where' => 'Konsole → System → E-Mail, Postfach „support"',
|
|
'missing' => 'Das Postfach support@ hat kein eigenes SMTP-Konto und meldet sich deshalb über das no-reply-Konto an. Der Server weist den Absender ab (553). Betroffen ist alles mit Support-Zweck — auch die Antwort an einen Kunden aus der Konsole.',
|
|
'risk' => 'Website-Anfragen landen im Posteingang der Konsole und sind nicht verloren, aber niemand wird benachrichtigt. Kundenantworten aus der Konsole kommen gar nicht an.',
|
|
],
|
|
[
|
|
'title' => 'Erklärungs-Modal je Zusatzmodul',
|
|
'state' => self::PLANNED,
|
|
'where' => 'resources/views/livewire/billing.blade.php, landing.blade.php',
|
|
'missing' => 'Zu jedem Modul soll ein Modal erklären, warum es das Modul gibt und worauf es aufbaut, damit „mal dabei, mal dazubuchbar" keine Frage mehr aufwirft. Sinnvoll erst, wenn die zwei nicht gelieferten Module etwas tun.',
|
|
'risk' => 'Ohne die Erklärung bleibt die Preistafel an der Stelle missverständlich, an der sie am meisten kostet.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Die Zustände nach Dringlichkeit, und das ist keine Geschmacksfrage.
|
|
*
|
|
* Sortiert wird danach, was ein offener Punkt HEUTE anrichtet: verkauft und
|
|
* nicht geliefert kostet in diesem Moment Geld und Vertrauen. Halb gebaut
|
|
* kann jederzeit jemandem vor die Füße fallen. Ungeprüft ist ein Risiko,
|
|
* das noch niemand gesehen hat. Beschlossen tut gar nichts, bis jemand
|
|
* anfängt.
|
|
*
|
|
* Ohne diese Reihenfolge stand die Liste in der Reihenfolge, in der sie
|
|
* geschrieben wurde — also zufällig.
|
|
*/
|
|
public const ORDER = [self::SOLD, self::PARTIAL, self::UNPROVEN, self::PLANNED];
|
|
|
|
/**
|
|
* Nach Zustand gruppiert, dringendste Gruppe zuerst. Leere Gruppen fallen
|
|
* weg — eine Überschrift ohne Inhalt liest sich wie ein Fehler.
|
|
*
|
|
* @return array<string, list<array<string, string>>>
|
|
*/
|
|
public static function grouped(): array
|
|
{
|
|
$groups = [];
|
|
|
|
foreach (self::ORDER as $state) {
|
|
$items = array_values(array_filter(self::all(), fn (array $i) => $i['state'] === $state));
|
|
|
|
if ($items !== []) {
|
|
$groups[$state] = $items;
|
|
}
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
/** Der dringendste Zustand, der gerade vorkommt — oder null, wenn nichts offen ist. */
|
|
public static function worst(): ?string
|
|
{
|
|
foreach (self::ORDER as $state) {
|
|
foreach (self::all() as $item) {
|
|
if ($item['state'] === $state) {
|
|
return $state;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** @return array<string, int> Zustand => Anzahl */
|
|
public static function counts(): array
|
|
{
|
|
$counts = [];
|
|
|
|
foreach (self::all() as $item) {
|
|
$counts[$item['state']] = ($counts[$item['state']] ?? 0) + 1;
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
}
|