diff --git a/server/services/auftraege/auftraege.js b/server/services/auftraege/auftraege.js index 53f8702..1f469fb 100644 --- a/server/services/auftraege/auftraege.js +++ b/server/services/auftraege/auftraege.js @@ -5,10 +5,14 @@ * über `deps` (L4/L5, Standard: dynamischer Import beim Aufruf). Jede Änderung an einer bestehenden AB läuft unter * sperreAuftrag(id) (konstanten.js), das Senden zusätzlich unter SPERRE_NUMMER; Mails gehen immer erst nach der Sperre. */ -import { - AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN, -} from './konstanten.js' +import { config } from '../../config.js' +import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { createAuftrag, deleteAuftrag, getAuftrag, updateAuftrag } from './auftragStore.js' import { AuftragError } from './fehler.js' +import { + AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN, +} from './konstanten.js' // --------------------------------------------------------------------------------------------------------------------- // Eingabeprüfung (rein, synchron) @@ -187,3 +191,115 @@ export function pruefeEingabe(eingabe, { teilweise = false } = {}) { } return ergebnis } + +// --------------------------------------------------------------------------------------------------------------------- +// Entwurf anlegen, ändern, löschen +// --------------------------------------------------------------------------------------------------------------------- + +const hatText = (wert) => typeof wert === 'string' && wert.trim() !== '' + +export async function ladeAuftrag(id) { + const auftrag = istDokumentId(id) ? await getAuftrag(id) : null + if (!auftrag) throw new AuftragError(404, 'Auftragsbestätigung nicht gefunden.') + return auftrag +} + +export async function ladeKunde(kundenId) { + const nichtGefunden = () => new AuftragError(404, 'Kunde nicht gefunden.') + if (!istDokumentId(kundenId)) throw nichtGefunden() + try { + return await getDocument(config.collections.customers, kundenId) + } catch (err) { + if (err?.status === 404) throw nichtGefunden() + throw err + } +} + +/** Die Felder, die die Eingabeprüfung kennt, aus einem gespeicherten Auftrag (ohne kundenId und kundenart). */ +function eingabeAusAuftrag(auftrag) { + const { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } = auftrag + return { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } +} + +/** Das Ticket eines Projekts als Zeitquelle (nie ein Leereintrag). */ +function ticketsDesProjekts(projekt) { + return hatText(projekt?.ticketId) ? [projekt.ticketId.trim()] : [] +} + +async function ladeProjekt(projektId) { + try { + return await getDocument(config.collections.websiteProjects, projektId) + } catch (err) { + if (err?.status === 404) return null + throw err + } +} + +/** Projekt und Tickets müssen dem Kunden gehören; liefert die Felder mit aufgelöstem Titel und aufgelöster Zeitquelle. */ +async function pruefeBezuege(kundeDoc, felder) { + let titel = felder.titel + let zeitquelle = felder.zeitquelle + let projekt = null + if (felder.projektId) { + projekt = await ladeProjekt(felder.projektId) + if (!projekt || projekt.customerId !== kundeDoc.$id) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.') + } + if (!titel) titel = String(projekt?.projectName || '').trim().slice(0, LIMITS.maxTitel).trim() + if (!titel) throw new AuftragError(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.') + if (zeitquelle.length === 0 && projekt) zeitquelle = ticketsDesProjekts(projekt) + if (zeitquelle.length > 0) { + const tickets = await listDocuments(config.collections.workorders, [Query.equal('$id', zeitquelle), Query.limit(LIMITS.maxZeitquellen)]) + const gefunden = new Map(tickets.map((t) => [t.$id, t])) + if (!zeitquelle.every((id) => gefunden.get(id)?.customerId === kundeDoc.$id)) { + throw new AuftragError(400, 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.') + } + } + return { ...felder, titel, zeitquelle } +} + +/** Setzt die Kundenart am Kunden, aber nur, wenn dort noch keine steht. */ +async function schreibeKundenart(kundeDoc, kundenart) { + if (!kundenart || hatText(kundeDoc.kundenart)) return + await updateDocument(config.collections.customers, kundeDoc.$id, { kundenart }) +} + +/** Die Felder einer AB, wie createAuftrag und updateAuftrag sie bekommen. */ +const speicherFelder = (f) => ({ + projektId: f.projektId, titel: f.titel, zeitquelle: f.zeitquelle, art: f.art, positionen: f.positionen, + stundensatzCents: f.stundensatzCents, schaetzungMinuten: f.schaetzungMinuten, summeCents: f.summeCents, + zahlungsplan: f.zahlungsplan, zahlungszielTage: f.zahlungszielTage, auftragVom: f.auftragVom, auftragWeg: f.auftragWeg, + startAm: f.startAm, fertigBis: f.fertigBis, +}) + +export async function legeEntwurfAn(eingabe, { von } = {}) { + const felder = pruefeEingabe(eingabe) + if (!hatText(eingabe.kundenId)) throw new AuftragError(400, 'Kunde: Bitte einen Kunden angeben.') + const kundeDoc = await ladeKunde(eingabe.kundenId) + const fertig = await pruefeBezuege(kundeDoc, felder) + await schreibeKundenart(kundeDoc, felder.kundenart) + return createAuftrag({ kundenId: eingabe.kundenId, ...speicherFelder(fertig), erstelltVon: String(von || '') }) +} + +export async function aendereEntwurf(id, eingabe) { + return withLocks(sperreAuftrag(id), async () => { + const auftrag = await ladeAuftrag(id) + if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich ändern.') + if (eingabe?.kundenId !== undefined && eingabe.kundenId !== auftrag.kundenId) { + throw new AuftragError(400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.') + } + const teil = pruefeEingabe(eingabe, { teilweise: true }) + const felder = pruefeEingabe({ ...eingabeAusAuftrag(auftrag), ...teil }) + const kundeDoc = await ladeKunde(auftrag.kundenId) + const fertig = await pruefeBezuege(kundeDoc, felder) + await schreibeKundenart(kundeDoc, felder.kundenart) + return updateAuftrag(id, speicherFelder(fertig)) + }) +} + +export async function loescheEntwurf(id) { + return withLocks(sperreAuftrag(id), async () => { + const auftrag = await ladeAuftrag(id) + if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich löschen.') + await deleteAuftrag(id) + }) +} diff --git a/test/auftraegeEntwurf.test.js b/test/auftraegeEntwurf.test.js new file mode 100644 index 0000000..0e150f5 --- /dev/null +++ b/test/auftraegeEntwurf.test.js @@ -0,0 +1,119 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { + starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, LEAD_KUNDE, FREMD, VON, MELDUNG, + festpreisEingabe, aufwandEingabe, abgelehnt, +} from './helpers/auftraegeAb.js' + +const umg = await starteUmgebung() +const { aw, config } = umg +after(() => umg.schliesse()) +beforeEach(umg.zuruecksetzen) +const { getDocument } = await import('../server/services/appwriteAdmin.js') +const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { legeEntwurfAn, aendereEntwurf, loescheEntwurf } = await import('../server/services/auftraege/auftraege.js') +const kundenartVon = async (id) => (await getDocument(config.collections.customers, id)).kundenart || '' +const FREMDES_PROJEKT = MELDUNG.projektFremd +const FREMDES_TICKET = 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.' + +test('legeEntwurfAn: Festpreis-Entwurf mit Titel und Zeitquelle aus dem Projekt, Summe und Ersteller', async () => { + const a = await legeEntwurfAn(festpreisEingabe(), { von: VON }) + assert.deepEqual([a.status, a.nummer, a.kundenId, a.projektId, a.titel], ['entwurf', '', KUNDE_ID, PROJEKT_ID, 'Website Beispiel']) + assert.deepEqual(a.zeitquelle, [TICKET_ID]) + assert.deepEqual([a.art, a.summeCents, a.erstelltVon], ['festpreis', 80000, VON]) + assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' }) + assert.deepEqual(await getAuftrag(a.id), a) +}) + +test('legeEntwurfAn: ein eigener Titel gilt; ohne Projekt braucht es einen Titel', async () => { + assert.equal((await legeEntwurfAn(festpreisEingabe({ titel: ' Relaunch Phase 1 ' }), { von: VON })).titel, 'Relaunch Phase 1') + await abgelehnt(legeEntwurfAn(festpreisEingabe({ projektId: '' }), { von: VON }), 400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.') + const frei = await legeEntwurfAn(festpreisEingabe({ projektId: '', titel: 'Logo-Entwurf' }), { von: VON }) + assert.deepEqual([frei.projektId, frei.zeitquelle], ['', []]) +}) + +test('legeEntwurfAn: Aufwand-Entwurf ohne Positionen und Summe, mit Stundensatz, Schätzung und gewählten Tickets', async () => { + const a = await legeEntwurfAn(aufwandEingabe({ zeitquelle: [TICKET_ID, ZWEITES_TICKET] }), { von: VON }) + assert.deepEqual([a.art, a.positionen, a.summeCents, a.stundensatzCents, a.schaetzungMinuten], ['aufwand', [], null, 10000, 600]) + assert.deepEqual(a.zeitquelle, [TICKET_ID, ZWEITES_TICKET]) + assert.deepEqual(a.zahlungsplan, { art: 'monatlich', prozent: null, text: '' }) +}) + +test('legeEntwurfAn: Entwürfe gibt es auch für Interessenten (nur das Senden ist festen Kunden vorbehalten)', async () => { + const a = await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'Angebotsentwurf' }), { von: VON }) + assert.deepEqual([a.kundenId, a.status], [LEAD_KUNDE, 'entwurf']) +}) + +test('kundenart aus der Eingabe wird am Kunden gespeichert – aber nur, wenn dort noch keine steht', async () => { + await legeEntwurfAn(festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat' }), { von: VON }) + assert.equal(await kundenartVon(FREMD.kunde), 'privat') + await legeEntwurfAn(festpreisEingabe({ kundenart: 'privat' }), { von: VON }) // der Kunde ist schon 'unternehmen' + assert.equal(await kundenartVon(KUNDE_ID), 'unternehmen') + await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'x', kundenart: '' }), { von: VON }) + assert.equal(await kundenartVon(LEAD_KUNDE), '') +}) + +test('fremde Bezüge werden abgelehnt, ohne dass etwas gespeichert wird (auch nicht die Kundenart)', async () => { + const eingabe = (patch) => festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat', ...patch }) + const faelle = [ + ['Projekt eines anderen Kunden', { projektId: PROJEKT_ID }, FREMDES_PROJEKT], + ['unbekanntes Projekt', { projektId: 'p_gibtsnicht' }, FREMDES_PROJEKT], + ['Ticket eines anderen Kunden', { zeitquelle: [TICKET_ID] }, FREMDES_TICKET], + ['ein Ticket unbekannt', { zeitquelle: [FREMD.ticket, 't_gibtsnicht'] }, FREMDES_TICKET], + ] + for (const [name, patch, meldung] of faelle) await abgelehnt(legeEntwurfAn(eingabe(patch), { von: VON }), 400, meldung, name) + assert.equal(aw.docs(config.collections.auftraege).size, 0) + assert.equal(await kundenartVon(FREMD.kunde), '') +}) + +test('Kunde: ohne Kennung 400, unbekannt 404, Kennung mit Sonderzeichen 404', async () => { + await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: undefined }), { von: VON }), 400, 'Kunde: Bitte einen Kunden angeben.') + await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: 'c_gibtsnicht' }), { von: VON }), 404, MELDUNG.kundeFehlt) + await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: '../users' }), { von: VON }), 404, MELDUNG.kundeFehlt) +}) + +test('status, nummer, Token und Ersteller aus der Eingabe werden ignoriert', async () => { + const a = await legeEntwurfAn( + { ...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'abc', ersetztId: 'a_alt', erstelltVon: 'boese@example.invalid', pdfDatei: 'x/y.pdf' }, + { von: VON }, + ) + assert.deepEqual([a.status, a.nummer, a.jahr, a.erstelltVon], ['entwurf', '', null, VON]) + assert.ok(!a.tokenHash && !a.ersetztId && !a.pdfDatei) +}) + +test('aendereEntwurf: nur die genannten Felder ändern sich, der Rest bleibt', async () => { + const a = await legeEntwurfAn(festpreisEingabe(), { von: VON }) + const b = await aendereEntwurf(a.id, { titel: 'Neuer Titel', zahlungszielTage: 30 }) + assert.deepEqual([b.titel, b.zahlungszielTage, b.summeCents], ['Neuer Titel', 30, 80000]) + assert.deepEqual([b.positionen, b.zahlungsplan, b.zeitquelle], [a.positionen, a.zahlungsplan, a.zeitquelle]) + assert.deepEqual(await getAuftrag(a.id), b) +}) + +test('aendereEntwurf: Wechsel der Art prüft das Ganze (Anzahlung geht nicht bei Aufwand), mit passendem Plan klappt er', async () => { + const a = await legeEntwurfAn(festpreisEingabe(), { von: VON }) + await abgelehnt(aendereEntwurf(a.id, { art: 'aufwand' }), 400, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.') + assert.equal((await getAuftrag(a.id)).art, 'festpreis') + const b = await aendereEntwurf(a.id, { art: 'aufwand', stundensatzCents: 12000, zahlungsplan: { art: 'monatlich' } }) + assert.deepEqual([b.art, b.positionen, b.summeCents, b.stundensatzCents, b.schaetzungMinuten], ['aufwand', [], null, 12000, null]) +}) + +test('aendereEntwurf: Kunde lässt sich nicht wechseln, fremdes Projekt wird abgelehnt, unbekannte Aufträge sind 404', async () => { + const a = await legeEntwurfAn(festpreisEingabe(), { von: VON }) + await abgelehnt(aendereEntwurf(a.id, { kundenId: FREMD.kunde }), 400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.') + assert.equal((await aendereEntwurf(a.id, { kundenId: KUNDE_ID, titel: 'Gleicher Kunde' })).titel, 'Gleicher Kunde') + await abgelehnt(aendereEntwurf(a.id, { projektId: FREMD.projekt }), 400, FREMDES_PROJEKT) + await abgelehnt(aendereEntwurf('a_gibtsnicht', { titel: 'x' }), 404, MELDUNG.auftragFehlt) + await abgelehnt(aendereEntwurf('../x', { titel: 'x' }), 404, MELDUNG.auftragFehlt) +}) + +test('Ändern und Löschen gibt es nur für Entwürfe (409); Löschen entfernt den Entwurf', async () => { + const a = await legeEntwurfAn(festpreisEingabe(), { von: VON }) + await updateAuftrag(a.id, { status: 'versendet', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1 }) + await abgelehnt(aendereEntwurf(a.id, { titel: 'Nachträglich' }), 409, MELDUNG.nurEntwurfAendern) + await abgelehnt(loescheEntwurf(a.id), 409, MELDUNG.nurEntwurfLoeschen) + assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel') + const b = await legeEntwurfAn(festpreisEingabe(), { von: VON }) + await loescheEntwurf(b.id) + assert.equal(await getAuftrag(b.id), null) + await abgelehnt(loescheEntwurf(b.id), 404, MELDUNG.auftragFehlt) +})