Angebote: der Kunde sagt mit einem Klick zu
DER LEITGEDANKE: SO WENIG TIPPEN WIE MOEGLICH Niemand schreibt hier den Leistungsumfang. Er entsteht aus der Aufgabenvorlage des Pakets -- denselben 92 Punkten, nach denen spaeter gearbeitet wird. Der Nebeneffekt ist wichtiger als die gesparte Tipparbeit: Was im Angebot steht, IST die Arbeitsliste. Ein von Hand geschriebenes Angebot und eine getrennt gepflegte Aufgabenliste laufen unweigerlich auseinander -- und dann steht im Angebot etwas, das niemand abarbeitet. Zu tun bleibt: Paket waehlen, Preis bestaetigen. Beides ist vorbelegt, Laufzeit und Ablaufdatum werden gerechnet. DER WORTLAUT WIRD BEIM ABSENDEN EINGEFROREN Ein angenommenes Angebot ist ein Vertrag. Bei einem Streit zaehlt, WAS dem Kunden gezeigt wurde, als er zusagte. Wuerde der Text bei jeder Ansicht neu aus den Vorlagen erzeugt, staende nach der naechsten Vorlagenaenderung etwas anderes da als damals. Die Zusage wird mit Zeitpunkt und (gehashter) Herkunft belegt -- die Beweislast liegt beim Unternehmer. WAS AUTOMATISCH PASSIERT Angebot raus -> Anfrage steht auf 'angebot' (der Wunsch: 'wenn ich ein angebot rausschicke soll der automatisch das erkennen'). Zusage -> Projekt angelegt, Aufgabenliste eingesetzt, Anzahlung als offene Rechnung erzeugt, Anfrage auf 'angenommen', Benachrichtigung an mich. Die UHR startet dabei NICHT. Sie startet erst mit dem Zahlungseingang -- das ist die Regel aus dem vorigen Schritt, und sie gilt auch dann, wenn der Kunde selbst zugesagt hat. Genau dieser Punkt wird ausdruecklich geprueft: Es fuehlt sich richtig an, mit der Zusage loszulegen. ZWEI FEHLER IN DER GELDANZEIGE GEFUNDEN Beide fielen erst auf, als das erste Angebot ueber tausend Euro entstand -- alle frueheren Testbetraege lagen darunter: - Kein Tausenderpunkt: 1490 Euro erschienen als '1490,00 €'. - Das Minuszeichen ging verloren: Math.trunc(-0.5) ergibt -0, und -0 schreibt sich als '0'. Eine ERSTATTUNG von 50 Cent erschien damit als '0,50 €' -- also wie eine Forderung. Ausgerechnet beim Abbruch mit Rueckzahlung waere das der falsche Ort fuer einen Anzeigefehler. Bewusst von Hand statt ueber Intl.NumberFormat: Die Ausgabe muss auf dem Server und im Browser zeichengleich sein -- ein Betrag, der in der Verwaltung anders aussieht als im Portal, saet Zweifel an der Zahl. Geprueft: 56 zu den Angeboten, 53 zur Automatik (inkl. der neun Geldpruefungen), 70 zur Annahme, 28 zur Suche -- alle gegen eine echte Datenbank. Darunter: zweiter Klick legt nichts doppelt an, abgelaufene Angebote lassen sich nicht mehr annehmen (ein Angebot, das man sieht aber nicht annehmen kann, waere eine Falle), ein fremder Kunde erfaehrt nicht einmal, dass ein Angebot existiert. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -60,6 +60,8 @@ const MIGRATION_FILES = [
|
||||
// Die Uhr laeuft erst bei Anzahlung; Abbruch mit Erstattung;
|
||||
// Benachrichtigungen fuer die Verwaltung.
|
||||
"0016_webdesign_automatik.sql",
|
||||
// Angebote mit Zusage im Portal.
|
||||
"0017_webdesign_angebote.sql",
|
||||
];
|
||||
|
||||
function alreadyApplied(name) {
|
||||
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
jede Abfrage filtert auf die kunde_id aus der Sitzung. */
|
||||
import {
|
||||
portalAnmelden, portalAbmelden, portalEinladungPruefen, portalPasswortSetzen,
|
||||
portalUebersicht, portalProjekt, portalAenderungEinreichen,
|
||||
portalUebersicht, portalAngebotZusagen, portalAngebotAblehnen, portalProjekt, portalAenderungEinreichen,
|
||||
portalAenderungEntscheiden, portalNachricht, portalProfilSpeichern,
|
||||
} from "./routes/webdesign-portal.js";
|
||||
/* Kunden und Projekte verwalten (Masterplan S.13). Hier entstehen
|
||||
@@ -131,6 +131,8 @@ import {
|
||||
abbruchVorschau, projektAbbrechen,
|
||||
} from "./routes/webdesign-annahme.js";
|
||||
import { suche } from "./routes/webdesign-suche.js";
|
||||
import { angebotVorschau, angebotSenden, angebotZurueckziehen }
|
||||
from "./routes/webdesign-angebote.js";
|
||||
/* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen,
|
||||
damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH.
|
||||
Sie muessen beim Start EINMAL entschluesselt werden, weil die
|
||||
@@ -314,6 +316,11 @@ app.post("/webdesign/admin/anfragen/:id/annehmen", wrap(anfrageAnnehmen));
|
||||
app.get("/webdesign/admin/anfragen/:id/absage-vorschau", wrap(absageVorschau));
|
||||
app.post("/webdesign/admin/anfragen/:id/ablehnen", wrap(anfrageAblehnen));
|
||||
|
||||
/* Angebot: Vorschau (liest nur), Absenden, Zurueckziehen. */
|
||||
app.get("/webdesign/admin/anfragen/:id/angebot-vorschau", wrap(angebotVorschau));
|
||||
app.post("/webdesign/admin/anfragen/:id/angebot", wrap(angebotSenden));
|
||||
app.post("/webdesign/admin/angebote/:id/zurueckziehen", wrap(angebotZurueckziehen));
|
||||
|
||||
/* ---------- Kundenportal ----------
|
||||
Oeffentlich sind nur Anmeldung und das Einloesen eines Einladungslinks.
|
||||
Alles Weitere prueft die Kundensitzung im Handler selbst. */
|
||||
@@ -323,6 +330,8 @@ app.get("/webdesign/portal/einladung", wrap(portalEinladungPruefen));
|
||||
app.post("/webdesign/portal/passwort", wrap(portalPasswortSetzen));
|
||||
app.get("/webdesign/portal/uebersicht", wrap(portalUebersicht));
|
||||
app.get("/webdesign/portal/projekt/:id", wrap(portalProjekt));
|
||||
app.post("/webdesign/portal/angebote/:id/zusagen", wrap(portalAngebotZusagen));
|
||||
app.post("/webdesign/portal/angebote/:id/ablehnen", wrap(portalAngebotAblehnen));
|
||||
app.post("/webdesign/portal/aenderung", wrap(portalAenderungEinreichen));
|
||||
app.post("/webdesign/portal/aenderung/:id/entscheiden", wrap(portalAenderungEntscheiden));
|
||||
app.post("/webdesign/portal/nachricht", wrap(portalNachricht));
|
||||
|
||||
@@ -189,7 +189,32 @@ export function spracheOderDe(wert) {
|
||||
/* Betrag in Cent -> lesbarer Text. Nie mit Gleitkomma rechnen. */
|
||||
export function centZuText(cent, waehrung = "EUR") {
|
||||
if (cent == null) return null;
|
||||
const ganz = Math.trunc(cent / 100);
|
||||
const rest = String(Math.abs(cent % 100)).padStart(2, "0");
|
||||
return `${ganz},${rest} ${waehrung === "EUR" ? "€" : waehrung}`;
|
||||
|
||||
/* ZWEI FEHLER, DIE HIER LANGE STECKTEN (gefunden 24.08.2026)
|
||||
|
||||
1. KEIN TAUSENDERPUNKT. Ein Angebot ueber 1490 Euro erschien als
|
||||
"1490,00 €". Bei einem Betrag, den jemand bezahlen soll, ist das
|
||||
nicht nur unschoen -- man verliest sich. Aufgefallen erst, als
|
||||
das erste Angebot ueber tausend Euro entstand; alle frueheren
|
||||
Testbetraege lagen darunter.
|
||||
|
||||
2. DAS MINUSZEICHEN GING VERLOREN. Math.trunc(-0.5) ergibt -0, und
|
||||
-0 schreibt sich als "0". Eine Erstattung von 50 Cent erschien
|
||||
damit als "0,50 €" -- also wie eine Forderung. Deshalb wird das
|
||||
Vorzeichen jetzt getrennt vom Betrag behandelt, statt sich auf
|
||||
die Ganzzahl zu verlassen.
|
||||
|
||||
Bewusst von Hand statt ueber Intl.NumberFormat: Die Ausgabe soll auf
|
||||
dem Server und im Browser Zeichen fuer Zeichen gleich sein. Intl
|
||||
haengt an der eingestellten Sprache -- und ein Betrag, der in der
|
||||
Verwaltung anders aussieht als im Portal, saet Zweifel an der Zahl. */
|
||||
const negativ = cent < 0;
|
||||
const absolut = Math.abs(cent);
|
||||
const ganz = Math.floor(absolut / 100);
|
||||
const rest = String(absolut % 100).padStart(2, "0");
|
||||
|
||||
/* Dreiergruppen von rechts. */
|
||||
const gruppiert = String(ganz).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
|
||||
return `${negativ ? "-" : ""}${gruppiert},${rest} ${waehrung === "EUR" ? "€" : waehrung}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
/* =====================================================================
|
||||
webdesign-angebote.js — ein Angebot schicken, das der Kunde anklickt
|
||||
|
||||
DER LEITGEDANKE: SO WENIG TIPPEN WIE MÖGLICH
|
||||
|
||||
Wunsch vom 24.08.2026: "das zum angebot rausschicken für mich soll
|
||||
auch perfektionniert werden so dass es für mich am einfachsten ist
|
||||
und ich so wenig wie möglich schreiben muss".
|
||||
|
||||
Deshalb schreibt hier NIEMAND den Leistungsumfang. Er entsteht aus
|
||||
der Aufgabenvorlage des Pakets — denselben 92 Punkten, nach denen
|
||||
später auch gearbeitet wird. Das hat einen Nebeneffekt, der wichtiger
|
||||
ist als die gesparte Tipparbeit: Was im Angebot steht, IST die
|
||||
Arbeitsliste. Ein von Hand geschriebenes Angebot und eine getrennt
|
||||
gepflegte Aufgabenliste laufen unweigerlich auseinander — und dann
|
||||
steht im Angebot etwas, das niemand abarbeitet.
|
||||
|
||||
Zu tippen bleibt: das Paket wählen und den Preis bestätigen. Beides
|
||||
ist vorbelegt.
|
||||
|
||||
DER WORTLAUT WIRD BEIM ABSENDEN EINGEFROREN
|
||||
|
||||
Ein angenommenes Angebot ist ein Vertrag. Bei einem Streit zählt, WAS
|
||||
dem Kunden gezeigt wurde, als er zusagte. Würde der Text bei jeder
|
||||
Ansicht neu aus den Vorlagen erzeugt, stünde nach der nächsten
|
||||
Änderung an den Vorlagen etwas anderes da als damals.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { darfWebdesign } from "../lib/webdesign-ausweis.js";
|
||||
import { requireSession } from "../lib/auth.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import {
|
||||
jetzt, neueId, naechsteNummer, text, verlauf, centZuText, PAKETE,
|
||||
} from "../lib/webdesign-helfer.js";
|
||||
import { laufzeitFuer, werktageAddieren, terminVorschlag } from "../lib/webdesign-termine.js";
|
||||
import { vorlageAnwenden } from "./webdesign-aufgaben.js";
|
||||
import { meldungAnlegen } from "../lib/webdesign-automatik.js";
|
||||
|
||||
async function darf(req, recht) {
|
||||
return darfWebdesign(req, recht, async () => {
|
||||
const sitzung = await requireSession(req);
|
||||
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
|
||||
if (sitzung.isOwner) return { erlaubt: true, sitzung };
|
||||
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
|
||||
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
|
||||
});
|
||||
}
|
||||
function werIst(p) {
|
||||
if (p.actor) return p.actor;
|
||||
if (p.sitzung && p.sitzung.actor) return "team:" + p.sitzung.actor;
|
||||
return "unbekannt";
|
||||
}
|
||||
|
||||
/* Preisvorschlag je Paket, in Euro.
|
||||
|
||||
Wie die Laufzeiten im Code und nicht in der Datenbank: Es ist eine
|
||||
Geschäftsentscheidung, kein Einstellwert. Er ist ein VORSCHLAG und
|
||||
steht vor dem Absenden zum Ändern da -- kein Angebot geht ungesehen
|
||||
hinaus. */
|
||||
export const PREIS_VORSCHLAG = {
|
||||
onepager: 490,
|
||||
website: 1490,
|
||||
shop: 2990,
|
||||
verwaltung: 990,
|
||||
betreuung: 39, // je Monat
|
||||
unklar: 1490,
|
||||
};
|
||||
|
||||
const PAKETNAME = {
|
||||
onepager: "Onepager", website: "Website", shop: "Onlineshop",
|
||||
verwaltung: "Verwaltungsbereich", betreuung: "Laufende Betreuung",
|
||||
unklar: "Projekt",
|
||||
};
|
||||
|
||||
/* Der Leistungstext aus der Aufgabenvorlage.
|
||||
|
||||
Gruppiert nach Kategorie, damit daraus kein Zettel mit 25 Spiegel-
|
||||
strichen wird. Nur Punkte, die auch WIRKLICH von mir kommen: Was
|
||||
der Kunde beisteuert (Texte, Bilder, Zugänge), gehört nicht in eine
|
||||
Leistungsbeschreibung -- es steht getrennt darunter als das, was
|
||||
gebraucht wird. */
|
||||
function leistungenBauen(paket) {
|
||||
const punkte = db.prepare(
|
||||
`SELECT titel, kategorie, wer_dran FROM wd_aufgaben_vorlagen
|
||||
WHERE paket = ? AND aktiv = 1 ORDER BY reihenfolge`
|
||||
).all(paket);
|
||||
|
||||
const meine = punkte.filter((p) => p.wer_dran !== "kunde");
|
||||
const seine = punkte.filter((p) => p.wer_dran === "kunde");
|
||||
|
||||
const gruppen = new Map();
|
||||
for (const p of meine) {
|
||||
const k = p.kategorie || "Sonstiges";
|
||||
if (!gruppen.has(k)) gruppen.set(k, []);
|
||||
gruppen.get(k).push(p.titel);
|
||||
}
|
||||
|
||||
let out = "";
|
||||
for (const [gruppe, liste] of gruppen) {
|
||||
out += "**" + gruppe + "**\n";
|
||||
for (const t of liste) out += "· " + t + "\n";
|
||||
out += "\n";
|
||||
}
|
||||
if (seine.length) {
|
||||
out += "**Was ich von dir brauche**\n";
|
||||
for (const t of seine) out += "· " + t + "\n";
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VORSCHAU — alles vorbelegt, nichts zu tippen
|
||||
--------------------------------------------------------------------- */
|
||||
export async function angebotVorschau(req, res) {
|
||||
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`)
|
||||
.get(String(req.params.id || ""));
|
||||
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||
|
||||
/* Das Paket aus der Anfrage, aber überschreibbar: Beim Lesen stellt
|
||||
sich oft heraus, dass etwas anderes passt als angekreuzt wurde. */
|
||||
const paket = PAKETE.includes(text(req.query?.paket, 30))
|
||||
? text(req.query?.paket, 30)
|
||||
: (PAKETE.includes(anfrage.paket) ? anfrage.paket : "unklar");
|
||||
|
||||
const preisEuro = PREIS_VORSCHLAG[paket] ?? PREIS_VORSCHLAG.unklar;
|
||||
const werktage = laufzeitFuer(paket);
|
||||
|
||||
const offen = db.prepare(
|
||||
`SELECT id, nummer, status FROM wd_angebote
|
||||
WHERE anfrage_id = ? AND status IN ('gesendet','angenommen')`
|
||||
).get(anfrage.id);
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
paket,
|
||||
titel: PAKETNAME[paket] + (anfrage.name ? " für " + anfrage.name : ""),
|
||||
preisEuro,
|
||||
anzahlungEuro: Math.round(preisEuro * 0.3 * 100) / 100,
|
||||
werktage,
|
||||
/* Vier Wochen: lang genug zum Überlegen, kurz genug, dass ein Preis
|
||||
von heute nicht in einem Jahr noch gilt. */
|
||||
gueltigBis: werktageAddieren(new Date(), 20),
|
||||
leistungen: leistungenBauen(paket),
|
||||
sprache: anfrage.sprache || "de",
|
||||
kunde: { name: anfrage.name, email: anfrage.email },
|
||||
schonUnterwegs: offen || null,
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
ABSENDEN
|
||||
--------------------------------------------------------------------- */
|
||||
export async function angebotSenden(req, res) {
|
||||
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`)
|
||||
.get(String(req.params.id || ""));
|
||||
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||
if (!anfrage.email) {
|
||||
return json(res, { ok: false,
|
||||
error: "Diese Anfrage hat keine E-Mail-Adresse — ohne sie gibt es kein Portal." }, 400);
|
||||
}
|
||||
|
||||
/* Ein zweites offenes Angebot zur selben Anfrage wäre für den Kunden
|
||||
nicht auflösbar: Er sähe zwei Preise und wüsste nicht, welcher
|
||||
gilt. Wer neu anbieten will, zieht das alte zurück. */
|
||||
const offen = db.prepare(
|
||||
`SELECT nummer FROM wd_angebote WHERE anfrage_id = ? AND status IN ('gesendet','angenommen')`
|
||||
).get(anfrage.id);
|
||||
if (offen) {
|
||||
return json(res, { ok: false,
|
||||
error: `Zu dieser Anfrage ist bereits Angebot ${offen.nummer} unterwegs. Zieh es zurück, bevor du ein neues schickst.` }, 409);
|
||||
}
|
||||
|
||||
const paket = PAKETE.includes(text(req.body?.paket, 30)) ? text(req.body?.paket, 30) : "unklar";
|
||||
const preisEuro = Number(req.body?.preisEuro);
|
||||
if (!Number.isFinite(preisEuro) || preisEuro <= 0) {
|
||||
return json(res, { ok: false, error: "Bitte einen Preis angeben." }, 400);
|
||||
}
|
||||
const preisCent = Math.round(preisEuro * 100);
|
||||
const anzahlungCent = Math.round(preisCent * 0.3);
|
||||
|
||||
const leistungen = text(req.body?.leistungen, 20000) || leistungenBauen(paket);
|
||||
const gueltigBis = /^\d{4}-\d{2}-\d{2}$/.test(text(req.body?.gueltigBis, 10) || "")
|
||||
? text(req.body?.gueltigBis, 10)
|
||||
: 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. */
|
||||
let kunde = db.prepare(
|
||||
`SELECT id FROM wd_kunden WHERE lower(email) = lower(?) AND archiviert = 0`
|
||||
).get(anfrage.email);
|
||||
|
||||
const id = neueId();
|
||||
const nummer = naechsteNummer("wd_angebote", "AG");
|
||||
let einladung = null;
|
||||
|
||||
db.transaction(() => {
|
||||
if (!kunde) {
|
||||
const kid = neueId();
|
||||
db.prepare(
|
||||
`INSERT INTO wd_kunden (id, email, name, telefon, sprache,
|
||||
email_bestaetigt, freigeschaltet, gesperrt, erstellt_am)
|
||||
VALUES (?,?,?,?,?,0,1,0,?)`
|
||||
).run(kid, anfrage.email, anfrage.name || anfrage.email,
|
||||
anfrage.telefon || null, anfrage.sprache || "de", jetzt());
|
||||
kunde = { id: kid };
|
||||
einladung = "neu";
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO wd_angebote
|
||||
(id, nummer, anfrage_id, kunde_id, paket, titel, preis_cent, anzahlung_cent,
|
||||
waehrung, laufzeit_werktage, leistungen_text, hinweis_text, sprache,
|
||||
status, gueltig_bis, gesendet_am, erstellt_am)
|
||||
VALUES (?,?,?,?,?,?,?,?,'EUR',?,?,?,?,'gesendet',?,?,?)`
|
||||
).run(
|
||||
id, nummer, anfrage.id, kunde.id, paket,
|
||||
text(req.body?.titel, 160) || (PAKETNAME[paket] + " für " + (anfrage.name || "")),
|
||||
preisCent, anzahlungCent, laufzeitFuer(paket),
|
||||
leistungen, text(req.body?.hinweis, 4000) || null,
|
||||
anfrage.sprache || "de", gueltigBis, jetzt(), jetzt()
|
||||
);
|
||||
|
||||
/* Der Status der Anfrage folgt der Handlung, ohne dass jemand daran
|
||||
denken muss -- genau das war der Wunsch: "wenn ich ein angebot
|
||||
rausschicke soll der automatisch das erkennen". */
|
||||
db.prepare(
|
||||
`UPDATE wd_anfragen SET status = 'angebot', kunde_id = ?, aktualisiert_am = ? WHERE id = ?`
|
||||
).run(kunde.id, jetzt(), anfrage.id);
|
||||
|
||||
/* Das Angebot landet zusätzlich im Postfach. Das Portal ist der Ort
|
||||
der Entscheidung, aber niemand schaut dort ohne Anlass hinein. */
|
||||
db.prepare(
|
||||
`INSERT INTO wd_postfach (id, kunde_id, autor, text, gelesen_kunde, gelesen_admin, intern, erstellt_am)
|
||||
VALUES (?,?, 'dogfather', ?, 0, 1, 0, ?)`
|
||||
).run(neueId(), kunde.id,
|
||||
`Dein Angebot ${nummer} liegt bereit: ${PAKETNAME[paket]} für ${centZuText(preisCent)}. `
|
||||
+ `Du findest es in deinem Portal und kannst dort mit einem Klick zusagen. `
|
||||
+ `Gültig bis ${gueltigBis}.`,
|
||||
jetzt());
|
||||
})();
|
||||
|
||||
verlauf("anfrage", anfrage.id, "angebot_gesendet", werIst(p),
|
||||
`${nummer} · ${paket} · ${centZuText(preisCent)}`);
|
||||
|
||||
return json(res, {
|
||||
ok: true, id, nummer,
|
||||
preisText: centZuText(preisCent),
|
||||
anzahlungText: centZuText(anzahlungCent),
|
||||
gueltigBis,
|
||||
kundeNeu: einladung === "neu",
|
||||
hinweis: "Das Angebot liegt im Portal des Kunden und als Nachricht in seinem Postfach.",
|
||||
});
|
||||
}
|
||||
|
||||
/* Zurückziehen — damit man ein neues schicken kann. */
|
||||
export async function angebotZurueckziehen(req, res) {
|
||||
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const a = db.prepare(`SELECT * FROM wd_angebote WHERE id = ?`).get(String(req.params.id || ""));
|
||||
if (!a) return json(res, { ok: false, error: "Angebot nicht gefunden." }, 404);
|
||||
/* Ein angenommenes Angebot ist ein Vertrag. Man zieht es nicht
|
||||
zurück -- man bricht das Projekt ab. */
|
||||
if (a.status === "angenommen") {
|
||||
return json(res, { ok: false,
|
||||
error: "Dieses Angebot wurde bereits angenommen. Brich stattdessen das Projekt ab." }, 409);
|
||||
}
|
||||
db.prepare(`UPDATE wd_angebote SET status = 'zurueckgezogen', aktualisiert_am = ? WHERE id = ?`)
|
||||
.run(jetzt(), a.id);
|
||||
verlauf("angebot", a.id, "zurueckgezogen", werIst(p), a.nummer);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
PORTAL — was der Kunde sieht und anklickt
|
||||
===================================================================== */
|
||||
|
||||
/* Gilt das Angebot noch?
|
||||
|
||||
Getrennte Funktion, weil die Prüfung an zwei Stellen gebraucht wird
|
||||
(Anzeigen und Zusagen) und in beiden Fällen dasselbe bedeuten muss.
|
||||
Ein Angebot, das man noch sieht, aber nicht mehr annehmen kann, wäre
|
||||
eine Falle. */
|
||||
function nochGueltig(a) {
|
||||
if (a.status !== "gesendet") return false;
|
||||
if (!a.gueltig_bis) return true;
|
||||
return a.gueltig_bis >= new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function portalAngebote(kundeId) {
|
||||
const liste = db.prepare(
|
||||
`SELECT id, nummer, paket, titel, preis_cent, anzahlung_cent, waehrung,
|
||||
laufzeit_werktage, leistungen_text, hinweis_text, status,
|
||||
gueltig_bis, gesendet_am, entschieden_am
|
||||
FROM wd_angebote
|
||||
WHERE kunde_id = ? AND status IN ('gesendet','angenommen')
|
||||
ORDER BY gesendet_am DESC`
|
||||
).all(kundeId);
|
||||
|
||||
for (const a of liste) {
|
||||
a.preis_text = centZuText(a.preis_cent, a.waehrung);
|
||||
a.anzahlung_text = centZuText(a.anzahlung_cent, a.waehrung);
|
||||
a.annehmbar = nochGueltig(a);
|
||||
a.abgelaufen = a.status === "gesendet" && !a.annehmbar;
|
||||
}
|
||||
return liste;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
ZUSAGE DURCH DEN KUNDEN
|
||||
|
||||
Der folgenreichste Klick auf der ganzen Seite: Hier entsteht ein
|
||||
Vertrag. Entsprechend gilt alles oder nichts, und entsprechend wird
|
||||
belegt, WAS zugesagt wurde.
|
||||
|
||||
Was in einem Zug passiert:
|
||||
1. Angebot als angenommen festschreiben, mit Zeitpunkt und Herkunft
|
||||
2. Projekt anlegen, Aufgabenliste aus der Vorlage
|
||||
3. Anzahlung als offene Rechnung erzeugen
|
||||
4. Anfrage auf "angenommen"
|
||||
5. Benachrichtigung an mich
|
||||
|
||||
Die UHR startet hier NICHT. Sie startet erst mit dem Zahlungseingang
|
||||
-- das ist die Regel aus dem vorigen Schritt, und sie gilt auch dann,
|
||||
wenn der Kunde selbst zugesagt hat.
|
||||
--------------------------------------------------------------------- */
|
||||
export async function portalAngebotZusagen(req, res, kunde, ipHash) {
|
||||
const a = db.prepare(`SELECT * FROM wd_angebote WHERE id = ? AND kunde_id = ?`)
|
||||
.get(String(req.params.id || ""), kunde.id);
|
||||
if (!a) return json(res, { ok: false, error: "Angebot nicht gefunden." }, 404);
|
||||
|
||||
if (a.status === "angenommen") {
|
||||
/* Ein zweiter Klick -- versehentlich oder weil die Antwort lange
|
||||
brauchte -- darf kein zweites Projekt anlegen. */
|
||||
return json(res, { ok: true, bereitsAngenommen: true, projektId: a.projekt_id,
|
||||
hinweis: "Dieses Angebot hast du bereits angenommen." });
|
||||
}
|
||||
if (!nochGueltig(a)) {
|
||||
return json(res, { ok: false,
|
||||
error: "Dieses Angebot ist nicht mehr gültig. Schreib mir kurz, dann mache ich dir ein neues." }, 409);
|
||||
}
|
||||
|
||||
let projektId = null;
|
||||
let projektNummer = null;
|
||||
let zahlungId = null;
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
projektId = neueId();
|
||||
projektNummer = naechsteNummer("wd_projekte", "P");
|
||||
const v = terminVorschlag(a.paket, new Date());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO wd_projekte
|
||||
(id, nummer, kunde_id, anfrage_id, titel, paket, beschreibung,
|
||||
status, naechster_schritt, wartet_auf, preis_cent, anzahlung_cent,
|
||||
waehrung, termin_am, termin_geplant_am, angenommen_am, termin_werktage, erstellt_am)
|
||||
VALUES (?,?,?,?,?,?,?, 'briefing', ?, 'kunde', ?,?,?,?,?,?,?,?)`
|
||||
).run(
|
||||
projektId, projektNummer, kunde.id, a.anfrage_id, a.titel, a.paket,
|
||||
a.leistungen_text,
|
||||
/* Der erste Zug liegt beim Kunden: Ohne Anzahlung geht es nicht
|
||||
los. Das steht so auch im Portal, damit niemand auf eine
|
||||
Arbeit wartet, die noch gar nicht beginnen kann. */
|
||||
"Anzahlung überweisen — danach geht es los",
|
||||
a.preis_cent, a.anzahlung_cent, a.waehrung,
|
||||
v.termin, v.termin, jetzt(), a.laufzeit_werktage, jetzt()
|
||||
);
|
||||
|
||||
vorlageAnwenden(projektId, kunde.id, a.paket);
|
||||
|
||||
/* Die Anzahlung als offene Rechnung. Sie entsteht HIER und nicht
|
||||
erst, wenn jemand daran denkt -- sonst hinge der Start des
|
||||
Projekts an einem Handgriff, den man vergessen kann. */
|
||||
zahlungId = neueId();
|
||||
db.prepare(
|
||||
`INSERT INTO wd_zahlungen
|
||||
(id, nummer, kunde_id, projekt_id, art, zweck_text, betrag_cent, waehrung,
|
||||
status, kundenart, erstellt_am)
|
||||
VALUES (?,?,?,?, 'anzahlung', ?, ?, ?, 'offen', 'verbraucher', ?)`
|
||||
).run(zahlungId, naechsteNummer("wd_zahlungen", "Z"), kunde.id, projektId,
|
||||
"Anzahlung (30 %) — " + a.titel, a.anzahlung_cent, a.waehrung, jetzt());
|
||||
|
||||
db.prepare(
|
||||
`UPDATE wd_angebote
|
||||
SET status = 'angenommen', entschieden_am = ?, zusage_ip_hash = ?,
|
||||
projekt_id = ?, aktualisiert_am = ?
|
||||
WHERE id = ?`
|
||||
).run(jetzt(), ipHash || null, projektId, jetzt(), a.id);
|
||||
|
||||
if (a.anfrage_id) {
|
||||
db.prepare(`UPDATE wd_anfragen SET status = 'angenommen', aktualisiert_am = ? WHERE id = ?`)
|
||||
.run(jetzt(), a.anfrage_id);
|
||||
}
|
||||
db.prepare(`UPDATE wd_kunden SET freigeschaltet = 1, aktualisiert_am = ? WHERE id = ? AND gesperrt = 0`)
|
||||
.run(jetzt(), kunde.id);
|
||||
})();
|
||||
} catch (fehler) {
|
||||
return json(res, { ok: false,
|
||||
error: "Beim Annehmen ist etwas schiefgegangen — es wurde nichts angelegt. Bitte versuch es noch einmal.",
|
||||
einzelheit: String((fehler && fehler.message) || fehler).slice(0, 300) }, 500);
|
||||
}
|
||||
|
||||
verlauf("angebot", a.id, "angenommen", "kunde:" + kunde.id,
|
||||
`${a.nummer} → ${projektNummer}`);
|
||||
|
||||
meldungAnlegen({
|
||||
art: "zusage",
|
||||
titel: "Zusage! " + a.nummer + " angenommen",
|
||||
text: (kunde.name || "Der Kunde") + " hat zugesagt: " + centZuText(a.preis_cent, a.waehrung)
|
||||
+ ". " + projektNummer + " ist angelegt, die Anzahlung von "
|
||||
+ centZuText(a.anzahlung_cent, a.waehrung) + " steht offen. Die Uhr läuft, sobald sie da ist.",
|
||||
ziel: "projekte", zielId: projektId,
|
||||
dringend: 1,
|
||||
});
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
projektId, projektNummer, zahlungId,
|
||||
anzahlungText: centZuText(a.anzahlung_cent, a.waehrung),
|
||||
hinweis: "Danke! Dein Projekt ist angelegt. Sobald die Anzahlung da ist, geht es los.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function portalAngebotAblehnen(req, res, kunde) {
|
||||
const a = db.prepare(`SELECT * FROM wd_angebote WHERE id = ? AND kunde_id = ?`)
|
||||
.get(String(req.params.id || ""), kunde.id);
|
||||
if (!a) return json(res, { ok: false, error: "Angebot nicht gefunden." }, 404);
|
||||
if (a.status === "angenommen") {
|
||||
return json(res, { ok: false, error: "Dieses Angebot hast du bereits angenommen." }, 409);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE wd_angebote SET status = 'abgelehnt', entschieden_am = ?,
|
||||
ablehnung_grund = ?, aktualisiert_am = ? WHERE id = ?`
|
||||
).run(jetzt(), text(req.body?.grund, 500) || null, jetzt(), a.id);
|
||||
|
||||
verlauf("angebot", a.id, "abgelehnt", "kunde:" + kunde.id, a.nummer);
|
||||
meldungAnlegen({
|
||||
art: "absage",
|
||||
titel: a.nummer + " wurde abgelehnt",
|
||||
text: (kunde.name || "Der Kunde") + " hat das Angebot nicht angenommen."
|
||||
+ (req.body?.grund ? " Grund: " + String(req.body.grund).slice(0, 200) : ""),
|
||||
ziel: "anfragen", zielId: a.anfrage_id,
|
||||
});
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -33,8 +33,11 @@ import {
|
||||
jetzt, neueId, text, istEmail, emailNormal, tokenHash,
|
||||
besucherIp, zuVieleVersuche, versuchZaehlen, versucheZuruecksetzen,
|
||||
verlauf, spracheOderDe, centZuText,
|
||||
ipHash,
|
||||
} from "../lib/webdesign-helfer.js";
|
||||
import { werktageBis } from "../lib/webdesign-termine.js";
|
||||
import { portalAngebote, portalAngebotZusagen as angebotZusagenKern,
|
||||
portalAngebotAblehnen as angebotAblehnenKern } from "./webdesign-angebote.js";
|
||||
|
||||
const scrypt = promisify(scryptRoh);
|
||||
|
||||
@@ -303,6 +306,13 @@ export function portalUebersicht(req, res) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/* Offene Angebote GANZ nach vorn.
|
||||
|
||||
Ein Angebot ist das Einzige im Portal, das eine Entscheidung
|
||||
verlangt -- alles andere ist Auskunft. Stünde es unter den
|
||||
laufenden Projekten, sähe man es erst nach dem Scrollen. */
|
||||
const angebote = portalAngebote(kunde.id);
|
||||
|
||||
const abos = db
|
||||
.prepare(`SELECT id, paket, preis_cent, status, start_am, naechste_zahlung, laeuft_bis
|
||||
FROM wd_betreuung_abos WHERE kunde_id = ? AND status != 'abgelaufen'`)
|
||||
@@ -320,6 +330,7 @@ export function portalUebersicht(req, res) {
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
angebote,
|
||||
kunde: {
|
||||
name: kunde.name, firma: kunde.firma, email: kunde.email,
|
||||
sprache: kunde.sprache, betreuung_aktiv: !!kunde.betreuung_aktiv,
|
||||
@@ -518,3 +529,27 @@ export function portalProfilSpeichern(req, res) {
|
||||
nachgebaute Fassung würde diesen Teil früher oder später verlieren —
|
||||
und dann hätte eine Sperre in einem Teil des Portals keine Wirkung. */
|
||||
export { passwortHashen, passwortPruefen, PASSWORT_MIN, sitzungLesen, brauchtKunde };
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
ANGEBOTE — Hüllen um die Kernfunktionen
|
||||
|
||||
Die Logik steht in webdesign-angebote.js, weil sie dort mit dem
|
||||
Erzeugen zusammengehört. Hier kommt nur dazu, was das Portal
|
||||
beisteuern muss: WER gerade angemeldet ist und von welcher Verbindung
|
||||
aus zugesagt wurde.
|
||||
|
||||
Der IP-Abdruck wird gehasht, nicht im Klartext gespeichert: Er dient
|
||||
dem Nachweis, dass die Zusage von diesem Zugang kam -- dafür genügt
|
||||
ein Vergleichswert, die Adresse selbst braucht niemand aufzubewahren.
|
||||
--------------------------------------------------------------------- */
|
||||
export async function portalAngebotZusagen(req, res) {
|
||||
const kunde = brauchtKunde(req, res);
|
||||
if (!kunde) return;
|
||||
return angebotZusagenKern(req, res, kunde, ipHash(besucherIp(req)));
|
||||
}
|
||||
|
||||
export async function portalAngebotAblehnen(req, res) {
|
||||
const kunde = brauchtKunde(req, res);
|
||||
if (!kunde) return;
|
||||
return angebotAblehnenKern(req, res, kunde);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/* =====================================================================
|
||||
test-angebote.mjs — Angebot schicken, Kunde sagt zu
|
||||
|
||||
Der folgenreichste Klick der ganzen Seite: Mit der Zusage entsteht ein
|
||||
Vertrag. Entsprechend wird hier geprüft, dass
|
||||
- alles oder nichts passiert,
|
||||
- ein zweiter Klick kein zweites Projekt anlegt,
|
||||
- der WORTLAUT eingefroren wird (bei Streit zählt, was gezeigt wurde),
|
||||
- und die Uhr trotz Zusage NOCH NICHT läuft.
|
||||
|
||||
Der letzte Punkt ist der, den man beim Bauen am ehesten falsch macht:
|
||||
Es fühlt sich richtig an, mit der Zusage loszulegen. Die Regel ist
|
||||
aber, dass die Zeit erst mit der Anzahlung läuft — und sie muss auch
|
||||
dann gelten, wenn der Kunde selbst zugesagt hat.
|
||||
===================================================================== */
|
||||
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-ang-"));
|
||||
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");
|
||||
|
||||
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 G = await import("./routes/webdesign-angebote.js");
|
||||
const Auto = await import("./lib/webdesign-automatik.js");
|
||||
|
||||
let ok = 0, fehler = 0;
|
||||
const pruefe = (n, b, z) => b
|
||||
? (ok++, L(" ok " + n))
|
||||
: (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : "")));
|
||||
|
||||
function ausweis(g = 60000) {
|
||||
const n = Buffer.from(JSON.stringify({ bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + g }))
|
||||
.toString("base64url");
|
||||
return n + "." + createHmac("sha256", process.env.WEBDESIGN_API_SECRET).update(n).digest("base64url");
|
||||
}
|
||||
async function ruf(fn, extra, ...rest) {
|
||||
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() {} },
|
||||
...rest);
|
||||
return out;
|
||||
}
|
||||
|
||||
const nun = new Date().toISOString();
|
||||
db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am)
|
||||
VALUES ('a1','A-1','Anna Muster','[email protected]','website','neu','de',?)`).run(nun);
|
||||
|
||||
L("\n1. VORSCHAU — alles vorbelegt, nichts zu tippen");
|
||||
let v = await ruf(G.angebotVorschau, { params: { id: "a1" } });
|
||||
pruefe("die Vorschau antwortet", v && v.ok === true, JSON.stringify(v).slice(0, 100));
|
||||
pruefe("das Paket kommt aus der Anfrage", v.paket === "website", v.paket);
|
||||
pruefe("ein Preis ist vorgeschlagen", v.preisEuro > 0, v.preisEuro);
|
||||
pruefe("die Anzahlung sind 30 Prozent davon",
|
||||
Math.abs(v.anzahlungEuro - v.preisEuro * 0.3) < 0.01, v.anzahlungEuro);
|
||||
pruefe("ein Ablaufdatum ist gesetzt", /^\d{4}-\d{2}-\d{2}$/.test(v.gueltigBis || ""), v.gueltigBis);
|
||||
/* DER KERN: Der Leistungstext entsteht aus der Aufgabenvorlage. */
|
||||
pruefe("der Leistungstext ist nicht leer", (v.leistungen || "").length > 100, (v.leistungen || "").length);
|
||||
pruefe("er ist nach Kategorien gegliedert", /\*\*/.test(v.leistungen));
|
||||
pruefe("und trennt, was der KUNDE beisteuern muss",
|
||||
/Was ich von dir brauche/.test(v.leistungen), v.leistungen.slice(-120));
|
||||
pruefe("die Vorschau legt NICHTS an",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_angebote`).get().n === 0);
|
||||
|
||||
L("\n2. EIN ANDERES PAKET WAEHLEN");
|
||||
const v2 = await ruf(G.angebotVorschau, { params: { id: "a1" }, query: { paket: "onepager" } });
|
||||
pruefe("das Paket laesst sich ueberschreiben", v2.paket === "onepager", v2.paket);
|
||||
pruefe("und der Preis passt sich an", v2.preisEuro !== v.preisEuro, v2.preisEuro);
|
||||
pruefe("auch der Leistungstext ist ein anderer", v2.leistungen !== v.leistungen);
|
||||
|
||||
L("\n3. ABSENDEN");
|
||||
const g = await ruf(G.angebotSenden, { params: { id: "a1" },
|
||||
body: { paket: "website", preisEuro: 1490, leistungen: v.leistungen } });
|
||||
pruefe("das Absenden gelingt", g && g.ok === true, JSON.stringify(g).slice(0, 140));
|
||||
pruefe("es gibt eine Angebotsnummer", /^AG-/.test(g.nummer || ""), g.nummer);
|
||||
const ang = db.prepare(`SELECT * FROM wd_angebote WHERE id = ?`).get(g.id);
|
||||
pruefe("der Status steht auf gesendet", ang.status === "gesendet");
|
||||
/* Eingefroren, nicht bei jeder Ansicht neu erzeugt -- sonst staende
|
||||
nach der naechsten Vorlagenaenderung etwas anderes da als damals. */
|
||||
pruefe("der Wortlaut ist eingefroren", ang.leistungen_text === v.leistungen);
|
||||
pruefe("die Anzahlung ist berechnet, nicht getippt",
|
||||
ang.anzahlung_cent === Math.round(149000 * 0.3), ang.anzahlung_cent);
|
||||
|
||||
const anf = db.prepare(`SELECT status, kunde_id FROM wd_anfragen WHERE id = 'a1'`).get();
|
||||
/* Der Wunsch: "wenn ich ein angebot rausschicke soll der automatisch
|
||||
das erkennen". */
|
||||
pruefe("die Anfrage steht jetzt automatisch auf 'angebot'", anf.status === "angebot", anf.status);
|
||||
pruefe("und ist mit dem Kunden verknuepft", !!anf.kunde_id);
|
||||
pruefe("das Angebot liegt auch im Postfach",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_postfach WHERE kunde_id = ?`).get(anf.kunde_id).n === 1);
|
||||
|
||||
L("\n4. KEIN ZWEITES ANGEBOT ZUR SELBEN ANFRAGE");
|
||||
const doppelt = await ruf(G.angebotSenden, { params: { id: "a1" },
|
||||
body: { paket: "website", preisEuro: 999 } });
|
||||
pruefe("ein zweites wird abgewiesen", doppelt.ok === false, JSON.stringify(doppelt).slice(0, 130));
|
||||
pruefe("mit Hinweis auf das offene", /AG-/.test(doppelt.error || ""), doppelt.error);
|
||||
|
||||
L("\n5. WAS DER KUNDE SIEHT");
|
||||
const kunde = { id: anf.kunde_id, name: "Anna Muster" };
|
||||
let sicht = G.portalAngebote(kunde.id);
|
||||
pruefe("das Angebot erscheint im Portal", sicht.length === 1);
|
||||
pruefe("mit lesbarem Preis", /1.490/.test(sicht[0].preis_text), sicht[0].preis_text);
|
||||
pruefe("und lesbarer Anzahlung", /447/.test(sicht[0].anzahlung_text), sicht[0].anzahlung_text);
|
||||
pruefe("es ist annehmbar", sicht[0].annehmbar === true);
|
||||
|
||||
L("\n6. ZUSAGE — hier entsteht der Vertrag");
|
||||
const z = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, kunde, "abc123");
|
||||
pruefe("die Zusage gelingt", z && z.ok === true, JSON.stringify(z).slice(0, 160));
|
||||
pruefe("es gibt eine Projektnummer", /^P-/.test(z.projektNummer || ""), z.projektNummer);
|
||||
|
||||
const ang2 = db.prepare(`SELECT * FROM wd_angebote WHERE id = ?`).get(g.id);
|
||||
pruefe("das Angebot steht auf angenommen", ang2.status === "angenommen");
|
||||
pruefe("mit Zeitpunkt", !!ang2.entschieden_am);
|
||||
/* Die Beweislast liegt beim Unternehmer: Wer, wann, von wo. */
|
||||
pruefe("und mit Herkunft der Zusage", ang2.zusage_ip_hash === "abc123", ang2.zusage_ip_hash);
|
||||
pruefe("das Projekt haengt am Angebot", ang2.projekt_id === z.projektId);
|
||||
|
||||
const pr = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(z.projektId);
|
||||
pruefe("das Projekt uebernimmt den Preis", pr.preis_cent === 149000, pr.preis_cent);
|
||||
pruefe("die Aufgabenliste steht",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id = ?`).get(z.projektId).n > 5);
|
||||
/* DER PUNKT, DEN MAN AM EHESTEN FALSCH MACHT. */
|
||||
pruefe("die UHR laeuft trotz Zusage NOCH NICHT", pr.uhr_start_am === null, String(pr.uhr_start_am));
|
||||
pruefe("der erste Zug liegt beim Kunden", pr.wartet_auf === "kunde", pr.wartet_auf);
|
||||
pruefe("und der naechste Schritt nennt die Anzahlung",
|
||||
/Anzahlung/.test(pr.naechster_schritt || ""), pr.naechster_schritt);
|
||||
|
||||
const zg = db.prepare(`SELECT * FROM wd_zahlungen WHERE projekt_id = ?`).get(z.projektId);
|
||||
pruefe("die Anzahlung wurde als offene Rechnung angelegt", !!zg && zg.status === "offen");
|
||||
pruefe("in der richtigen Hoehe", zg.betrag_cent === Math.round(149000 * 0.3), zg.betrag_cent);
|
||||
|
||||
pruefe("die Anfrage steht jetzt auf angenommen",
|
||||
db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'a1'`).get().status === "angenommen");
|
||||
|
||||
L("\n7. BENACHRICHTIGUNG AN MICH");
|
||||
const m = Auto.meldungenLesen(true, 10).find((x) => x.art === "zusage");
|
||||
pruefe("die Zusage wird gemeldet", !!m, JSON.stringify(Auto.meldungenLesen(true, 5).map((x) => x.art)));
|
||||
pruefe("sie ist dringend", m.dringend === 1);
|
||||
pruefe("sie nennt den Betrag", /1.490/.test(m.text), m.text);
|
||||
pruefe("und sagt, dass die Uhr erst mit der Anzahlung laeuft",
|
||||
/Uhr läuft, sobald/.test(m.text), m.text);
|
||||
|
||||
L("\n8. EIN ZWEITER KLICK LEGT NICHTS DOPPELT AN");
|
||||
const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n;
|
||||
const z2 = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, kunde, "abc123");
|
||||
pruefe("er meldet keinen Fehler", z2.ok === true && z2.bereitsAngenommen === true);
|
||||
pruefe("und es entstand kein zweites Projekt",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === vorher);
|
||||
|
||||
L("\n9. EIN ANGENOMMENES ANGEBOT LAESST SICH NICHT ZURUECKZIEHEN");
|
||||
const zz = await ruf(G.angebotZurueckziehen, { params: { id: g.id } });
|
||||
pruefe("das wird verweigert", zz.ok === false, JSON.stringify(zz).slice(0, 130));
|
||||
pruefe("mit Hinweis auf den Projektabbruch", /Projekt ab/.test(zz.error || ""), zz.error);
|
||||
|
||||
L("\n10. ABGELAUFENE ANGEBOTE");
|
||||
db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am)
|
||||
VALUES ('a2','A-2','Bert','[email protected]','onepager','neu','de',?)`).run(nun);
|
||||
const g2 = await ruf(G.angebotSenden, { params: { id: "a2" }, body: { paket: "onepager", preisEuro: 490 } });
|
||||
db.prepare(`UPDATE wd_angebote SET gueltig_bis = '2020-01-01' WHERE id = ?`).run(g2.id);
|
||||
const k2 = db.prepare(`SELECT id, name FROM wd_kunden WHERE lower(email) = '[email protected]'`).get();
|
||||
const sicht2 = G.portalAngebote(k2.id);
|
||||
pruefe("ein abgelaufenes Angebot ist NICHT mehr annehmbar", sicht2[0].annehmbar === false);
|
||||
pruefe("und wird als abgelaufen gekennzeichnet", sicht2[0].abgelaufen === true);
|
||||
/* Ein Angebot, das man noch sieht, aber nicht mehr annehmen kann, waere
|
||||
eine Falle -- deshalb muss auch der Versuch scheitern. */
|
||||
const zAlt = await ruf(G.portalAngebotZusagen, { params: { id: g2.id } }, k2, "x");
|
||||
pruefe("die Zusage auf ein abgelaufenes Angebot scheitert", zAlt.ok === false, zAlt.error);
|
||||
pruefe("mit einem freundlichen Hinweis statt einer Fehlermeldung",
|
||||
/neues/.test(zAlt.error || ""), zAlt.error);
|
||||
|
||||
L("\n11. FREMDE ANGEBOTE SIND UNSICHTBAR");
|
||||
const fremd = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, k2, "x");
|
||||
pruefe("ein anderer Kunde kann nicht zusagen", fremd.ok === false, fremd.error);
|
||||
pruefe("und erfaehrt nicht einmal, dass es existiert",
|
||||
/nicht gefunden/.test(fremd.error || ""), fremd.error);
|
||||
|
||||
L("\n12. DIE RECHTEPRUEFUNG GREIFT");
|
||||
for (const [name, kopf] of [
|
||||
["ohne Ausweis keine Vorschau", {}],
|
||||
["mit abgelaufenem Ausweis keine Vorschau", { authorization: "Bearer " + ausweis(-1000) }],
|
||||
]) {
|
||||
let out = null;
|
||||
await G.angebotVorschau({ headers: kopf, cookies: {}, params: { id: "a1" }, query: {} },
|
||||
{ status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} });
|
||||
pruefe(name, out && out.ok === false, JSON.stringify(out).slice(0, 70));
|
||||
}
|
||||
|
||||
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
||||
process.exitCode = fehler ? 1 : 0;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
@@ -221,6 +221,24 @@ pruefe("danach sind es null", Auto.meldungenOffen() === 0);
|
||||
pruefe("aber geloescht wurde nichts",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_meldungen`).get().n >= offenVorher);
|
||||
|
||||
L("\n12. GELDBETRAEGE — zwei Fehler, die man nur bei bestimmten Zahlen sieht");
|
||||
const { centZuText } = await import("./lib/webdesign-helfer.js");
|
||||
/* Beide fielen erst auf, als das erste Angebot über tausend Euro
|
||||
entstand — alle früheren Testbeträge lagen darunter, und da sieht man
|
||||
weder den fehlenden Tausenderpunkt noch das verlorene Minuszeichen. */
|
||||
pruefe("Tausender bekommen einen Punkt", centZuText(149000) === "1.490,00 €", centZuText(149000));
|
||||
pruefe("Millionen auch", centZuText(100000000) === "1.000.000,00 €", centZuText(100000000));
|
||||
pruefe("darunter bleibt es unverändert", centZuText(99900) === "999,00 €", centZuText(99900));
|
||||
/* Math.trunc(-0.5) ergibt -0, und -0 schreibt sich als "0": Eine
|
||||
Erstattung von 50 Cent erschien damit als Forderung. */
|
||||
pruefe("kleine NEGATIVE Beträge behalten ihr Minus", centZuText(-50) === "-0,50 €", centZuText(-50));
|
||||
pruefe("größere negative auch", centZuText(-149000) === "-1.490,00 €", centZuText(-149000));
|
||||
pruefe("null bleibt null", centZuText(0) === "0,00 €", centZuText(0));
|
||||
pruefe("nichts bleibt nichts", centZuText(null) === null);
|
||||
pruefe("die Nachkommastellen stimmen", centZuText(1490) === "14,90 €", centZuText(1490));
|
||||
pruefe("eine andere Währung wird angehängt",
|
||||
centZuText(1000, "CHF") === "10,00 CHF", centZuText(1000, "CHF"));
|
||||
|
||||
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
||||
process.exitCode = fehler ? 1 : 0;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user