Den Monatswechsel durchgespielt -- und zwei stille Fehler gefunden
Der zentrale Weg dieser Kachel war nie gelaufen: „Am 1. jedes Monats
um 00:00 Uhr starten alle Zaehler automatisch bei 0." Der erste
Oktober lag vor der Auslieferung, der erste November liegt dahinter.
Er laeuft ohne Zutun und betrifft alle gleichzeitig -- waere er
falsch, waere er fuer alle auf einmal falsch, und niemand wuesste
warum.
Dieselbe Lage gab es am 01.09.2026 schon einmal in RunOne (der Umbau
zur Hashkette war seit Tagen live und nie gelaufen). Die Lehre stand
danach in der Projektnotiz, und sie gilt hier woertlich: Was sich
nicht zuruecknehmen laesst, wird vorher auf einer Kopie durchgespielt.
`pruef-monatswechsel.mjs` verstellt dafuer die Uhr -- eine Huelle um
`Date`, ganz oben, vor jedem Import. Damit laeuft der ECHTE Code
durch zwei echte Monatswechsel (20. Oktober -> 1. November, 00:05 ->
3. Dezember), ohne dass eine Zeile dafuer umgebaut werden muesste.
41 Pruefungen, 0 Fehler: Zaehler bei 0, keine Warnung am ersten Tag,
die neuen Zielzahlen greifen, die alten Eintraege stehen unveraendert
da, der Oktober ist zu, der Verlauf misst ihn am OKTOBER-Ziel, und
„Neuer Monat" geht an alle drei -- auch an den, der den Oktober voll
hatte.
WAS DABEI AUFGEFALLEN IST -- zwei Fehler, beide stumm
1. DIE SUCHE NACH DEM ROLLENWECHSEL MASS DIE FALSCHE PERSON.
`WHERE person_id = ?` im Protokoll findet den, der die Aenderung
GEMACHT hat -- also DogFather --, nicht den, dessen Rolle sich
geaendert hat (workspace-personen.js schreibt die betroffene
Nummer ins `detail`). Fuer den Betroffenen fand die Abfrage
deshalb nie etwas; bei DogFather schob jede fremde
Rollenaenderung SEINEN Pflichtbeginn. In der echten Datenbank
stand bei ihm „grund: rollenwechsel" -- ein plausibler Wert aus
der falschen Zeile.
NACHGESEHEN, OB ES SCHADET: Alle sechs Zeilen stehen auf 2026-10,
und das ist ohnehin der frueheste moegliche Monat. Der Fehler
hatte noch keine Wirkung -- er haette sie beim naechsten
Rollenwechsel bekommen.
2. `node:sqlite` BINDET EINE ZAHL ALS REAL.
Der erste Versuch der Reparatur lautete
`detail LIKE ('#' || ? || ' %')` und traf NIE -- ohne Fehler, ohne
Warnung, immer leer. Nachgemessen:
SELECT ('#' || ? || ' %') mit der Zahl 42 -> '#42.0 %'
Gesucht wurde „#42.0 ", gespeichert ist „#42 ". Mit derselben Zahl
als Zeichenkette stimmt es sofort. Das Muster wird jetzt in
JavaScript gebaut; im uebrigen Haus kommt dieselbe Verkettung mit
einer Zahl nicht vor (nachgesehen).
Gefunden hat das nicht das Lesen, sondern eine Pruefung, die den
ECHTEN Weg benutzt (die Route der Personenverwaltung) statt den
Protokolltext selbst zu schreiben. Haette sie ihn selbst
geschrieben, haette sie ihre eigene Annahme geprueft und waere
gruen gewesen.
AUSSERDEM BERICHTIGT
Der Pflichtbeginn wurde EINMAL gesetzt und nie wieder angesehen
(`INSERT OR IGNORE`). Wer die Rolle verliert und spaeter zurueck-
bekommt, haette damit keinen Schonmonat mehr bekommen, obwohl die
Vorlage ihn zusichert. Jetzt wird neu gerechnet, wenn seit der
Festlegung ein Rollenwechsel dazugekommen ist -- und nur dann;
geprueft wird ausdruecklich, dass ein zweiter Abruf nichts bewegt.
GEPRUEFT: 216 + 41 = 257 Pruefungen, 0 Fehler
Mit drei Gegenproben an der neuen Stelle: DogFathers Pflichtbeginn
bewegt sich NICHT, wenn er die Rolle eines anderen aendert; ein
unbeteiligter Scout behaelt seinen Wert; und ein zweiter Abruf
rechnet nichts neu. Ohne die erste waere der Fehler von oben
unentdeckt geblieben, ohne die zweite hiesse „einer hat sich
geaendert" vielleicht „alle".
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -901,6 +901,125 @@ ok(zurueck.status === 200, `Rueckgaengig geht (${zurueck.status})`);
|
||||
ok(zurueckD.stand?.aufgaben.find((a) => a.schluessel === "meeting")?.zahl === 0,
|
||||
"…und der Zaehler steht wieder bei 0");
|
||||
|
||||
/* =====================================================================
|
||||
12c · AB WANN JEMAND PFLICHTIG IST -- ueber den ECHTEN Weg
|
||||
=====================================================================
|
||||
|
||||
Vorlage, Punkt 09: „Wer erst im Laufe des Monats eine der Rollen
|
||||
bekommt, erhaelt in diesem Monat keine Warnungen."
|
||||
|
||||
WARUM HIER DIE RICHTIGE ROUTE BENUTZT WIRD und nicht ein
|
||||
handgeschriebener Protokolleintrag: Der Pflichtbeginn wird aus dem
|
||||
Protokolltext gelesen (`detail` enthaelt „#<id> <name>: alt ->
|
||||
neu"). Wuerde die Pruefung diesen Text selbst schreiben, pruefte
|
||||
sie ihre eigene Annahme -- und merkte nie, wenn die
|
||||
Personenverwaltung ihn eines Tages anders schreibt.
|
||||
|
||||
DER FEHLER, DEN DAS GEFUNDEN HAT (02.10.2026): Die Abfrage suchte
|
||||
`WHERE person_id = ?`. Im Protokoll steht dort aber der, der die
|
||||
Aenderung GEMACHT hat -- also DogFather. Fuer den Betroffenen fand
|
||||
sie deshalb nie etwas, und bei DogFather schob jede fremde
|
||||
Rollenaenderung SEINEN Pflichtbeginn. Beides sah plausibel aus.
|
||||
===================================================================== */
|
||||
melde("\n=== Ab wann jemand pflichtig ist ===");
|
||||
|
||||
const dbP0 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
|
||||
const vorherStart = dbP0.prepare(
|
||||
"SELECT person_id, erster_monat, gesetzt_am, grund FROM mz_start").all();
|
||||
dbP0.close();
|
||||
const dogiVorher = vorherStart.find((z) => z.person_id === IDS.admin);
|
||||
braucht(vorherStart.length > 0, "es steht noch kein Pflichtbeginn in der Tabelle");
|
||||
|
||||
/* Ein eigener Mensch, dessen Rolle gleich wandert. */
|
||||
const WANDER_CODE = "CODE-Z80-000001";
|
||||
const dbW = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
{
|
||||
const salz = randomBytes(16).toString("hex");
|
||||
const h = scryptSync(WANDER_CODE, salz, 64,
|
||||
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
dbW.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
|
||||
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
|
||||
.run("Wanderer", "scout", h, salz, 32768,
|
||||
createHmac("sha256", kennSchluessel).update(WANDER_CODE).digest("hex"), ALT);
|
||||
}
|
||||
const idWander = dbW.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
dbW.close();
|
||||
CODES.wander = WANDER_CODE;
|
||||
kekse.wander = await anmelden("wander", "scout");
|
||||
braucht(!!kekse.wander, "der Wanderer kam nicht herein");
|
||||
|
||||
/* Einmal den Stand abrufen, damit sein Pflichtbeginn festgehalten wird. */
|
||||
const wanderVorher = await (await hol("wander", `${API}/stand`)).json();
|
||||
ok(wanderVorher.pflichtig === true,
|
||||
"ein ein Jahr alter Scout ist pflichtig (Ausgangspunkt)");
|
||||
|
||||
/* ---- Jetzt der ECHTE Rollenwechsel, zweimal ---------------------- */
|
||||
const rolleSetzen = (ziel) => schick("admin",
|
||||
`/workspace/api/verwaltung/personen/${idWander}/rolle`, "PUT", { rolle: ziel });
|
||||
|
||||
const rolleWeg = await rolleSetzen("creator");
|
||||
ok(rolleWeg.status === 200, `DogFather macht ihn zum Creator (${rolleWeg.status})`);
|
||||
const zurueckRolle = await rolleSetzen("scout");
|
||||
ok(zurueckRolle.status === 200, `…und wieder zum Scout (${zurueckRolle.status})`);
|
||||
|
||||
/* Das Rollenwechseln meldet alle Sitzungen dieser Person ab -- also
|
||||
noch einmal anmelden, sonst misst der naechste Aufruf nur ein 401. */
|
||||
kekse.wander = await anmelden("wander", "scout");
|
||||
braucht(!!kekse.wander, "nach dem Rollenwechsel kam er nicht wieder herein");
|
||||
|
||||
const wanderNachher = await (await hol("wander", `${API}/stand`)).json();
|
||||
const dbP = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
|
||||
const wanderStart = dbP.prepare(
|
||||
"SELECT erster_monat, grund FROM mz_start WHERE person_id = ?").get(idWander);
|
||||
const dogiNachher = dbP.prepare(
|
||||
"SELECT erster_monat, grund FROM mz_start WHERE person_id = ?").get(IDS.admin);
|
||||
dbP.close();
|
||||
|
||||
ok(wanderStart?.grund === "rollenwechsel",
|
||||
`der Pflichtbeginn kommt jetzt aus SEINEM Rollenwechsel (${wanderStart?.grund})`);
|
||||
ok(wanderStart?.erster_monat === mz.monatPlus(MONAT, 1)
|
||||
|| (HEUTE.slice(8) === "01" && wanderStart?.erster_monat === MONAT),
|
||||
`er ist erst ab dem Folgemonat pflichtig (${wanderStart?.erster_monat})`);
|
||||
ok(wanderNachher.pflichtig === false || HEUTE.slice(8) === "01",
|
||||
"…und bekommt in diesem Monat keine Warnungen mehr");
|
||||
ok(wanderNachher.aufgaben.every((a) => a.stufe === "neutral" || a.stufe === "aus"),
|
||||
"…keine einzige seiner Zeilen warnt");
|
||||
|
||||
/* GEGENPROBE 1: DogFather hat die Aenderung gemacht -- SEIN
|
||||
Pflichtbeginn darf sich davon nicht bewegen. Genau das war der
|
||||
Fehler. */
|
||||
ok(dogiNachher?.erster_monat === dogiVorher?.erster_monat,
|
||||
`DogFathers Pflichtbeginn ist unveraendert (${dogiVorher?.erster_monat}`
|
||||
+ ` -> ${dogiNachher?.erster_monat}) — er hat die Aenderung nur GEMACHT`);
|
||||
|
||||
/* GEGENPROBE 2: Wer gar nicht angefasst wurde, behaelt seinen Wert.
|
||||
Ohne diese Zeile hiesse „der eine hat sich geaendert" vielleicht
|
||||
„alle haben sich geaendert". */
|
||||
const dbP2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
|
||||
const scoutStart = dbP2.prepare(
|
||||
"SELECT erster_monat, grund FROM mz_start WHERE person_id = ?").get(IDS.scout);
|
||||
dbP2.close();
|
||||
const scoutVorher = vorherStart.find((z) => z.person_id === IDS.scout);
|
||||
ok(scoutStart?.erster_monat === scoutVorher?.erster_monat,
|
||||
`ein unbeteiligter Scout behaelt seinen Wert (${scoutStart?.erster_monat})`);
|
||||
|
||||
/* GEGENPROBE 3: Der Eintrag wird NICHT bei jedem Abruf neu gerechnet.
|
||||
Sonst waere der Pflichtbeginn keine Festlegung, sondern eine
|
||||
Momentaufnahme. */
|
||||
await hol("wander", `${API}/stand`);
|
||||
const dbP3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
|
||||
const nochmal = dbP3.prepare(
|
||||
"SELECT gesetzt_am FROM mz_start WHERE person_id = ?").get(idWander);
|
||||
dbP3.close();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await hol("wander", `${API}/stand`);
|
||||
const dbP4 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
|
||||
const undnochmal = dbP4.prepare(
|
||||
"SELECT gesetzt_am FROM mz_start WHERE person_id = ?").get(idWander);
|
||||
dbP4.close();
|
||||
ok(nochmal?.gesetzt_am === undnochmal?.gesetzt_am,
|
||||
"ein zweiter Abruf rechnet ihn nicht neu — er steht fest");
|
||||
|
||||
/* =====================================================================
|
||||
13 · WER GEHT, NIMMT SEINE ZAHLEN NICHT MIT
|
||||
=====================================================================
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/* =====================================================================
|
||||
DER MONATSWECHSEL (02.10.2026)
|
||||
=====================================================================
|
||||
|
||||
WARUM ES DIESE PRUEFUNG GIBT, obwohl pruef-manager-ziele schon 206
|
||||
Dinge misst: Weil keines davon den Monatswechsel erlebt hat.
|
||||
|
||||
Die Vorlage sagt: „Am 1. jedes Monats um 00:00 Uhr starten alle
|
||||
Zaehler automatisch bei 0." Das ist der zentrale Weg dieser Kachel,
|
||||
er laeuft ohne Zutun, er betrifft alle gleichzeitig, und er war am
|
||||
Tag der Auslieferung noch nie gelaufen -- der erste Oktober lag
|
||||
davor, der erste November liegt dahinter.
|
||||
|
||||
Genau diese Lage gab es in diesem Haus schon einmal: Am 01.09.2026
|
||||
war in RunOne der Umbau „Hashkette beginnt bei der Festschreibung"
|
||||
seit Tagen live und NIE gelaufen -- null festgeschriebene
|
||||
Buchungen. Der zentrale Weg war unerprobt, ausgerechnet der
|
||||
einzige, den man nicht rueckgaengig machen kann. Die Lehre stand
|
||||
danach in der Projektnotiz: „Was sich nicht zuruecknehmen laesst,
|
||||
wird vorher auf einer Kopie durchgespielt."
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WIE HIER DIE ZEIT VERSTELLT WIRD -- und was das NICHT beweist
|
||||
|
||||
Der ganze Code rechnet ueber `new Date()` und `Date.now()`. Diese
|
||||
Datei setzt davor eine Huelle, die beide um einen festen Betrag
|
||||
verschiebt. Damit laeuft der ECHTE Code durch einen echten
|
||||
Monatswechsel, ohne dass irgendetwas umgebaut werden muesste.
|
||||
|
||||
WAS DAMIT NICHT GEMESSEN WIRD, und das gehoert dazugesagt:
|
||||
SQLite rechnet in den Triggern mit `datetime('now')` und sieht
|
||||
weiterhin die wahre Uhr. Fuer die Freigabe alter Monate heisst das:
|
||||
Ihr Ablaufzeitpunkt liegt in der verschobenen Zukunft und damit
|
||||
immer in der Zukunft. Dass die Freigabe nach zwei Minuten
|
||||
verfaellt, prueft deshalb weiterhin pruef-manager-ziele, nicht
|
||||
diese Datei.
|
||||
|
||||
DIE HUELLE STEHT GANZ OBEN, vor jedem Import. Ein Modul, das beim
|
||||
Laden einmal `new Date()` aufruft, wuerde sonst die echte Zeit
|
||||
festhalten -- und die Pruefung maesse danach zwei verschiedene
|
||||
Uhren gegeneinander.
|
||||
===================================================================== */
|
||||
|
||||
/* ---------- Die Uhr ---------------------------------------------- */
|
||||
const ECHTE_ZEIT = Date;
|
||||
let versatz = 0;
|
||||
|
||||
globalThis.Date = class extends ECHTE_ZEIT {
|
||||
constructor(...a) {
|
||||
if (a.length === 0) super(ECHTE_ZEIT.now() + versatz);
|
||||
else super(...a);
|
||||
}
|
||||
static now() { return ECHTE_ZEIT.now() + versatz; }
|
||||
};
|
||||
|
||||
/** Setzt die Uhr auf einen bestimmten Zeitpunkt. */
|
||||
function uhrAuf(iso) {
|
||||
versatz = 0; // erst zurueck auf echt
|
||||
versatz = new ECHTE_ZEIT(iso).getTime() - ECHTE_ZEIT.now();
|
||||
return iso;
|
||||
}
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-mw-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
import { notbremse } from "./helfer-notbremse.mjs";
|
||||
const { eigenerPort } = await import("./helfer-port.mjs");
|
||||
const PORT = await eigenerPort(import.meta, "die Pruefung des Monatswechsels");
|
||||
|
||||
process.env.PORT = `${PORT}`;
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
/* OKTOBER, UND ZWAR MITTEN DRIN. Der 20. ist bewusst gewaehlt: nach
|
||||
dem 16. (also in der Orange-Zone) und vor den letzten sieben Tagen.
|
||||
So ist am Ausgangspunkt eine Warnung aktiv, die nach dem Wechsel
|
||||
verschwunden sein MUSS -- waere der Ausgangspunkt neutral, bewiese
|
||||
das Verschwinden nichts. */
|
||||
uhrAuf("2026-10-20T09:00:00+02:00");
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
await import("./index.js");
|
||||
notbremse(300_000, "pruef-monatswechsel");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = `http://127.0.0.1:${PORT}`;
|
||||
const API = "/workspace/api/manager-ziele";
|
||||
|
||||
let fehler = 0, geprueft = 0;
|
||||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
const melde = (t) => console.log(t);
|
||||
function braucht(bedingung, was) {
|
||||
if (bedingung) return;
|
||||
console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`);
|
||||
console.log("Das ist KEIN Befund am Code, sondern eine Voraussetzung,");
|
||||
console.log("die sich nicht herstellen liess.\n");
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* ---------- Leute ------------------------------------------------- */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const kennSchluessel = randomBytes(32).toString("hex");
|
||||
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
|
||||
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
|
||||
.run("code_kennung_schluessel", kennSchluessel, new Date().toISOString());
|
||||
|
||||
/* Ein Jahr alt -- damit alle vom ersten Tag an pflichtig sind. Waeren
|
||||
sie frisch angelegt, waere jede Ampel neutral und die Pruefung
|
||||
saehe gruen aus, ohne etwas gemessen zu haben. */
|
||||
const ALT = "2025-10-01T08:00:00Z";
|
||||
const CODES = {}, IDS = {};
|
||||
function anlegen(name, rolle, code) {
|
||||
const salz = randomBytes(16).toString("hex");
|
||||
const h = scryptSync(code, salz, 64,
|
||||
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
|
||||
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, h, salz, 32768,
|
||||
createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
for (const [schluessel, rolle, name] of [
|
||||
["admin", "admin", "Filipe"],
|
||||
["fleissig", "scout", "Fleissig"],
|
||||
["faul", "scout", "Faul"],
|
||||
]) {
|
||||
CODES[schluessel] = `CODE-MW${schluessel.slice(0, 3).toUpperCase()}-01`;
|
||||
IDS[schluessel] = anlegen(name, rolle, CODES[schluessel]);
|
||||
}
|
||||
d.close();
|
||||
|
||||
/* ---------- Anmelden ---------------------------------------------- */
|
||||
const kekse = {};
|
||||
for (const schluessel of Object.keys(CODES)) {
|
||||
const rolle = schluessel === "admin" ? "admin" : "scout";
|
||||
const a = await fetch(`${BASIS}/workspace/api/anmelden`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Origin: BASIS },
|
||||
body: JSON.stringify({ rolle, code: CODES[schluessel] }),
|
||||
});
|
||||
if (a.status === 200) {
|
||||
kekse[schluessel] = (a.headers.getSetCookie?.() || [])
|
||||
.map((z) => z.split(";")[0]).join("; ");
|
||||
}
|
||||
}
|
||||
braucht(Object.keys(kekse).length === 3, "nicht alle drei kamen herein");
|
||||
|
||||
const hol = (wer, pfad, einst = {}) => fetch(BASIS + pfad, {
|
||||
...einst,
|
||||
headers: {
|
||||
Cookie: kekse[wer] || "", Origin: BASIS,
|
||||
...(einst.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(einst.headers || {}),
|
||||
},
|
||||
});
|
||||
const schick = (wer, pfad, art, rumpf) =>
|
||||
hol(wer, pfad, { method: art, body: JSON.stringify(rumpf) });
|
||||
|
||||
/* =====================================================================
|
||||
1 · OKTOBER: ein voller und ein halber Monat
|
||||
===================================================================== */
|
||||
melde("\n=== Oktober, am 20. ===");
|
||||
const okt = await (await hol("fleissig", `${API}/stand`)).json();
|
||||
ok(okt.monat === "2026-10", `wir sind im Oktober (${okt.monat})`);
|
||||
ok(okt.tag === 20, `am 20. (${okt.tag})`);
|
||||
ok(okt.aufgaben.find((a) => a.schluessel === "creator").stufe === "orange",
|
||||
"und die offene Aufgabe steht auf ORANGE — Ausgangspunkt mit Warnung");
|
||||
|
||||
/* Fleissig macht alles fertig. */
|
||||
const erledigt = await schick("fleissig", `${API}/eintraege`, "POST",
|
||||
{ aufgabe: "creator", datum: "2026-10-18", namen: ["@a", "@b", "@c"] });
|
||||
ok((await erledigt.json()).angelegt.length === 3, "drei Creator eingetragen");
|
||||
for (const [aufgabe, extra, mal] of [["meeting", {}, 2], ["schulung", { art: "talk" }, 2],
|
||||
["werbung", { art: "agentur", link: "https://tiktok.com/@x/video/9" }, 1]]) {
|
||||
for (let i = 0; i < mal; i++) {
|
||||
await schick("fleissig", `${API}/eintraege`, "POST",
|
||||
{ aufgabe, datum: `2026-10-1${i}`, ...extra,
|
||||
...(aufgabe === "werbung" ? { link: `https://tiktok.com/@x/video/${i}9` } : {}) });
|
||||
}
|
||||
}
|
||||
/* Faul macht genau einen Creator. */
|
||||
await schick("faul", `${API}/eintraege`, "POST",
|
||||
{ aufgabe: "creator", datum: "2026-10-05", namen: ["@nurEiner"] });
|
||||
|
||||
const oktFleissig = await (await hol("fleissig", `${API}/stand`)).json();
|
||||
const oktFaul = await (await hol("faul", `${API}/stand`)).json();
|
||||
ok(oktFleissig.gesamt.erledigt === 8 && oktFleissig.gesamt.offen === 0,
|
||||
`Fleissig hat alles (${oktFleissig.gesamt.erledigt} von ${oktFleissig.gesamt.ziel})`);
|
||||
ok(oktFaul.gesamt.erledigt === 1 && oktFaul.gesamt.offen === 4,
|
||||
`Faul hat eins von acht (${oktFaul.gesamt.erledigt} von ${oktFaul.gesamt.ziel})`);
|
||||
|
||||
/* DogFather setzt die Zielzahlen fuer den naechsten Monat hoch. */
|
||||
const gesetzt = await schick("admin", `${API}/ziele`, "PUT",
|
||||
{ ziele: { scout: { creator: 5, meeting: 1 } } });
|
||||
const gesetztD = await gesetzt.json();
|
||||
ok(gesetzt.status === 200 && gesetztD.monat === "2026-11",
|
||||
`neue Zielzahlen gelten ab November (${gesetztD.monat})`);
|
||||
ok((await (await hol("fleissig", `${API}/stand`)).json())
|
||||
.aufgaben.find((a) => a.schluessel === "creator").ziel === 3,
|
||||
"…und der Oktober bleibt bei 3 — die Aenderung wirkt nicht rueckwaerts");
|
||||
|
||||
/* =====================================================================
|
||||
2 · DER WECHSEL
|
||||
===================================================================== */
|
||||
melde("\n=== Und jetzt ist es der 1. November, 00:05 Uhr ===");
|
||||
uhrAuf("2026-11-01T00:05:00+01:00");
|
||||
|
||||
const nov = await (await hol("fleissig", `${API}/stand`)).json();
|
||||
ok(nov.monat === "2026-11", `der Monat ist gewechselt (${nov.monat})`);
|
||||
ok(nov.tag === 1, `wir sind am 1. (${nov.tag})`);
|
||||
ok(nov.aufgaben.every((a) => a.zahl === 0),
|
||||
"ALLE Zaehler stehen bei 0 — ohne dass jemand etwas angefasst haette");
|
||||
ok(nov.aufgaben.every((a) => a.stufe === "neutral" || a.stufe === "aus"),
|
||||
"keine einzige Warnung am ersten Tag");
|
||||
ok(nov.aufgaben.find((a) => a.schluessel === "creator").ziel === 5,
|
||||
`die neue Zielzahl gilt jetzt (${nov.aufgaben.find((a) => a.schluessel === "creator").ziel})`);
|
||||
ok(nov.aufgaben.find((a) => a.schluessel === "meeting").ziel === 1,
|
||||
"…auch die zweite geaenderte");
|
||||
ok(nov.aufgaben.find((a) => a.schluessel === "schulung").ziel === 2,
|
||||
"…und was nicht geaendert wurde, bleibt bei der Vorgabe");
|
||||
|
||||
/* DIE ALTEN DATEN SIND NICHT WEG -- das ist der Kern der Regel
|
||||
„Zuordnung zu Monat/Jahr, nicht Loeschen von Daten". */
|
||||
const oktNochDa = await (await hol("fleissig",
|
||||
`${API}/eintraege?monat=2026-10&aufgabe=creator`)).json();
|
||||
ok(oktNochDa.eintraege.length === 3,
|
||||
`die Oktober-Eintraege stehen noch da (${oktNochDa.eintraege.length})`);
|
||||
ok(oktNochDa.bearbeitbar === false,
|
||||
"…und sind fuer den Scout nicht mehr bearbeitbar");
|
||||
|
||||
const dbL = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
|
||||
const lage = dbL.prepare("SELECT monat FROM mz_lage WHERE eins = 1").get();
|
||||
const oktZeilen = dbL.prepare(
|
||||
"SELECT COUNT(*) n FROM mz_eintrag WHERE monat = '2026-10'").get().n;
|
||||
dbL.close();
|
||||
ok(lage.monat === "2026-11", `die Sperre kennt den neuen Monat (${lage.monat})`);
|
||||
ok(oktZeilen === 9, `neun Oktober-Zeilen unveraendert in der Datenbank (${oktZeilen})`);
|
||||
|
||||
/* GEGENPROBE: Der Oktober ist wirklich zu. */
|
||||
const altAendern = await schick("fleissig", `${API}/eintrag/${oktNochDa.eintraege[0].id}`,
|
||||
"PATCH", { datum: "2026-10-19", name: "@heimlich" });
|
||||
ok(altAendern.status === 409,
|
||||
`der Scout kommt im Oktober nicht mehr hinein (${altAendern.status})`);
|
||||
const altAdmin = await schick("admin", `${API}/eintrag/${oktNochDa.eintraege[0].id}`,
|
||||
"PATCH", { datum: "2026-10-19", name: "@berichtigt" });
|
||||
ok(altAdmin.status === 200, `DogFather schon (${altAdmin.status})`);
|
||||
|
||||
/* =====================================================================
|
||||
3 · DER VERLAUF
|
||||
===================================================================== */
|
||||
melde("\n=== Der Verlauf zeigt den Oktober ===");
|
||||
const verlaufF = await (await hol("fleissig", `${API}/verlauf`)).json();
|
||||
const verlaufA = await (await hol("faul", `${API}/verlauf`)).json();
|
||||
const oktF = verlaufF.monate.find((m) => m.monat === "2026-10");
|
||||
const oktA = verlaufA.monate.find((m) => m.monat === "2026-10");
|
||||
ok(!!oktF, "der Oktober steht im Verlauf");
|
||||
ok(oktF?.aufgaben.every((a) => a.erreicht === true),
|
||||
"bei Fleissig ist jede Aufgabe als erreicht vermerkt");
|
||||
ok(oktA?.aufgaben.filter((a) => a.erreicht === false).length === 4,
|
||||
`bei Faul sind vier als nicht erreicht vermerkt (${oktA?.aufgaben.filter((a) => a.erreicht === false).length})`);
|
||||
ok(oktF?.aufgaben.find((a) => a.schluessel === "creator")?.ziel === 3,
|
||||
"…und gemessen wird am OKTOBER-Ziel (3), nicht am neuen (5)");
|
||||
ok(!verlaufF.monate.some((m) => m.monat === "2026-11"),
|
||||
"der laufende Monat steht NICHT im Verlauf — er ist noch nicht vorbei");
|
||||
|
||||
/* =====================================================================
|
||||
4 · DIE ERINNERUNG AM ERSTEN
|
||||
===================================================================== */
|
||||
melde("\n=== Die Meldung zum Monatsanfang ===");
|
||||
const mz = await import("./workspace-manager-ziele.js");
|
||||
const rufe = mz.zielRufe(new Date());
|
||||
ok(rufe.length === 3,
|
||||
`alle drei Pflichtigen bekommen eine Meldung (${rufe.length})`);
|
||||
ok(rufe.every((r) => r.titel === "Neuer Monat"),
|
||||
'…und zwar „Neuer Monat"');
|
||||
ok(rufe.every((r) => r.merkmal === "mz:2026-11-01"),
|
||||
"…mit dem Tag im Merkmal, also hoechstens eine je Person");
|
||||
ok(rufe.every((r) => /starten bei 0/.test(r.text)),
|
||||
"…und dem Satz aus der Vorlage");
|
||||
|
||||
/* GEGENPROBE: Wer im Oktober alles geschafft hatte, bekommt im
|
||||
November trotzdem eine -- es ist ein neuer Monat. Haette der alte
|
||||
Glueckwunsch die Erinnerungen dauerhaft abgestellt, waere Fleissig
|
||||
hier still, und das waere der schlimmste denkbare Fehler: Der
|
||||
Beste bekaeme als Einziger keine Erinnerung mehr. */
|
||||
ok(rufe.some((r) => r.personId === IDS.fleissig),
|
||||
"auch der, der den Oktober voll hatte — der Glueckwunsch galt nur fuer den Oktober");
|
||||
|
||||
/* =====================================================================
|
||||
5 · NOCH EIN WECHSEL -- und ein Monat ohne eigene Zielzahl
|
||||
===================================================================== */
|
||||
melde("\n=== Und weiter in den Dezember ===");
|
||||
uhrAuf("2026-12-03T10:00:00+01:00");
|
||||
const dez = await (await hol("fleissig", `${API}/stand`)).json();
|
||||
ok(dez.monat === "2026-12", `Dezember (${dez.monat})`);
|
||||
ok(dez.aufgaben.find((a) => a.schluessel === "creator").ziel === 5,
|
||||
"ein Monat ohne eigene Festlegung erbt die letzte geltende (5)");
|
||||
ok(dez.aufgaben.every((a) => a.zahl === 0), "und faengt wieder bei 0 an");
|
||||
|
||||
const verlaufDez = await (await hol("fleissig", `${API}/verlauf`)).json();
|
||||
ok(verlaufDez.monate.length === 2,
|
||||
`der Verlauf kennt jetzt zwei Monate (${verlaufDez.monate.length})`);
|
||||
ok(verlaufDez.monate[0].monat === "2026-11" && verlaufDez.monate[1].monat === "2026-10",
|
||||
"…der juengste zuerst");
|
||||
ok(verlaufDez.monate[0].aufgaben.every((a) => a.erreicht === false),
|
||||
"der leere November steht als nicht erreicht da");
|
||||
|
||||
/* UND DER OKTOBER HAT SICH NICHT VERAENDERT, obwohl inzwischen zwei
|
||||
Monatswechsel und eine Zielzahlaenderung dazwischenliegen. Das ist
|
||||
die eigentliche Zusage des eingefrorenen Ziels. */
|
||||
const oktSpaet = verlaufDez.monate.find((m) => m.monat === "2026-10");
|
||||
ok(oktSpaet?.aufgaben.find((a) => a.schluessel === "creator")?.ziel === 3,
|
||||
`der Oktober steht zwei Monate spaeter immer noch auf 3 (${oktSpaet?.aufgaben.find((a) => a.schluessel === "creator")?.ziel})`);
|
||||
ok(oktSpaet?.aufgaben.every((a) => a.erreicht === true),
|
||||
"…und gilt weiterhin als vollstaendig erreicht");
|
||||
|
||||
/* ---------- Die Team-Uebersicht eines vergangenen Monats ---------- */
|
||||
const teamOkt = await (await hol("admin", `${API}/team?monat=2026-10`)).json();
|
||||
const fleissigOkt = teamOkt.leute.find((p) => p.id === IDS.fleissig);
|
||||
ok(fleissigOkt?.gesamt.erledigt === 8,
|
||||
`DogFather sieht den Oktober unveraendert (${fleissigOkt?.gesamt.erledigt} von ${fleissigOkt?.gesamt.ziel})`);
|
||||
ok(fleissigOkt?.stufe === "gruen", "…und Fleissig steht dort auf gruen");
|
||||
|
||||
/* =====================================================================
|
||||
SCHLUSS
|
||||
===================================================================== */
|
||||
versatz = 0;
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -268,23 +268,99 @@ function zielFuer(monat, rolle, aufgabe) {
|
||||
bei jeder Abfrage daraus gerechnet, verschöbe ein Aufräumlauf
|
||||
rückwirkend, seit wann jemand verantwortlich ist.
|
||||
===================================================================== */
|
||||
/** Wann hat sich DIE ROLLE DIESER PERSON zuletzt geändert?
|
||||
*
|
||||
* BERICHTIGT AM 02.10.2026 -- und das war ein echter Fehler, kein
|
||||
* Schönheitsfleck.
|
||||
*
|
||||
* Hier stand `WHERE person_id = ?`. Im Protokoll steht unter
|
||||
* `person_id` aber der, der die Änderung GEMACHT hat (also
|
||||
* DogFather), nicht der, dessen Rolle sich geändert hat -- siehe
|
||||
* workspace-personen.js: `protokolliere("rolle_geaendert",
|
||||
* { personId: req.person.id, … detail: "#<id> <name>: alt -> neu" })`.
|
||||
*
|
||||
* Die Abfrage hat damit zwei Dinge falsch gemacht, und beide sahen
|
||||
* richtig aus:
|
||||
*
|
||||
* 1. Für den BETROFFENEN fand sie nie etwas -- sein Pflichtbeginn
|
||||
* wurde aus dem Anlegedatum gerechnet, als hätte er die Rolle
|
||||
* seit jeher.
|
||||
* 2. Für DogFather fand sie JEDE Rollenänderung, die er an anderen
|
||||
* vorgenommen hat, und schob SEINEN Pflichtbeginn davon. In der
|
||||
* echten Datenbank stand deshalb bei ihm „grund: rollenwechsel"
|
||||
* -- ein plausibler Wert, der aus der falschen Zeile kam.
|
||||
*
|
||||
* Gefunden hat es nicht die Prüfung, sondern ein Blick in die Quelle
|
||||
* beim Nachlesen, wie das Protokoll geschrieben wird. Das ist genau
|
||||
* die Sorte Fehler, vor der die Hausnotiz warnt: Eine Prüfung, die
|
||||
* grün ist, weil sie das Falsche misst.
|
||||
*
|
||||
* JETZT ÜBER DAS `detail`, denn dort steht die betroffene Nummer.
|
||||
* Das ist eine Textsuche in einem Protokolltext und damit die
|
||||
* schwächste Stelle hier -- deshalb wird das Ergebnis nicht jedes
|
||||
* Mal neu gerechnet, sondern in `mz_start` festgehalten. */
|
||||
function rollenWechselAm(personId) {
|
||||
/* DAS MUSTER WIRD HIER GEBAUT UND NICHT IN SQL ZUSAMMENGESETZT.
|
||||
|
||||
Hier stand `detail LIKE ('#' || ? || ' %')`, und das hat NIE
|
||||
getroffen -- ohne Fehler, ohne Warnung, einfach immer leer.
|
||||
Nachgemessen in einer leeren Datenbank:
|
||||
|
||||
SELECT ('#' || ? || ' %') mit der Zahl 42 -> '#42.0 %'
|
||||
|
||||
`node:sqlite` bindet eine JavaScript-Zahl als REAL, und SQLite
|
||||
macht aus 42.0 beim Verketten den Text „42.0". Gesucht wurde
|
||||
also nach „#42.0 ", gespeichert ist „#42 ". Mit derselben Zahl
|
||||
als Zeichenkette (`"42"`) stimmt es sofort.
|
||||
|
||||
Das ist genau die Sorte Fehler, vor der die Hausnotiz warnt: Die
|
||||
Abfrage lief, sie war fehlerfrei, und sie war wertlos. Gefunden
|
||||
hat sie nicht das Lesen, sondern eine Pruefung, die den ECHTEN
|
||||
Weg benutzt hat statt den Protokolltext selbst zu schreiben.
|
||||
|
||||
`Number.isInteger` davor ist kein Schmuck: Eine Zeichenkette mit
|
||||
`%` darin waere sonst ein Platzhalter im Muster. */
|
||||
if (!Number.isInteger(Number(personId))) return null;
|
||||
try {
|
||||
return db().prepare(
|
||||
`SELECT zeitpunkt FROM protokoll
|
||||
WHERE aktion = 'rolle_geaendert' AND detail LIKE ?
|
||||
ORDER BY zeitpunkt DESC LIMIT 1`)
|
||||
.get(`#${Number(personId)} %`)?.zeitpunkt || null;
|
||||
} catch (fehler) {
|
||||
console.error("[manager-ziele] Rollenwechsel suchen:", fehler?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function startMonatFuer(personId) {
|
||||
const d = db();
|
||||
const da = d.prepare("SELECT erster_monat FROM mz_start WHERE person_id = ?").get(personId);
|
||||
if (da) return da.erster_monat;
|
||||
const da = d.prepare(
|
||||
"SELECT erster_monat, gesetzt_am FROM mz_start WHERE person_id = ?").get(personId);
|
||||
const wechsel = rollenWechselAm(personId);
|
||||
|
||||
/* NEU GERECHNET WIRD NUR, WENN SICH SEITHER ETWAS GEÄNDERT HAT.
|
||||
Vorher stand hier `if (da) return` -- einmal gesetzt, für immer.
|
||||
Damit bekam jemand, der die Rolle verliert und später
|
||||
zurückbekommt, KEINEN Schonmonat mehr, obwohl die Vorlage ihm
|
||||
genau den zusichert („Wer erst im Laufe des Monats eine der
|
||||
Rollen bekommt …"). Es wäre erst in Monaten aufgefallen, und
|
||||
dann als „der wird zu Unrecht gemahnt". */
|
||||
if (da && !(wechsel && wechsel > da.gesetzt_am)) return da.erster_monat;
|
||||
|
||||
const p = d.prepare("SELECT erstellt FROM personen WHERE id = ?").get(personId);
|
||||
if (!p) return ERSTER_MONAT;
|
||||
if (!p) return da?.erster_monat || ERSTER_MONAT;
|
||||
|
||||
/* Der jüngste Rollenwechsel schlägt das Anlegedatum: Wer seit einem
|
||||
Jahr Creator war und gestern Scout wurde, ist nicht seit einem
|
||||
Jahr pflichtig. */
|
||||
const wechsel = d.prepare(
|
||||
`SELECT zeitpunkt FROM protokoll
|
||||
WHERE person_id = ? AND aktion = 'rolle_geaendert'
|
||||
ORDER BY zeitpunkt DESC LIMIT 1`).get(personId);
|
||||
Jahr pflichtig.
|
||||
|
||||
const quelle = wechsel?.zeitpunkt || p.erstellt;
|
||||
JEDER Rollenwechsel zählt, auch der von Scout zu Manager. Das ist
|
||||
eine Entscheidung und keine Nachlässigkeit: Seit die Zielzahlen
|
||||
je Rolle gelten, bekommt man mit einer neuen Rolle wirklich neue
|
||||
Pflichten -- und Rollen ändert ohnehin nur DogFather, hier ist
|
||||
also nichts zu holen. */
|
||||
const quelle = wechsel || p.erstellt;
|
||||
let grund = wechsel ? "rollenwechsel" : "angelegt";
|
||||
let monat = ERSTER_MONAT;
|
||||
try {
|
||||
@@ -295,8 +371,13 @@ function startMonatFuer(personId) {
|
||||
}
|
||||
if (monat < ERSTER_MONAT) { monat = ERSTER_MONAT; grund += "+erster_monat"; }
|
||||
|
||||
d.prepare(`INSERT OR IGNORE INTO mz_start (person_id, erster_monat, gesetzt_am, grund)
|
||||
VALUES (?,?,?,?)`).run(personId, monat, jetzt(), grund);
|
||||
d.prepare(`INSERT INTO mz_start (person_id, erster_monat, gesetzt_am, grund)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(person_id) DO UPDATE SET
|
||||
erster_monat = excluded.erster_monat,
|
||||
gesetzt_am = excluded.gesetzt_am,
|
||||
grund = excluded.grund`)
|
||||
.run(personId, monat, jetzt(), grund);
|
||||
return monat;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user