From 65b98ace896a3591e072242e8a1c8d8d42b32eca Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 24 Aug 2026 11:56:08 +0200 Subject: [PATCH] Die Uhr laeuft erst, wenn die Anzahlung da ist Bisher begann der Liefertermin mit der Annahme. Das ist unfair in beide Richtungen: Wer zehn Tage bis zur Zahlung braucht, verbraucht zehn Tage der zugesagten Zeit, ohne dass ein Handschlag Arbeit passiert waere -- und ich stehe am Ende als der da, der seinen Termin reisst. Ein Projekt hat jetzt drei Abschnitte statt zwei: 1. angenommen, wartet auf Anzahlung -> Uhr steht 2. Anzahlung da -> Uhr laeuft, Termin ab HEUTE neu 3. uebergeben oder abgebrochen -> Uhr steht wieder Der Zahlungseingang loest alles Weitere von selbst aus: Uhr starten, Termin neu rechnen, Status von briefing auf design, 'wer ist am Zug' auf mich, Benachrichtigung in der Verwaltung. Der bei der Annahme genannte Termin bleibt als termin_geplant_am erhalten, und die Meldung nennt BEIDE -- so sieht man, dass sich etwas verschoben hat, ohne nachrechnen zu muessen. Eingehaengt an der Stelle, an der beide Wege zusammenlaufen (PayPals Meldung UND das Vermerken von Hand). Nur am Webhook haenge sich die Seite verschieden verhalten, je nachdem WIE das Geld ankam -- eine von Hand verbuchte Zahlung startete die Uhr nie. Zwei Grundsaetze fuer die Automatik: Sie setzt Dinge in Gang, nimmt aber nie eine Entscheidung zurueck, die ein Mensch getroffen hat (ein von Hand pausiertes Projekt wird nicht kommentarlos wieder gestartet). Und jeder Schritt hinterlaesst eine Spur im Verlauf UND als Meldung -- eine Automatik, die stillschweigend arbeitet, ist kein Helfer, sondern ein Raetsel. ABBRECHEN Ein angenommener Auftrag bleibt abbrechbar: Der Kunde zahlt nicht, meldet sich nicht, springt ab. Vorschau und Ausfuehrung sind getrennt -- die Seite rechnet aus dem Aufgabenfortschritt vor, wie viel Leistung erbracht wurde, und schlaegt daraus einen Erstattungsbetrag vor. Der Betrag ist ein VORSCHLAG: Ob im Einzelfall mehr oder weniger angemessen ist, haengt an Dingen, die keine Tabelle kennt. Offene Rechnungen werden storniert (eine Zahlungsaufforderung ohne Gegenleistung), bezahlte bleiben unangetastet, und die Rueckzahlung loest die Seite bewusst NICHT selbst aus -- PayPal-Rueckzahlungen sind nicht umkehrbar. BENACHRICHTIGUNGEN Eigene Tabelle statt im Verlauf: Der Verlauf haelt fest, WAS geschehen ist -- vollstaendig, zum Nachschlagen. Eine Benachrichtigung ist ein Anstupsen, das gelesen und weggelegt wird. Beides in einer Tabelle hiesse: entweder ein Verlauf voller Rauschen oder Meldungen, die man nicht wegklicken kann. Wegklicken markiert nur als gelesen, loescht nichts. Dazu die Liste der Projekte, die seit ueber einer Woche auf ihre Anzahlung warten. Sie stehen in keiner anderen Zahl, weil ihre Uhr nie zu laufen begann -- ohne diesen Hinweis vergisst man sie. Geprueft: 44 gegen eine echte Datenbank. Darunter der Kern -- die Annahme wird zehn Tage zurueckdatiert, und der Termin muss danach trotzdem volle 20 Werktage entfernt liegen. Beim Bauen des Tests selbst ein Fehler gefunden: Die erste Fassung datierte nur die Annahme zurueck, nicht den damals errechneten Termin, und bildete damit genau den Fall nicht ab, um den es geht. Co-Authored-By: Claude Opus 5 --- .../migrations/0016_webdesign_automatik.sql | 80 ++++++ server-internal/db.js | 3 + server-internal/index.js | 6 +- server-internal/lib/webdesign-automatik.js | 237 ++++++++++++++++++ server-internal/routes/webdesign-annahme.js | 139 ++++++++++ server-internal/routes/webdesign-zahlungen.js | 66 ++++- server-internal/test-automatik.mjs | 226 +++++++++++++++++ webdesign/sw.js | 2 +- webdesign/verwaltung.html | 105 +++++++- 9 files changed, 852 insertions(+), 12 deletions(-) create mode 100644 cloudflare-worker/migrations/0016_webdesign_automatik.sql create mode 100644 server-internal/lib/webdesign-automatik.js create mode 100644 server-internal/test-automatik.mjs diff --git a/cloudflare-worker/migrations/0016_webdesign_automatik.sql b/cloudflare-worker/migrations/0016_webdesign_automatik.sql new file mode 100644 index 00000000..5fc6c029 --- /dev/null +++ b/cloudflare-worker/migrations/0016_webdesign_automatik.sql @@ -0,0 +1,80 @@ +-- ===================================================================== +-- Die Uhr läuft erst, wenn die Anzahlung da ist +-- +-- WARUM DAS EIN EIGENER ZUSTAND IST +-- +-- Bisher begann der Liefertermin mit der Annahme. Das ist unfair in +-- beide Richtungen: Wer zehn Tage bis zur Zahlung braucht, verbraucht +-- zehn Tage der zugesagten Zeit, ohne dass ein Handschlag Arbeit +-- passiert wäre — und ich stehe am Ende als der da, der seinen Termin +-- reisst. +-- +-- Deshalb hat ein Projekt jetzt drei Abschnitte statt zwei: +-- 1. angenommen, wartet auf Anzahlung → Uhr steht +-- 2. Anzahlung da → Uhr läuft, Termin ab HEUTE +-- 3. übergeben oder abgebrochen → Uhr steht wieder +-- +-- `termin_am` wird beim Zahlungseingang NEU berechnet. Es wäre bequemer, +-- den bei der Annahme errechneten Termin stehenzulassen — aber dann wäre +-- die Zusage falsch, sobald jemand eine Woche mit der Zahlung wartet. +-- Der bei der Annahme genannte Termin bleibt als `termin_geplant_am` +-- erhalten, damit man sieht, was ursprünglich in Aussicht stand. +-- ===================================================================== + +ALTER TABLE wd_projekte ADD COLUMN uhr_start_am TEXT; +ALTER TABLE wd_projekte ADD COLUMN termin_geplant_am TEXT; + +-- --------------------------------------------------------------------- +-- Abbruch +-- +-- Ein angenommener Auftrag muss abbrechbar bleiben: Der Kunde zahlt +-- nicht, meldet sich nicht mehr, oder springt selbst ab. Ohne diesen +-- Weg bliebe das Projekt für immer offen in der Liste stehen und +-- verfälschte jede Zahl im Cockpit. +-- +-- Der Erstattungsbetrag wird festgehalten, nicht nur die Entscheidung: +-- Bei einem Streit zählt, WAS erstattet wurde und WARUM — und der +-- Fortschritt zum Zeitpunkt des Abbruchs ist die Begründung dafür. +-- --------------------------------------------------------------------- +ALTER TABLE wd_projekte ADD COLUMN abbruch_am TEXT; +ALTER TABLE wd_projekte ADD COLUMN abbruch_grund TEXT; +ALTER TABLE wd_projekte ADD COLUMN abbruch_wer TEXT; -- kunde | dogfather +ALTER TABLE wd_projekte ADD COLUMN abbruch_erstattung_cent INTEGER; +ALTER TABLE wd_projekte ADD COLUMN abbruch_fortschritt INTEGER; -- Prozent bei Abbruch + +-- ===================================================================== +-- Benachrichtigungen für die Verwaltung +-- +-- WARUM EINE EIGENE TABELLE UND NICHT DER VERLAUF +-- +-- `wd_verlauf` gibt es schon, aber er hat einen anderen Zweck: Er hält +-- fest, WAS geschehen ist — vollständig, für später, zum Nachschlagen. +-- Eine Benachrichtigung ist etwas anderes: Sie ist ein Anstupsen, das +-- gelesen und dann weggelegt wird. +-- +-- Beides in eine Tabelle zu werfen hiesse: Entweder man markiert jeden +-- Verlaufseintrag als gelesen (dann ist der Verlauf voller Rauschen), +-- oder man kann Benachrichtigungen nicht wegklicken (dann sammeln sie +-- sich, bis niemand mehr hinsieht). Getrennt kann jedes für sich das +-- Richtige tun. +-- ===================================================================== +CREATE TABLE IF NOT EXISTS wd_meldungen ( + id TEXT PRIMARY KEY, + art TEXT NOT NULL, -- anzahlung_da | restzahlung_da | zusage | widerruf | zahlung_faellig + titel TEXT NOT NULL, + text TEXT, + -- Wohin man springt, wenn man draufdrückt. Eine Benachrichtigung, die + -- nur meldet und nicht hinführt, erzeugt Arbeit statt sie abzunehmen. + ziel TEXT, -- projekte | zahlungen | anfragen | postfach + ziel_id TEXT, + dringend INTEGER NOT NULL DEFAULT 0, + gelesen INTEGER NOT NULL DEFAULT 0, + gelesen_am TEXT, + erstellt_am TEXT NOT NULL +); + +-- Ungelesene zuerst, neueste oben: genau die Reihenfolge, in der sie +-- gelesen werden. Ohne Index wird das bei jedem Laden der Übersicht neu +-- sortiert. +CREATE INDEX IF NOT EXISTS idx_wd_meldungen_offen + ON wd_meldungen (gelesen, erstellt_am DESC); diff --git a/server-internal/db.js b/server-internal/db.js index 2cece8eb..f859988c 100644 --- a/server-internal/db.js +++ b/server-internal/db.js @@ -57,6 +57,9 @@ const MIGRATION_FILES = [ // Annehmen und Ablehnen einer Anfrage: echter Liefertermin (termin_am) // fuer den Countdown, Zeitpunkt der Annahme, Ablehnungsgrund. "0015_webdesign_annahme.sql", + // Die Uhr laeuft erst bei Anzahlung; Abbruch mit Erstattung; + // Benachrichtigungen fuer die Verwaltung. + "0016_webdesign_automatik.sql", ]; function alreadyApplied(name) { diff --git a/server-internal/index.js b/server-internal/index.js index 1b0133a7..e1801786 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -124,10 +124,11 @@ import { import { zahlungAnsehen, zahlungStarten, zahlungAbschliessen, paypalWebhook, zahlungAnlegen, zahlungenListe, - einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht, + einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht, meldungGelesen, } from "./routes/webdesign-zahlungen.js"; import { annahmeVorschau, anfrageAnnehmen, absageVorschau, anfrageAblehnen, + abbruchVorschau, projektAbbrechen, } from "./routes/webdesign-annahme.js"; import { suche } from "./routes/webdesign-suche.js"; /* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen, @@ -363,6 +364,9 @@ app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe)); app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen)); app.get("/webdesign/admin/uebersicht", wrap(uebersicht)); app.get("/webdesign/admin/suche", wrap(suche)); +app.post("/webdesign/admin/meldungen/:id/gelesen", wrap(meldungGelesen)); +app.get("/webdesign/admin/projekte/:id/abbruch-vorschau", wrap(abbruchVorschau)); +app.post("/webdesign/admin/projekte/:id/abbrechen", wrap(projektAbbrechen)); app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen)); app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben)); app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen)); diff --git a/server-internal/lib/webdesign-automatik.js b/server-internal/lib/webdesign-automatik.js new file mode 100644 index 00000000..59c4e6a9 --- /dev/null +++ b/server-internal/lib/webdesign-automatik.js @@ -0,0 +1,237 @@ +/* ===================================================================== + webdesign-automatik.js — was von selbst passieren soll + + Der Leitgedanke: Ich soll nichts von Hand nachtragen müssen, was die + Seite selbst wissen kann. Eine Zahlung geht ein — dann muss niemand + daran denken, die Uhr zu starten, den Status umzustellen und sich + eine Notiz zu machen. Das passiert, weil es passiert ist. + + ZWEI REGELN, DIE DAS ERTRÄGLICH MACHEN + + 1. AUTOMATIK NUR IN EINE RICHTUNG. + Sie setzt Dinge in Gang, nimmt aber nie eine Entscheidung zurück, + die ein Mensch getroffen hat. Wer den Status von Hand auf + "pausiert" stellt, will das so — dann darf eine eingehende Zahlung + ihn nicht kommentarlos wieder auf "Design" schieben. + + 2. JEDER SCHRITT HINTERLÄSST EINE SPUR. + Alles, was hier von selbst geschieht, steht danach im Verlauf UND + als Benachrichtigung. Eine Automatik, die stillschweigend arbeitet, + ist keine Hilfe, sondern ein Rätsel: Man sieht ein verändertes + Projekt und weiss nicht, wer es angefasst hat. + ===================================================================== */ +import { db } from "../db.js"; +import { jetzt, neueId, verlauf, centZuText } from "./webdesign-helfer.js"; +import { terminVorschlag, laufzeitFuer, hatLiefertermin } from "./webdesign-termine.js"; + +/* --------------------------------------------------------------------- + BENACHRICHTIGUNGEN + --------------------------------------------------------------------- */ +export function meldungAnlegen({ art, titel, text, ziel, zielId, dringend }) { + const id = neueId(); + db.prepare( + `INSERT INTO wd_meldungen (id, art, titel, text, ziel, ziel_id, dringend, gelesen, erstellt_am) + VALUES (?,?,?,?,?,?,?,0,?)` + ).run(id, art, titel, text || null, ziel || null, zielId || null, dringend ? 1 : 0, jetzt()); + return id; +} + +export function meldungenLesen(nurOffene = true, grenze = 20) { + return db.prepare( + `SELECT id, art, titel, text, ziel, ziel_id, dringend, gelesen, erstellt_am + FROM wd_meldungen ${nurOffene ? "WHERE gelesen = 0" : ""} + ORDER BY gelesen ASC, erstellt_am DESC LIMIT ?` + ).all(grenze); +} + +export function meldungenOffen() { + return db.prepare(`SELECT COUNT(*) AS n FROM wd_meldungen WHERE gelesen = 0`).get().n; +} + +/* --------------------------------------------------------------------- + DIE UHR STARTEN + + Wird aufgerufen, wenn eine Zahlung als bezahlt vermerkt wird — egal + ob PayPal das gemeldet hat oder ich es von Hand eingetragen habe. + Beide Wege müssen dasselbe auslösen, sonst hinge das Verhalten davon + ab, WIE das Geld ankam. + + Rückgabe: was tatsächlich geschehen ist, damit der Aufrufer es + weitermelden kann. Ein stilles `void` würde bedeuten, dass die + Oberfläche raten muss, ob etwas passiert ist. + --------------------------------------------------------------------- */ +export function zahlungVerbucht(zahlungId, wer) { + const z = db.prepare( + `SELECT id, nummer, art, projekt_id, kunde_id, betrag_cent, waehrung + FROM wd_zahlungen WHERE id = ?` + ).get(zahlungId); + if (!z) return { getan: [] }; + + const getan = []; + const kunde = z.kunde_id + ? db.prepare(`SELECT name FROM wd_kunden WHERE id = ?`).get(z.kunde_id) + : null; + const wieViel = centZuText(z.betrag_cent, z.waehrung); + + /* Ohne Projekt gibt es keine Uhr — etwa bei einer laufenden Betreuung. + Gemeldet wird trotzdem: Geld, das eingeht, will man sehen. */ + if (!z.projekt_id) { + meldungAnlegen({ + art: "zahlung_da", + titel: wieViel + " eingegangen", + text: (kunde ? kunde.name + " · " : "") + (z.nummer || ""), + ziel: "zahlungen", zielId: z.id, + }); + return { getan: ["gemeldet"] }; + } + + const p = db.prepare( + `SELECT id, nummer, titel, paket, status, uhr_start_am, termin_am, termin_geplant_am + FROM wd_projekte WHERE id = ?` + ).get(z.projekt_id); + if (!p) return { getan: [] }; + + /* ---- Ist das die Anzahlung, und steht die Uhr noch? ---- */ + const istAnzahlung = z.art === "anzahlung"; + const uhrStehtNoch = !p.uhr_start_am; + /* Ein abgebrochenes oder uebergebenes Projekt startet nicht mehr -- + eine verspaetet verbuchte Zahlung darf kein totes Projekt + wiederbeleben. */ + const laeuftNoch = p.status !== "abgebrochen" && p.status !== "uebergeben"; + + if (istAnzahlung && uhrStehtNoch && laeuftNoch) { + const start = jetzt(); + /* Der Termin wird ab HEUTE neu gerechnet, nicht ab der Annahme. + + Sonst verbraucht ein Kunde, der zehn Tage bis zur Zahlung + braucht, zehn Tage der zugesagten Zeit -- und ich stehe am Ende + als der da, der seinen Termin reisst. Der urspruenglich genannte + Termin bleibt als termin_geplant_am erhalten, damit man den + Unterschied sieht. */ + const neuerTermin = hatLiefertermin(p.paket) + ? terminVorschlag(p.paket, new Date()).termin + : null; + + db.prepare( + `UPDATE wd_projekte + SET uhr_start_am = ?, start_am = ?, + termin_geplant_am = COALESCE(termin_geplant_am, termin_am), + termin_am = COALESCE(?, termin_am), + anzahlung_bezahlt = 1, + status = CASE WHEN status = 'briefing' THEN 'design' ELSE status END, + wartet_auf = 'dogfather', + naechster_schritt = CASE + WHEN naechster_schritt IS NULL OR naechster_schritt = '' + OR naechster_schritt LIKE 'Unterlagen%' + THEN 'Es geht los — erste Entwürfe' + ELSE naechster_schritt END, + aktualisiert_am = ? + WHERE id = ?` + ).run(start, start, neuerTermin, jetzt(), p.id); + + getan.push("uhr_gestartet"); + verlauf("projekt", p.id, "uhr_gestartet", wer || "system", + `Anzahlung ${wieViel} verbucht` + (neuerTermin ? ` · Liefertermin ${neuerTermin}` : "")); + + meldungAnlegen({ + art: "anzahlung_da", + titel: "Anzahlung da — " + p.nummer + " läuft", + text: (kunde ? kunde.name + ": " : "") + wieViel + " eingegangen." + + (neuerTermin ? " Liefertermin " + neuerTermin + "." : "") + + (p.termin_am && neuerTermin && p.termin_am !== neuerTermin + ? " (bei der Annahme war " + p.termin_am + " vorgesehen)" : ""), + ziel: "projekte", zielId: p.id, + dringend: 1, + }); + return { getan, projektId: p.id, terminNeu: neuerTermin }; + } + + /* ---- Restzahlung oder eine weitere Zahlung ---- */ + if (z.art === "rest") { + db.prepare(`UPDATE wd_projekte SET rest_bezahlt = 1, aktualisiert_am = ? WHERE id = ?`) + .run(jetzt(), p.id); + getan.push("rest_vermerkt"); + } + meldungAnlegen({ + art: "zahlung_da", + titel: wieViel + " eingegangen — " + p.nummer, + text: (kunde ? kunde.name + " · " : "") + (z.nummer || ""), + ziel: "projekte", zielId: p.id, + }); + getan.push("gemeldet"); + return { getan, projektId: p.id }; +} + +/* --------------------------------------------------------------------- + ERSTATTUNGSVORSCHLAG BEIM ABBRUCH + + Rechnet vor, statt zu entscheiden. Der Vorschlag ergibt sich aus dem + Anteil erledigter Arbeitsschritte: Wer bei 40 % abbricht, hat 40 % + der Leistung bekommen. + + Bewusst ein VORSCHLAG und keine Regel. Ob im Einzelfall mehr oder + weniger angemessen ist, hängt an Dingen, die keine Tabelle kennt -- + wer abgebrochen hat, ob überhaupt mitgewirkt wurde, wie lange + gewartet wurde. Die Zahl nimmt einem das Rechnen ab, nicht das + Urteilen. + + Der Fortschritt zählt nur Aufgaben, die nicht "entfaellt" sind: + Gestrichene Punkte sind keine erbrachte Leistung, würden die Quote + aber schönen. + --------------------------------------------------------------------- */ +export function erstattungsVorschlag(projektId) { + const p = db.prepare( + `SELECT id, preis_cent, anzahlung_cent, waehrung FROM wd_projekte WHERE id = ?` + ).get(projektId); + if (!p) return null; + + const a = db.prepare( + `SELECT + COUNT(*) AS gesamt, + SUM(CASE WHEN status = 'erledigt' THEN 1 ELSE 0 END) AS fertig + FROM wd_aufgaben + WHERE projekt_id = ? AND status <> 'entfaellt' AND nicht_enthalten = 0` + ).get(projektId); + + const gesamt = a.gesamt || 0; + const fertig = a.fertig || 0; + const prozent = gesamt ? Math.round((fertig / gesamt) * 100) : 0; + + /* Bezahlt ist, was tatsächlich verbucht wurde -- nicht, was + vorgesehen war. Ein Projekt kann angenommen sein, ohne dass je + Geld floss. */ + const bezahlt = db.prepare( + `SELECT COALESCE(SUM(betrag_cent),0) AS n FROM wd_zahlungen + WHERE projekt_id = ? AND status = 'bezahlt'` + ).get(projektId).n; + + /* Wert der erbrachten Leistung, gedeckelt auf das, was gezahlt wurde: + Mehr als vorhanden kann man nicht behalten. */ + const verdient = Math.min(bezahlt, Math.round((p.preis_cent || 0) * prozent / 100)); + const erstattung = Math.max(0, bezahlt - verdient); + + return { + prozent, fertig, gesamt, + bezahltCent: bezahlt, bezahltText: centZuText(bezahlt, p.waehrung), + behaltenCent: verdient, behaltenText: centZuText(verdient, p.waehrung), + erstattungCent: erstattung, erstattungText: centZuText(erstattung, p.waehrung), + }; +} + +/* Wie viel Zeit ist seit der Annahme vergangen, ohne dass gezahlt wurde? + + Gebraucht für die Erinnerung: Ein Projekt, das seit zwei Wochen auf + eine Anzahlung wartet, ist meistens keins mehr -- man will das sehen, + bevor man es vergisst. */ +export function wartetAufAnzahlung(tageGrenze = 7) { + return db.prepare( + `SELECT p.id, p.nummer, p.titel, p.angenommen_am, k.name AS kunde_name + FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id + WHERE p.archiviert = 0 + AND p.uhr_start_am IS NULL + AND p.status NOT IN ('abgebrochen','uebergeben') + AND p.angenommen_am IS NOT NULL + AND julianday('now') - julianday(p.angenommen_am) >= ? + ORDER BY p.angenommen_am ASC` + ).all(tageGrenze); +} diff --git a/server-internal/routes/webdesign-annahme.js b/server-internal/routes/webdesign-annahme.js index 002b181b..f8797351 100644 --- a/server-internal/routes/webdesign-annahme.js +++ b/server-internal/routes/webdesign-annahme.js @@ -40,6 +40,7 @@ import { terminVorschlag, werktageBis, laufzeitFuer, hatLiefertermin } from "../lib/webdesign-termine.js"; import { vorlageAnwenden } from "./webdesign-aufgaben.js"; import { einladungAnlegen, einladungsLink } from "./webdesign-kunden.js"; +import { erstattungsVorschlag, meldungAnlegen } from "../lib/webdesign-automatik.js"; async function darf(req, recht) { return darfWebdesign(req, recht, async () => { @@ -484,3 +485,141 @@ export async function anfrageAblehnen(req, res) { : null, }); } + +/* ===================================================================== + AUFTRAG ABBRECHEN + + Ein angenommener Auftrag muss abbrechbar bleiben. Der Kunde zahlt + nicht, meldet sich nicht mehr, springt ab — ohne diesen Weg bliebe + das Projekt für immer offen in der Liste und verfälschte jede Zahl im + Cockpit. + + ZWEI SCHRITTE, BEWUSST GETRENNT + + `abbruchVorschau` rechnet vor, `projektAbbrechen` führt aus. Ein + Abbruch ist endgültig und berührt Geld — man soll SEHEN, wie viel + Arbeit erledigt ist und welcher Betrag sich daraus ergibt, bevor man + entscheidet. Ein Dialog, der nur "wirklich abbrechen?" fragt, liefert + für diese Entscheidung keine Grundlage. + + Der Betrag ist ein VORSCHLAG. Ob im Einzelfall mehr oder weniger + angemessen ist, hängt an Dingen, die keine Tabelle kennt: wer + abgebrochen hat, ob überhaupt mitgewirkt wurde, wie lange gewartet + wurde. Die Zahl nimmt das Rechnen ab, nicht das Urteilen. + ===================================================================== */ +export const ABBRUCH_GRUENDE = [ + "keine_zahlung", "kunde_abgesprungen", "keine_mitwirkung", + "einvernehmlich", "mein_ruecktritt", "sonstiges", +]; + +export async function abbruchVorschau(req, res) { + const p = await darf(req, "WD_PROJEKTE_MANAGE"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const projekt = db.prepare( + `SELECT p.*, k.name AS kunde_name FROM wd_projekte p + JOIN wd_kunden k ON k.id = p.kunde_id WHERE p.id = ?` + ).get(String(req.params.id || "")); + if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404); + + return json(res, { + ok: true, + projekt: { + id: projekt.id, nummer: projekt.nummer, titel: projekt.titel, + kunde: projekt.kunde_name, status: projekt.status, + uhrLaeuft: !!projekt.uhr_start_am, + schonAbgebrochen: !!projekt.abbruch_am, + }, + rechnung: erstattungsVorschlag(projekt.id), + }); +} + +export async function projektAbbrechen(req, res) { + const p = await darf(req, "WD_PROJEKTE_MANAGE"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const projekt = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`) + .get(String(req.params.id || "")); + if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404); + + /* Zweimal abbrechen geht nicht. Wie beim Annehmen kommt dieselbe + Antwort zurück statt eines Fehlers -- das Ergebnis ist ja dasselbe. */ + if (projekt.abbruch_am) { + return json(res, { ok: true, bereitsAbgebrochen: true, + hinweis: "Dieses Projekt war bereits abgebrochen." }); + } + if (projekt.status === "uebergeben") { + return json(res, { ok: false, + error: "Dieses Projekt ist bereits übergeben. Ein abgeschlossener Auftrag lässt sich nicht mehr abbrechen." }, 409); + } + + const grund = ABBRUCH_GRUENDE.includes(text(req.body?.grund, 40)) + ? text(req.body?.grund, 40) : "sonstiges"; + const wer = req.body?.wer === "kunde" ? "kunde" : "dogfather"; + const notiz = text(req.body?.notiz, 1000) || null; + + const rechnung = erstattungsVorschlag(projekt.id); + /* Der Betrag kommt aus dem Formular, NICHT aus der Rechnung. + + Die Rechnung ist ein Vorschlag; entschieden hat der Mensch. Würde + hier stillschweigend der berechnete Wert genommen, wäre die + Eingabemöglichkeit im Dialog eine Attrappe -- und der Betrag im + Nachhinein nicht der, den man gesehen und bestätigt hat. */ + const gewuenscht = Number(req.body?.erstattungEuro); + const erstattungCent = Number.isFinite(gewuenscht) && gewuenscht >= 0 + ? Math.round(gewuenscht * 100) + : (rechnung ? rechnung.erstattungCent : 0); + + db.transaction(() => { + db.prepare( + `UPDATE wd_projekte + SET status = 'abgebrochen', abbruch_am = ?, abbruch_grund = ?, abbruch_wer = ?, + abbruch_erstattung_cent = ?, abbruch_fortschritt = ?, + /* Die Uhr steht ab jetzt. uhr_start_am bleibt stehen, damit + man später sieht, wie lange gelaufen wurde. */ + wartet_auf = 'niemand', + naechster_schritt = ?, + aktualisiert_am = ? + WHERE id = ?` + ).run(jetzt(), grund, wer, erstattungCent, + rechnung ? rechnung.prozent : null, + "Abgebrochen" + (notiz ? " — " + notiz.slice(0, 200) : ""), + jetzt(), projekt.id); + + /* Offene Zahlungen zurückziehen. Eine Rechnung, die zu einem + abgebrochenen Projekt weiter im Portal steht, ist eine + Zahlungsaufforderung ohne Gegenleistung -- und der Kunde würde + zurecht fragen, wofür. */ + db.prepare( + `UPDATE wd_zahlungen SET status = 'storniert', aktualisiert_am = ? + WHERE projekt_id = ? AND status IN ('offen','freigegeben')` + ).run(jetzt(), projekt.id); + })(); + + verlauf("projekt", projekt.id, "abgebrochen", werIst(p), + `${grund} · durch ${wer}` + + (erstattungCent ? ` · Erstattung ${(erstattungCent / 100).toFixed(2)} €` : " · keine Erstattung")); + + meldungAnlegen({ + art: "abgebrochen", + titel: projekt.nummer + " abgebrochen", + text: `Grund: ${grund}` + + (erstattungCent ? ` · ${(erstattungCent / 100).toFixed(2)} € zu erstatten` : " · keine Erstattung"), + ziel: "projekte", zielId: projekt.id, + dringend: erstattungCent > 0 ? 1 : 0, + }); + + return json(res, { + ok: true, + grund, wer, + erstattungCent, + fortschritt: rechnung ? rechnung.prozent : null, + /* Die Erstattung wird NICHT automatisch ausgelöst. Geld + zurückzuschicken ist eine Handlung mit Folgen, und PayPal- + Rückzahlungen sind nicht umkehrbar. Hier steht, was zu tun ist -- + getan wird es von Hand. */ + hinweis: erstattungCent > 0 + ? `Zu erstatten: ${(erstattungCent / 100).toFixed(2)} €. Die Rückzahlung löst die Seite bewusst nicht selbst aus — überweise sie über PayPal und vermerke sie danach.` + : "Es ist nichts zu erstatten.", + }); +} diff --git a/server-internal/routes/webdesign-zahlungen.js b/server-internal/routes/webdesign-zahlungen.js index a10eb48a..9ed21e22 100644 --- a/server-internal/routes/webdesign-zahlungen.js +++ b/server-internal/routes/webdesign-zahlungen.js @@ -52,6 +52,8 @@ import { brauchtKunde } from "./webdesign-portal.js"; import { zustimmungSpeichern } from "./webdesign-widerruf.js"; import * as paypal from "../lib/webdesign-paypal.js"; import * as geheim from "../lib/webdesign-geheimnisse.js"; +import { zahlungVerbucht, meldungenLesen, meldungenOffen, erstattungsVorschlag, wartetAufAnzahlung } + from "../lib/webdesign-automatik.js"; import { jetzt, neueId, naechsteNummer, text, verlauf, centZuText, besucherIp, spracheOderDe, @@ -286,16 +288,32 @@ function zahlungAlsBezahltVermerken(zahlungId, einzug, wer) { nun, nun, zahlungId ); - /* Anzahlung/Rest am Projekt mitführen, damit die Verwaltung den Stand - sieht, ohne die Zahlungen einzeln durchzugehen. */ - const z = db.prepare(`SELECT art, projekt_id FROM wd_zahlungen WHERE id = ?`).get(zahlungId); - if (z?.projekt_id && (z.art === "anzahlung" || z.art === "rest")) { - const feld = z.art === "anzahlung" ? "anzahlung_bezahlt" : "rest_bezahlt"; - db.prepare(`UPDATE wd_projekte SET ${feld} = 1, aktualisiert_am = ? WHERE id = ?`) - .run(nun, z.projekt_id); - } - verlauf("zahlung", zahlungId, "bezahlt", wer, { captureId: einzug.captureId || null }); + + /* AB HIER UEBERNIMMT DIE AUTOMATIK. + + Vorher wurde hier nur ein Haekchen am Projekt gesetzt. Das reichte, + solange der Liefertermin mit der Annahme begann -- jetzt haengt an + einer eingehenden Anzahlung deutlich mehr: Die Uhr startet, der + Termin wird ab heute NEU gerechnet, der Status wechselt, und ich + bekomme eine Benachrichtigung. + + Bewusst an DIESER Stelle und nicht im Webhook: Hier laufen beide + Wege zusammen -- PayPals Meldung und das Vermerken von Hand. Haenge + man es nur an den Webhook, verhielte sich die Seite verschieden, je + nachdem WIE das Geld ankam, und eine von Hand verbuchte Zahlung + starte die Uhr nie. */ + try { + zahlungVerbucht(zahlungId, wer); + } catch (fehler) { + /* Die Zahlung selbst ist zu diesem Zeitpunkt schon sicher verbucht. + Scheitert die Automatik danach, darf das NICHT die Buchung + umwerfen -- lieber ein Projekt, dessen Uhr man von Hand startet, + als eine Zahlung, die verlorengeht. Der Fehler wird aber + festgehalten, damit er nicht unbemerkt bleibt. */ + verlauf("zahlung", zahlungId, "automatik_fehlgeschlagen", "system", + String(fehler && fehler.message || fehler).slice(0, 300)); + } } /* --------------------------------------------------------------------- @@ -722,5 +740,35 @@ export async function uebersicht(req, res) { eingerichtet: paypal.istEingerichtet("einmal"), live: paypal.istLive(), }, + /* Benachrichtigungen. Sie stehen ganz oben im Cockpit, weil sie das + Einzige sind, was seit dem letzten Hinsehen NEU ist -- alles + andere sind Bestaende, die man auch morgen noch sieht. */ + meldungen: meldungenLesen(true, 8), + meldungenOffen: meldungenOffen(), + /* Projekte, die seit einer Woche auf ihre Anzahlung warten. Ohne + diese Liste vergisst man sie: Sie stehen in keiner Zahl, weil + ihre Uhr nie zu laufen begann. */ + wartetAufAnzahlung: wartetAufAnzahlung(7), }); } + +/* Eine Benachrichtigung wegklicken. + + Nur als gelesen markieren, nicht löschen: Der Verlauf soll + nachvollziehbar bleiben, und ein versehentliches Wegklicken darf + nichts endgültig verschwinden lassen. */ +export async function meldungGelesen(req, res) { + const p = await darf(req, "WD_ANFRAGEN_VIEW"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const id = String(req.params.id || ""); + if (id === "alle") { + const n = db.prepare( + `UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE gelesen = 0` + ).run(jetzt()).changes; + return json(res, { ok: true, anzahl: n }); + } + db.prepare(`UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE id = ?`) + .run(jetzt(), id); + return json(res, { ok: true, anzahl: 1 }); +} diff --git a/server-internal/test-automatik.mjs b/server-internal/test-automatik.mjs new file mode 100644 index 00000000..9c2b3e4c --- /dev/null +++ b/server-internal/test-automatik.mjs @@ -0,0 +1,226 @@ +/* ===================================================================== + test-automatik.mjs — die Uhr, die Meldungen und der Abbruch + + Der Kern: Die zugesagte Zeit darf NICHT bei der Annahme zu laufen + beginnen, sondern erst, wenn die Anzahlung da ist. Sonst verbraucht + ein Kunde, der zehn Tage bis zur Zahlung braucht, zehn Tage der + zugesagten Zeit — und am Ende steht der Anbieter als der da, der + seinen Termin reisst. + + Das ist nicht mit den Augen prüfbar: Ein Termin, der zehn Tage zu + früh liegt, sieht aus wie ein richtiger. + ===================================================================== */ +import { mkdtempSync, rmSync, writeSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes, createHmac } from "node:crypto"; + +const L = (...a) => writeSync(1, a.join(" ") + "\n"); + +/* better-sqlite3 bricht beim Beenden hart ab (bekannter Fehler in seiner + Aufraeumphase) und verschluckt dabei jede Ausgabe -- auch eine + Fehlermeldung. Ein Absturz im Test saehe dann aus wie "der Test lief + gar nicht": Migrationen, dann Stille. Genau das ist hier passiert. + Deshalb werden Fehler ausdruecklich abgefangen und SOFORT geschrieben. */ +process.on("uncaughtException", (e) => { + writeSync(2, "\nABGEBROCHEN: " + ((e && e.stack) || e) + "\n"); + process.exit(1); +}); +process.on("unhandledRejection", (e) => { + writeSync(2, "\nABGELEHNT: " + ((e && e.stack) || e) + "\n"); + process.exit(1); +}); +const tmp = mkdtempSync(join(tmpdir(), "wd-auto-")); +process.env.DB_PATH = join(tmp, "t.db"); +process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64"); +process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex"); +process.env.WD_BASIS_URL = "https://dogfather-universe.com"; + +const { initDb, db } = await import("./db.js"); +initDb(); +const geheim = await import("./lib/webdesign-geheimnisse.js"); +await geheim.geheimnisseLaden(); +const { vorlagenSicherstellen } = await import("./lib/webdesign-vorlagen.js"); +vorlagenSicherstellen(); +const A = await import("./routes/webdesign-annahme.js"); +const Auto = await import("./lib/webdesign-automatik.js"); +const { werktageBis } = await import("./lib/webdesign-termine.js"); + +let ok = 0, fehler = 0; +const pruefe = (n, b, z) => b + ? (ok++, L(" ok " + n)) + : (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : ""))); + +function ausweis(g = 60000) { + const n = Buffer.from(JSON.stringify({ bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + g })) + .toString("base64url"); + return n + "." + createHmac("sha256", process.env.WEBDESIGN_API_SECRET).update(n).digest("base64url"); +} +async function ruf(fn, extra) { + let out = null; + await fn({ headers: { authorization: "Bearer " + ausweis() }, cookies: {}, + params: {}, body: {}, query: {}, ...extra }, + { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }); + return out; +} + +const nun = new Date().toISOString(); +db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) + VALUES ('a1','A-1','Anna Muster','anna@example.com','website','neu','de',?)`).run(nun); + +L("\n1. ANNEHMEN — die Uhr steht noch"); +const an = await ruf(A.anfrageAnnehmen, { params: { id: "a1" }, body: { preisEuro: 2000 } }); +pruefe("die Annahme gelingt", an && an.ok === true, JSON.stringify(an).slice(0, 120)); +let p = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("es gibt einen geplanten Liefertermin", !!p.termin_am, p.termin_am); +pruefe("aber die Uhr laeuft NOCH NICHT", p.uhr_start_am === null, String(p.uhr_start_am)); +pruefe("und der Status steht auf briefing", p.status === "briefing", p.status); +let terminBeiAnnahme = p.termin_am; // wird gleich auf den zurueckdatierten Wert gesetzt + +L("\n2. EINE OFFENE RECHNUNG AENDERT NICHTS"); +db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am) + VALUES ('z1','Z-1',?,?,'anzahlung','Anzahlung',60000,'offen',?)`).run(an.kundeId, an.projektId, nun); +p = db.prepare(`SELECT uhr_start_am FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("eine offene Rechnung startet die Uhr nicht", p.uhr_start_am === null); + +L("\n3. ZAHLUNGSEINGANG — jetzt laeuft sie, Termin ab HEUTE neu gerechnet"); +/* Die Annahme wird kuenstlich zehn Tage zurueckdatiert. Waere der Termin + an die Annahme gebunden, blieben jetzt zehn Tage weniger uebrig -- + genau der Fehler, um den es hier geht. */ +/* Beides zurueckdatieren: die Annahme UND den damals errechneten Termin. + + Die erste Fassung setzte nur angenommen_am zurueck. Der Termin war + aber schon "heute + 20 Werktage" -- und die Neuberechnung beim + Zahlungseingang kam auf denselben Tag heraus. Die Pruefung + "der Termin liegt jetzt spaeter" schlug fehl, obwohl der Code + richtig rechnete: Der Testaufbau bildete den Fall gar nicht ab, um + den es geht. */ +const vor10 = new Date(Date.now() - 10 * 864e5).toISOString(); +const { werktageAddieren } = await import("./lib/webdesign-termine.js"); +const terminVonDamals = werktageAddieren(vor10, 20); +db.prepare(`UPDATE wd_projekte SET angenommen_am = ?, termin_am = ? WHERE id = ?`) + .run(vor10, terminVonDamals, an.projektId); +terminBeiAnnahme = terminVonDamals; +db.prepare(`UPDATE wd_zahlungen SET status = 'bezahlt', bezahlt_am = ? WHERE id = 'z1'`).run(nun); + +const erg = Auto.zahlungVerbucht("z1", "dogfather"); +pruefe("die Automatik meldet den Uhrstart", + erg.getan.includes("uhr_gestartet"), JSON.stringify(erg)); + +p = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("die Uhr laeuft jetzt", !!p.uhr_start_am, String(p.uhr_start_am)); +pruefe("die Anzahlung ist am Projekt vermerkt", p.anzahlung_bezahlt === 1); +pruefe("der Status wechselte von briefing auf design", p.status === "design", p.status); +pruefe("jetzt bin ICH am Zug, nicht der Kunde", p.wartet_auf === "dogfather", p.wartet_auf); +pruefe("der urspruenglich geplante Termin bleibt erhalten", + p.termin_geplant_am === terminBeiAnnahme, p.termin_geplant_am + " vs. " + terminBeiAnnahme); +/* DER KERN dieser ganzen Aenderung. */ +pruefe("der Liefertermin ist ab HEUTE neu gerechnet: volle 20 Werktage", + werktageBis(p.termin_am, new Date()) === 20, + "noch " + werktageBis(p.termin_am, new Date()) + " Werktage (erwartet 20)"); +pruefe("er liegt damit spaeter als der bei der Annahme genannte", + p.termin_am > terminBeiAnnahme, p.termin_am + " > " + terminBeiAnnahme); + +L("\n4. BENACHRICHTIGUNG"); +const m = Auto.meldungenLesen(true, 10); +pruefe("es gibt eine ungelesene Meldung", m.length >= 1, m.length); +const anz = m.find((x) => x.art === "anzahlung_da"); +pruefe("sie meldet die Anzahlung", !!anz, JSON.stringify(m.map((x) => x.art))); +pruefe("sie nennt die Projektnummer", /P-/.test(anz.titel), anz.titel); +pruefe("sie fuehrt zum Projekt", anz.ziel === "projekte" && anz.ziel_id === an.projektId); +pruefe("sie ist als dringend gekennzeichnet", anz.dringend === 1); +/* Beide Termine im Text: Nur so sieht man, dass sich etwas verschoben + hat -- und muss nicht selbst nachrechnen, warum. */ +pruefe("sie nennt beide Termine, damit der Unterschied sichtbar ist", + anz.text.includes(p.termin_am) && anz.text.includes(terminBeiAnnahme), anz.text); + +L("\n5. EINE ZWEITE VERBUCHUNG STARTET NICHTS ERNEUT"); +const vorher = p.uhr_start_am; +Auto.zahlungVerbucht("z1", "dogfather"); +const p2 = db.prepare(`SELECT uhr_start_am, termin_am FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("die Uhr bleibt beim ersten Start", p2.uhr_start_am === vorher); +pruefe("und der Termin verschiebt sich nicht noch einmal", p2.termin_am === p.termin_am); + +L("\n6. ERSTATTUNGSVORSCHLAG AUS DEM FORTSCHRITT"); +const alle = db.prepare(`SELECT id FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge`).all(an.projektId); +const wieViele = Math.floor(alle.length / 4); +for (let i = 0; i < wieViele; i++) { + db.prepare(`UPDATE wd_aufgaben SET status = 'erledigt' WHERE id = ?`).run(alle[i].id); +} +const r = Auto.erstattungsVorschlag(an.projektId); +pruefe("der Fortschritt wird gezaehlt", r.fertig === wieViele, r.fertig + "/" + r.gesamt); +pruefe("bezahlt sind 600 EUR", r.bezahltCent === 60000, r.bezahltCent); +pruefe("behalten plus erstatten ergibt genau das Bezahlte", + r.behaltenCent + r.erstattungCent === r.bezahltCent, + r.behaltenCent + " + " + r.erstattungCent + " = " + r.bezahltCent); +pruefe("man kann nie mehr behalten als bezahlt wurde", r.behaltenCent <= r.bezahltCent); + +L("\n7. DIE ABBRUCH-VORSCHAU VERAENDERT NICHTS"); +const vs = await ruf(A.abbruchVorschau, { params: { id: an.projektId } }); +pruefe("die Vorschau antwortet", vs.ok === true); +pruefe("sie nennt denselben Fortschritt", vs.rechnung.prozent === r.prozent, vs.rechnung.prozent); +pruefe("das Projekt ist danach noch NICHT abgebrochen", + db.prepare(`SELECT status FROM wd_projekte WHERE id = ?`).get(an.projektId).status === "design"); + +L("\n8. ABBRECHEN"); +db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am) + VALUES ('z2','Z-2',?,?,'rest','Restzahlung',140000,'offen',?)`).run(an.kundeId, an.projektId, nun); +const ab = await ruf(A.projektAbbrechen, { + params: { id: an.projektId }, + body: { grund: "keine_zahlung", wer: "kunde", erstattungEuro: 100 }, +}); +pruefe("das Abbrechen gelingt", ab.ok === true, JSON.stringify(ab).slice(0, 140)); +const pa = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("der Status steht auf abgebrochen", pa.status === "abgebrochen"); +pruefe("der Grund ist auswertbar hinterlegt", pa.abbruch_grund === "keine_zahlung"); +pruefe("wer abgebrochen hat, steht dabei", pa.abbruch_wer === "kunde"); +/* Der eingegebene Betrag muss gelten -- sonst waere das Eingabefeld im + Dialog eine Attrappe. */ +pruefe("der eingegebene Betrag gilt, nicht der vorgeschlagene", + pa.abbruch_erstattung_cent === 10000, pa.abbruch_erstattung_cent); +pruefe("der Fortschritt beim Abbruch ist festgehalten", + pa.abbruch_fortschritt === r.prozent, pa.abbruch_fortschritt); +pruefe("offene Rechnungen wurden storniert", + db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z2'`).get().status === "storniert", + db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z2'`).get().status); +pruefe("die bereits BEZAHLTE Zahlung bleibt unangetastet", + db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z1'`).get().status === "bezahlt"); +pruefe("der Hinweis sagt, dass die Rueckzahlung von Hand erfolgt", + /nicht selbst/.test(ab.hinweis || ""), ab.hinweis); + +L("\n9. NACH DEM ABBRUCH"); +const ab2 = await ruf(A.projektAbbrechen, { params: { id: an.projektId }, body: { grund: "sonstiges" } }); +pruefe("ein zweiter Abbruch meldet keinen Fehler", + ab2.ok === true && ab2.bereitsAbgebrochen === true, JSON.stringify(ab2).slice(0, 90)); +db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am) + VALUES ('z3','Z-3',?,?,'anzahlung','Nachzuegler',1000,'bezahlt',?)`).run(an.kundeId, an.projektId, nun); +Auto.zahlungVerbucht("z3", "system"); +pruefe("eine spaet verbuchte Zahlung belebt es NICHT wieder", + db.prepare(`SELECT status FROM wd_projekte WHERE id = ?`).get(an.projektId).status === "abgebrochen"); + +L("\n10. WER SEIT EINER WOCHE AUF SEINE ANZAHLUNG WARTET"); +db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) + VALUES ('a2','A-2','Bert Beispiel','bert@example.com','onepager','neu','de',?)`).run(nun); +const an2 = await ruf(A.anfrageAnnehmen, { params: { id: "a2" } }); +db.prepare(`UPDATE wd_projekte SET angenommen_am = ? WHERE id = ?`) + .run(new Date(Date.now() - 9 * 864e5).toISOString(), an2.projektId); +const wartet = Auto.wartetAufAnzahlung(7); +pruefe("das seit neun Tagen wartende Projekt erscheint", + wartet.some((x) => x.id === an2.projektId), JSON.stringify(wartet.map((x) => x.nummer))); +pruefe("das abgebrochene erscheint NICHT", + !wartet.some((x) => x.id === an.projektId)); + +L("\n11. MELDUNGEN WEGKLICKEN"); +const offenVorher = Auto.meldungenOffen(); +pruefe("es gibt ungelesene Meldungen", offenVorher > 0, offenVorher); +db.prepare(`UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE gelesen = 0`) + .run(new Date().toISOString()); +pruefe("danach sind es null", Auto.meldungenOffen() === 0); +/* Nur als gelesen markieren, nicht loeschen: Ein versehentliches + Wegklicken darf nichts endgueltig verschwinden lassen. */ +pruefe("aber geloescht wurde nichts", + db.prepare(`SELECT COUNT(*) AS n FROM wd_meldungen`).get().n >= offenVorher); + +L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`); +process.exitCode = fehler ? 1 : 0; +rmSync(tmp, { recursive: true, force: true }); diff --git a/webdesign/sw.js b/webdesign/sw.js index c22b85f4..80d9d208 100644 --- a/webdesign/sw.js +++ b/webdesign/sw.js @@ -34,7 +34,7 @@ Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten Stand fest. */ -const CACHE_NAME = "dogfather-webdesign-v26"; +const CACHE_NAME = "dogfather-webdesign-v27"; /* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE HTML-Datei in dieser Liste. */ diff --git a/webdesign/verwaltung.html b/webdesign/verwaltung.html index 9258f928..a4109e8b 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -93,6 +93,36 @@ Sie waren doppelt so gross wie die Zahlen, um die es geht, und die Seite las sich wie ein Plakat statt wie ein Arbeitsplatz. Dieselbe Falle wie frueher bei `.wd a` und `.wd .wd-btn--haupt`. */ + /* ---------- Benachrichtigungen ---------- */ + .vw-meld { + display: flex; align-items: flex-start; gap: .7rem; + padding: .7rem .85rem; margin-bottom: .5rem; + border-radius: var(--wd-radius-m, 14px); + border: 1px solid rgb(var(--wd-blau-rgb) / .3); + background: linear-gradient(rgb(var(--wd-blau-rgb) / .07), rgb(var(--wd-blau-rgb) / .07)), + var(--wd-flaeche-2, #16191d); + } + /* Dringend heisst hier: Geld ist geflossen oder eine Frist laeuft. + Nur DIESE bekommen Farbe -- bekaeme jede Meldung eine, waere keine + mehr auffaellig. */ + .vw-meld--dringend { + border-color: rgb(var(--wd-gold-rgb) / .5); + background: linear-gradient(rgb(var(--wd-gold-rgb) / .08), rgb(var(--wd-gold-rgb) / .08)), + var(--wd-flaeche-2, #16191d); + } + .vw-meld-inhalt { flex: 1; min-width: 0; cursor: pointer; text-align: left; + background: none; border: 0; color: inherit; font: inherit; padding: 0; } + .wd .vw-meld-titel { font-weight: 700; font-size: .95rem; margin: 0; } + .wd .vw-meld-text { font-size: .84rem; color: var(--wd-text-soft); margin: .15rem 0 0; } + .vw-meld-weg { + flex-shrink: 0; min-width: 44px; min-height: 44px; + background: none; border: 0; color: var(--wd-text-soft); + cursor: pointer; font-size: 1.1rem; border-radius: 8px; + } + .vw-meld-weg:hover { background: rgb(255 255 255 / .07); color: var(--wd-text); } + .vw-meld-weg:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; } + .vw-meld-alle { margin: .1rem 0 1rem; } + .wd .vw-ub-h { font-size: 1.05rem; font-weight: 650; margin: 1.9rem 0 .75rem; @@ -1113,6 +1143,14 @@
Wird geladen…