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 });