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 <[email protected]>
This commit is contained in:
@@ -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.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user