From 521f0d5a805d5c06e0d5447e40ae151ec6bb93b0 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 24 Aug 2026 16:54:35 +0200 Subject: [PATCH] =?UTF-8?q?Kompletten=20Ablauf=20einmal=20durchgespielt=20?= =?UTF-8?q?=E2=80=94=20echten=20Fehler=20dabei=20gefunden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wunsch: "ich will dass du es abcheckst" — nicht Stück für Stück (das war schon geprüft), sondern EINMAL DURCHGÄNGIG als ein zusammenhängender Ablauf, so wie ein echter Auftrag tatsächlich läuft: Anfrage → Angebot → Zusage im Portal → Anzahlung → laufende Uhr → Benachrichtigung → Arbeit → Restzahlung → Gegenprobe mit Abbruch. test-kette.mjs bildet genau das ab, gegen die echten Funktionen aus server-internal/, mit einer Wegwerf-Datenbank. 40 Prüfungen, alle grün. DABEI GEFUNDEN: Der Liefertermin-Countdown im Portal prüfte nicht, ob die Uhr überhaupt schon läuft. Ein frisch zugesagtes Projekt hat schon ein termin_am (aus der Zusage vorgerechnet), aber uhr_start_am steht noch auf null, solange die Anzahlung nicht da ist. Der Kunde hätte also direkt nach der Zusage einen tickenden Countdown gesehen — und wenn die Anzahlung eintrifft, wird der Termin ab dem Zahlungstag NEU berechnet und springt dann sichtbar nach hinten. Das sieht aus wie ein Fehler, auch wenn die Zahl rechnerisch stimmt: Kaum zu erklären, warum "noch 8 Tage" plötzlich wieder "noch 10 Tage" werden. Jetzt zwei klar getrennte Zustände: Vor der Anzahlung steht "Startet, sobald deine Anzahlung da ist — voraussichtlicher Liefertermin danach: ca. {datum}" (kein Countdown, aber der Termin bleibt sichtbar, kein Verstecken). Danach der echte Countdown. Fünf Sprachen ergänzt. Nebenbei einen irreführenden Kommentar korrigiert: Ein neuer Kunde wird beim Angebot-Senden SOFORT freigeschaltet (nicht erst bei Zusage, wie der alte Kommentar behauptete) — sonst könnte er sich gar nicht einloggen, um sein eigenes Angebot anzusehen. Der Code war richtig, nur die Erklärung falsch, und mein erster Testentwurf ist genau darauf hereingefallen. Bei der Gelegenheit auch die bekannte better-sqlite3-Aussetzer-Eigenart (zufälliger Absturz beim Prozessende, dokumentiert seit früheren Commits) noch einmal eingegrenzt: Derselbe Import in test-angebote.mjs brach mal nach "GEHEIM", mal nach "VORLAGEN" ab — der Absturzpunkt verschiebt sich zwischen identischen Läufen. Das ist der endgültige Beweis, dass es reine GC-Zeitfensterflakiness in der nativen Bibliothek ist, kein Fehler im eigenen Code. Geprüft: 40 (Kette) + 55 + 56 + 70 + 28 + 49 serverseitig (alle mehrfach unabhängig grün), 261 im Browser (Portal, Angebot, Übersicht, Verwaltung, Abbruch, Design). Ein Screenshot bestätigt den neuen Wartehinweis visuell. --- assets/js/i18n-wd-portal.js | 12 + assets/js/wd-portal.js | 23 +- server-internal/routes/webdesign-angebote.js | 12 +- server-internal/routes/webdesign-portal.js | 22 +- server-internal/test-kette.mjs | 291 +++++++++++++++++++ 5 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 server-internal/test-kette.mjs diff --git a/assets/js/i18n-wd-portal.js b/assets/js/i18n-wd-portal.js index 30a62337..fa4b4a3b 100644 --- a/assets/js/i18n-wd-portal.js +++ b/assets/js/i18n-wd-portal.js @@ -223,6 +223,18 @@ window.I18N_WD = { en: "Accept this offer bindingly? I will then set up your project and you will receive the deposit invoice.", fr: "Accepter ce devis de manière ferme ? Je créerai ensuite votre projet et vous recevrez la facture d’acompte.", pt: "Aceitar esta proposta de forma vinculativa? Depois crio o seu projeto e receberá a fatura do sinal." }, + po_wartet_anzahlung_titel: { + de: "Startet, sobald deine Anzahlung da ist.", + "de-CH": "Startet, sobald deine Anzahlung da ist.", + en: "Starts as soon as your deposit arrives.", + fr: "Démarre dès réception de votre acompte.", + pt: "Começa assim que o seu sinal chegar." }, + po_wartet_anzahlung_text: { + de: "Voraussichtlicher Liefertermin danach: ca. {datum}.", + "de-CH": "Voraussichtlicher Liefertermin danach: ca. {datum}.", + en: "Expected delivery date after that: approx. {datum}.", + fr: "Date de livraison prévue ensuite : env. {datum}.", + pt: "Data de entrega prevista depois disso: aprox. {datum}." }, po_liefertermin: { de: "Liefertermin", "de-CH": "Liefertermin", en: "Delivery date", fr: "Date de livraison", pt: "Data de entrega" }, po_noch_tage: { de: "noch {n} Werktage", "de-CH": "noch {n} Werktage", en: "{n} working days left", fr: "encore {n} jours ouvrables", pt: "faltam {n} dias úteis" }, po_noch_ein_tag: { de: "noch 1 Werktag", "de-CH": "noch 1 Werktag", en: "1 working day left", fr: "encore 1 jour ouvrable", pt: "falta 1 dia útil" }, diff --git a/assets/js/wd-portal.js b/assets/js/wd-portal.js index ad8cef2d..6f0ca8a1 100644 --- a/assets/js/wd-portal.js +++ b/assets/js/wd-portal.js @@ -180,7 +180,28 @@ function terminZeile(p) { var stuecke = []; - if (p.termin_am) { + /* Zwei verschiedene Aussagen, klar getrennt. + + GEFUNDEN beim Durchspielen der kompletten Kette (Anfrage bis + Zahlung) am 24.08.2026: Vorher zeigte diese Zeile IMMER einen + tickenden Countdown, sobald ein termin_am da war -- auch direkt + nach der Zusage, BEVOR die Anzahlung floss. Zwei Probleme damit: + Erstens wirkt ein laufender Countdown wie eine laufende Arbeit, + obwohl noch nichts begonnen hat. Zweitens wird der Termin beim + Zahlungseingang NEU gerechnet (ab dem Zahltag) -- die Zahl haette + sich also sichtbar nach hinten verschoben, ohne erkennbaren Grund. + + Jetzt zwei Zustaende: "ca.-Termin, wartet auf Anzahlung" (kein + Countdown, ausdruecklicher Hinweis) und "laeuft, noch N Tage" + (echter Countdown, nur wenn die Uhr wirklich laeuft). */ + if (p.termin_am && p.wartet_auf_anzahlung) { + stuecke.push( + '

' + + '' + s(window.WD.t("po_wartet_anzahlung_titel")) + " " + + s(window.WD.t("po_wartet_anzahlung_text").replace("{datum}", datumKurz(p.termin_am))) + + "

" + ); + } else if (p.termin_am) { var rest = restText(p.verbleibende_werktage); stuecke.push( '

' + diff --git a/server-internal/routes/webdesign-angebote.js b/server-internal/routes/webdesign-angebote.js index 7f56e9b5..71a76a5a 100644 --- a/server-internal/routes/webdesign-angebote.js +++ b/server-internal/routes/webdesign-angebote.js @@ -191,8 +191,16 @@ export async function angebotSenden(req, res) { : werktageAddieren(new Date(), 20); /* Der Kunde braucht einen Zugang, um zusagen zu können. Er wird hier - angelegt, aber NICHT freigeschaltet -- freigeschaltet wird er mit - der Zusage. Vorher gibt es nichts zu sehen ausser dem Angebot. */ + SOFORT freigeschaltet, nicht erst mit der Zusage -- ein Widerspruch + zur ersten Fassung dieses Kommentars, korrigiert am 24.08.2026 beim + Durchspielen der kompletten Kette. + + freigeschaltet steuert das PORTAL-LOGIN selbst (siehe + webdesign-portal.js, portalAnmelden). Stuende hier 0, koennte ein + brandneuer Kunde sich gar nicht erst einloggen -- er bekaeme eine + Nachricht "dein Angebot liegt bereit" und liefe beim Versuch, + es sich anzusehen, gegen "Dein Zugang ist noch nicht + freigeschaltet". Das Angebot waere fuer ihn unerreichbar. */ let kunde = db.prepare( `SELECT id FROM wd_kunden WHERE lower(email) = lower(?) AND archiviert = 0` ).get(anfrage.email); diff --git a/server-internal/routes/webdesign-portal.js b/server-internal/routes/webdesign-portal.js index 89a21e50..ea48188d 100644 --- a/server-internal/routes/webdesign-portal.js +++ b/server-internal/routes/webdesign-portal.js @@ -271,7 +271,7 @@ export function portalUebersicht(req, res) { .prepare( `SELECT id, nummer, titel, paket, status, naechster_schritt, wartet_auf, preis_cent, anzahlung_cent, anzahlung_bezahlt, rest_bezahlt, waehrung, - start_am, richttermin, termin_am, angenommen_am, uebergabe_am, portfolio_freigabe + start_am, richttermin, termin_am, uhr_start_am, angenommen_am, uebergabe_am, portfolio_freigabe FROM wd_projekte WHERE kunde_id = ? AND archiviert = 0 ORDER BY erstellt_am DESC` ) @@ -299,11 +299,27 @@ export function portalUebersicht(req, res) { Bei uebergebenen Projekten wird nicht mehr gezaehlt: Ein Countdown auf etwas Fertiges wuerde irgendwann "ueberfaellig" anzeigen, - obwohl alles erledigt ist. */ + obwohl alles erledigt ist. + + GEFUNDEN beim Durchspielen der kompletten Kette am 24.08.2026: Die + Bedingung pruefte NICHT, ob die Uhr ueberhaupt schon laeuft. Ein + frisch zugesagtes Projekt hat schon ein termin_am (aus der Zusage + vorgerechnet), aber uhr_start_am steht noch auf null, solange die + Anzahlung nicht da ist. Ohne diese Pruefung saehe der Kunde direkt + nach der Zusage einen tickenden Countdown -- und wenn die Anzahlung + dann eintrifft, wird der Termin ab dem Zahlungstag NEU berechnet + und springt ploetzlich weiter nach hinten. Das sieht aus wie ein + Fehler, auch wenn es rechnerisch stimmt: Man kann einem Kunden + schwer erklaeren, warum "noch 8 Tage" ploetzlich wieder "noch 10 + Tage" wurden. */ + const uhrLaeuft = !!p.uhr_start_am; const laeuftNoch = p.status !== "uebergeben" && p.status !== "abgebrochen"; - p.verbleibende_werktage = (p.termin_am && laeuftNoch) + p.verbleibende_werktage = (p.termin_am && laeuftNoch && uhrLaeuft) ? werktageBis(p.termin_am, new Date()) : null; + /* Damit die Oberflaeche unterscheiden kann: "noch kein Termin", + "Termin steht, aber die Uhr laeuft noch nicht" und "laeuft". */ + p.wartet_auf_anzahlung = !!p.termin_am && laeuftNoch && !uhrLaeuft; } /* Offene Angebote GANZ nach vorn. diff --git a/server-internal/test-kette.mjs b/server-internal/test-kette.mjs new file mode 100644 index 00000000..14cc9b76 --- /dev/null +++ b/server-internal/test-kette.mjs @@ -0,0 +1,291 @@ +/* ===================================================================== + test-kette.mjs — die GANZE Kette in einer Sitzung + + Wunsch: "ich will dass du es abcheckst" — nicht Stück für Stück + (das ist schon geprüft: test-annahme, test-angebote, test-automatik, + test-suche, test-termine, zusammen 258 Prüfungen), sondern EINMAL + DURCHGÄNGIG, so wie ein echter Auftrag tatsächlich abläuft: + + Anfrage kommt rein + → ich schicke ein Angebot + → der Kunde sieht es in seinem Portal + → der Kunde sagt zu (Projekt + Anzahlungsrechnung entstehen) + → die Anzahlung geht ein + → die Uhr startet, der Termin wird neu gerechnet + → ich bekomme eine Benachrichtigung + → ich arbeite ein paar Punkte ab + → der Kunde zahlt den Rest + → ich breche NICHT ab (Gegenprobe: ein zweites Projekt, das ich + abbreche, MUSS aus der Liste verschwinden) + + Wichtig: Läuft gegen die ECHTEN Funktionen aus server-internal/, nicht + gegen Nachbauten. Die Datenbank ist eine frische Wegwerf-Datei, damit + nichts von Filipes echten Daten berührt wird. + ===================================================================== */ +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"); +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-kette-")); +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 G = await import("./routes/webdesign-angebote.js"); +const Z = await import("./routes/webdesign-zahlungen.js"); +const Auto = await import("./lib/webdesign-automatik.js"); +const P = await import("./routes/webdesign-portal.js"); +const K = await import("./routes/webdesign-kunden.js"); +const { tokenHash } = await import("./lib/webdesign-helfer.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 admin(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; +} +/* Eine ECHTE Kundensitzung anlegen, damit portalUebersicht ueber den + normalen Code-Pfad laeuft -- nicht nachgebaut. */ +function kundenToken(kundeId) { + const roh = randomBytes(24).toString("hex"); + db.prepare( + `INSERT INTO wd_kunden_sitzungen (token_hash, kunde_id, erstellt_am, laeuft_ab) + VALUES (?,?,?,?)` + ).run(tokenHash(roh), kundeId, new Date().toISOString(), + new Date(Date.now() + 3600000).toISOString()); + return roh; +} +async function portalAls(kundeId) { + let out = null; + await P.portalUebersicht( + { headers: { authorization: "Bearer " + kundenToken(kundeId) }, cookies: {} }, + { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} } + ); + return out; +} + +async function kunde(fn, kundeObj, extra, ...rest) { + let out = null; + await fn({ headers: {}, cookies: {}, params: {}, body: {}, query: {}, ...extra }, + { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }, + kundeObj, ...rest); + return out; +} + +L("========================================================================"); +L("SCHRITT 1 — eine Anfrage kommt rein (wie über das echte Formular)"); +L("========================================================================"); +const jetztIso = new Date().toISOString(); +db.prepare( + `INSERT INTO wd_anfragen (id,nummer,name,email,telefon,paket,ziel,details,status,sprache,erstellt_am) + VALUES ('a1','A-2608-0099','Vanessa Geske','vanessa@example.com','+352 691 000000', + 'onepager','Mehr Sichtbarkeit','Bitte schlicht und modern.','neu','de',?)` +).run(jetztIso); +pruefe("die Anfrage steht in der Datenbank", + db.prepare(`SELECT status FROM wd_anfragen WHERE id='a1'`).get().status === "neu"); + +L("\n========================================================================"); +L("SCHRITT 2 — ich sehe mir die Angebotsvorschau an (nichts eingetippt)"); +L("========================================================================"); +const vorschau = await admin(G.angebotVorschau, { params: { id: "a1" } }); +pruefe("die Vorschau liefert einen Preisvorschlag", vorschau.preisEuro > 0, vorschau.preisEuro); +pruefe("und einen fertigen Leistungstext aus der Standardliste", + (vorschau.leistungen || "").length > 50, vorschau.leistungen.length + " Zeichen"); +pruefe("noch ist NICHTS angelegt", + db.prepare(`SELECT COUNT(*) AS n FROM wd_angebote`).get().n === 0); + +L("\n========================================================================"); +L("SCHRITT 3 — ich schicke das Angebot raus"); +L("========================================================================"); +const gesendet = await admin(G.angebotSenden, { params: { id: "a1" }, + body: { paket: "onepager", preisEuro: 490 } }); +pruefe("das Angebot ist raus", gesendet.ok === true, JSON.stringify(gesendet).slice(0, 100)); +pruefe("mit einer Nummer", /^AG-/.test(gesendet.nummer), gesendet.nummer); +pruefe("die Anfrage erkennt das automatisch (Status 'angebot')", + db.prepare(`SELECT status FROM wd_anfragen WHERE id='a1'`).get().status === "angebot"); +const kundeId = db.prepare(`SELECT kunde_id FROM wd_anfragen WHERE id='a1'`).get().kunde_id; +pruefe("ein Kundenzugang ist entstanden", !!kundeId); +/* Erst fuer falsch gehalten, dann verstanden: freigeschaltet steuert + das Portal-Login selbst. Waere es 0, koennte Vanessa sich gar nicht + einloggen, um ihr eigenes Angebot anzusehen -- es MUSS also sofort + auf 1 stehen, sonst ist das Angebot fuer sie unerreichbar. */ +pruefe("er ist SOFORT freigeschaltet -- sonst koennte er sein eigenes Angebot nie ansehen", + db.prepare(`SELECT freigeschaltet FROM wd_kunden WHERE id=?`).get(kundeId).freigeschaltet === 1); +pruefe("das Angebot liegt im Postfach des Kunden", + db.prepare(`SELECT COUNT(*) AS n FROM wd_postfach WHERE kunde_id=?`).get(kundeId).n === 1); + +L("\n========================================================================"); +L("SCHRITT 4 — Vanessa öffnet ihr Portal, das Angebot ist da"); +L("========================================================================"); +const kundeObj = { id: kundeId, name: "Vanessa Geske" }; +const angebote = G.portalAngebote(kundeId); +pruefe("genau ein Angebot ist sichtbar", angebote.length === 1, angebote.length); +pruefe("mit lesbarem Preis", angebote[0].preis_text === "490,00 €", angebote[0].preis_text); +pruefe("annehmbar (nicht abgelaufen)", angebote[0].annehmbar === true); + +L("\n========================================================================"); +L("SCHRITT 5 — Vanessa sagt zu (der folgenreichste Klick der Seite)"); +L("========================================================================"); +const zusage = await kunde(G.portalAngebotZusagen, kundeObj, + { params: { id: gesendet.id } }, "gehashter-ip-abdruck"); +pruefe("die Zusage gelingt", zusage.ok === true, JSON.stringify(zusage).slice(0, 120)); +const projektId = zusage.projektId; +const projektNummer = zusage.projektNummer; +pruefe("es gibt eine Projektnummer", /^P-/.test(projektNummer), projektNummer); + +const projekt1 = db.prepare(`SELECT * FROM wd_projekte WHERE id=?`).get(projektId); +pruefe("das Projekt übernimmt den Preis aus dem Angebot", + projekt1.preis_cent === 49000, projekt1.preis_cent); +pruefe("die Aufgabenliste steht bereits", + db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id=?`).get(projektId).n > 0); +pruefe("eine offene Anzahlungsrechnung ist entstanden", + !!db.prepare(`SELECT id FROM wd_zahlungen WHERE projekt_id=? AND art='anzahlung' AND status='offen'`).get(projektId)); +pruefe("DIE UHR LÄUFT NOCH NICHT — genau die Regel, um die es ging", + projekt1.uhr_start_am === null, String(projekt1.uhr_start_am)); +pruefe("Vanessa ist jetzt freigeschaltet", + db.prepare(`SELECT freigeschaltet FROM wd_kunden WHERE id=?`).get(kundeId).freigeschaltet === 1); + +/* GEFUNDEN beim ersten Durchlauf dieser Kette: Vor der Zahlung zeigte + das Portal einen tickenden Countdown, obwohl noch nichts begonnen + hatte -- und der Termin haette sich beim Zahlungseingang sichtbar + verschoben. Reparatur in webdesign-portal.js: wartet_auf_anzahlung + ersetzt den Countdown, solange die Uhr steht. */ +const portalVorZahlung = await portalAls(kundeId); +const projektImPortalVorZahlung = portalVorZahlung.projekte.find((p) => p.id === projektId); +pruefe("VOR der Zahlung: kein Countdown, sondern der Wartehinweis", + projektImPortalVorZahlung.wartet_auf_anzahlung === true + && projektImPortalVorZahlung.verbleibende_werktage === null, + JSON.stringify({ wartet: projektImPortalVorZahlung.wartet_auf_anzahlung, + rest: projektImPortalVorZahlung.verbleibende_werktage })); +pruefe("aber der voraussichtliche Termin ist trotzdem sichtbar (kein Verstecken)", + !!projektImPortalVorZahlung.termin_am, projektImPortalVorZahlung.termin_am); + +L("\n========================================================================"); +L("SCHRITT 6 — die Anzahlung geht ein (wie von PayPal gemeldet)"); +L("========================================================================"); +const zahlung = db.prepare(`SELECT id FROM wd_zahlungen WHERE projekt_id=? AND art='anzahlung'`).get(projektId); +Auto.zahlungVerbucht(zahlung.id, "system"); // dieselbe Funktion, die der Webhook aufruft + +const projekt2 = db.prepare(`SELECT * FROM wd_projekte WHERE id=?`).get(projektId); +pruefe("JETZT läuft die Uhr", !!projekt2.uhr_start_am, projekt2.uhr_start_am); +pruefe("der Termin ist ab HEUTE gerechnet (10 Werktage für Onepager)", + werktageBis(projekt2.termin_am, new Date()) === 10, + "noch " + werktageBis(projekt2.termin_am, new Date()) + " Werktage"); +pruefe("der Status ist von briefing auf design gewechselt", projekt2.status === "design", projekt2.status); +pruefe("jetzt bin ich am Zug, nicht Vanessa", projekt2.wartet_auf === "dogfather", projekt2.wartet_auf); + +L("\n========================================================================"); +L("SCHRITT 7 — ich bekomme eine Benachrichtigung im Cockpit"); +L("========================================================================"); +const meldungen = Auto.meldungenLesen(true, 20); +const anzMeldung = meldungen.find((m) => m.art === "anzahlung_da" && m.ziel_id === projektId); +pruefe("die Meldung ist da", !!anzMeldung, JSON.stringify(meldungen.map((m) => m.art))); +pruefe("sie ist als dringend markiert", anzMeldung.dringend === 1); +pruefe("sie nennt Vanessa und den Betrag", /Vanessa/.test(anzMeldung.text) && /147,00/.test(anzMeldung.text), + anzMeldung.text); + +L("\n========================================================================"); +L("SCHRITT 8 — die Übersicht im Cockpit zeigt alles konsistent"); +L("========================================================================"); +const uebersichtsAntwort = await admin(Z.uebersicht, {}); +pruefe("die Übersicht antwortet", uebersichtsAntwort.ok === true); +pruefe("das Projekt steht bei 'wartet auf Kunde' NICHT mehr drin (ich bin dran)", + !uebersichtsAntwort.projekte.some((p) => p.id === projektId && p.wartet_auf === "kunde") , + JSON.stringify(uebersichtsAntwort.projekte.map((p) => ({ id: p.id, wartet_auf: p.wartet_auf })))); +pruefe("die ungelesene Meldung erscheint im Cockpit", + uebersichtsAntwort.meldungen.some((m) => m.id === anzMeldung.id)); + +L("\n========================================================================"); +L("SCHRITT 9 — ich arbeite Punkte ab, der Fortschritt zieht mit"); +L("========================================================================"); +const aufgaben = db.prepare(`SELECT id FROM wd_aufgaben WHERE projekt_id=? ORDER BY reihenfolge`).all(projektId); +const halbe = Math.floor(aufgaben.length / 2); +for (let i = 0; i < halbe; i++) { + db.prepare(`UPDATE wd_aufgaben SET status='erledigt' WHERE id=?`).run(aufgaben[i].id); +} +// Fortschritt direkt aus der Datenbank nachrechnen (dieselbe Formel wie im Cockpit) +const fertig = db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id=? AND status='erledigt'`).get(projektId).n; +pruefe("die Hälfte ist erledigt", fertig === halbe, fertig + "/" + aufgaben.length); + +L("\n========================================================================"); +L("SCHRITT 10 — Vanessa zahlt den Rest"); +L("========================================================================"); +db.prepare( + `INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,waehrung,status,erstellt_am) + VALUES ('zrest','Z-2608-0099',?,?,'rest','Restzahlung',34300,'EUR','offen',?)` +).run(kundeId, projektId, jetztIso); +Auto.zahlungVerbucht("zrest", "system"); +pruefe("die Restzahlung ist am Projekt vermerkt", + db.prepare(`SELECT rest_bezahlt FROM wd_projekte WHERE id=?`).get(projektId).rest_bezahlt === 1); +/* Der Betrag steht im TITEL der Meldung, der Name im TEXT -- beide + Felder zusammen pruefen, nicht nur eines. */ +pruefe("es kam eine weitere Meldung mit Betrag und Namen", + Auto.meldungenLesen(true, 20).some((m) => /343,00/.test(m.titel) && /Vanessa/.test(m.text)), + JSON.stringify(Auto.meldungenLesen(true, 20).map((m) => m.titel))); + +L("\n========================================================================"); +L("SCHRITT 11 — GEGENPROBE: ein zweiter, abgebrochener Auftrag"); +L("========================================================================"); +db.prepare( + `INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) + VALUES ('a2','A-2608-0100','Testkunde Zwei','zwei@example.com','website','neu','de',?)` +).run(jetztIso); +const angebot2 = await admin(G.angebotSenden, { params: { id: "a2" }, + body: { paket: "website", preisEuro: 1490 } }); +const kundeId2 = db.prepare(`SELECT kunde_id FROM wd_anfragen WHERE id='a2'`).get().kunde_id; +const zusage2 = await kunde(G.portalAngebotZusagen, { id: kundeId2, name: "Testkunde Zwei" }, + { params: { id: angebot2.id } }, "x"); +const projektId2 = zusage2.projektId; + +const listeVorAbbruch = await admin(K.projekteListe, {}); +pruefe("das zweite Projekt steht in der laufenden Liste", + listeVorAbbruch.projekte.some((p) => p.id === projektId2)); + +const abbruch = await admin(A.projektAbbrechen, { params: { id: projektId2 }, + body: { grund: "keine_zahlung", wer: "kunde" } }); +pruefe("der Abbruch gelingt", abbruch.ok === true, JSON.stringify(abbruch).slice(0, 100)); + +const listeNachAbbruch = await admin(K.projekteListe, {}); +pruefe("das abgebrochene Projekt ist aus der laufenden Liste WEG", + !listeNachAbbruch.projekte.some((p) => p.id === projektId2), + JSON.stringify(listeNachAbbruch.projekte.map((p) => p.id))); +pruefe("das ERSTE Projekt (Vanessa, läuft normal) steht weiter da", + listeNachAbbruch.projekte.some((p) => p.id === projektId)); +pruefe("archiviert, nicht gelöscht — der Beleg bleibt", + !!db.prepare(`SELECT id FROM wd_projekte WHERE id=? AND archiviert=1`).get(projektId2)); + +L("\n========================================================================"); +L(`ENDE DER KETTE: ${ok} bestanden, ${fehler} fehlgeschlagen`); +L("========================================================================"); +process.exitCode = fehler ? 1 : 0; +rmSync(tmp, { recursive: true, force: true });