From 66ec8e0a4439677345e556154bb720db3611be9e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:15:37 +0000 Subject: [PATCH] =?UTF-8?q?AB-Kern:=20Projekt=20nachtr=C3=A4glich=20einer?= =?UTF-8?q?=20Auftragsbest=C3=A4tigung=20zuordnen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/auftraege.js | 33 ++++++- test/auftraegeAbFolge.test.js | 126 +++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/server/services/auftraege/auftraege.js b/server/services/auftraege/auftraege.js index bf0a9bc..f50c02a 100644 --- a/server/services/auftraege/auftraege.js +++ b/server/services/auftraege/auftraege.js @@ -15,7 +15,7 @@ import { entfernePdf, lesePdf, speicherePdf } from './ablage.js' import { createAuftrag, deleteAuftrag, getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js' import { AuftragError } from './fehler.js' import { vergibNummer } from './nummern.js' -import { umhaengen } from './projektRechnungenStore.js' +import { listProjektRechnungen, umhaengen, updateProjektRechnung } from './projektRechnungenStore.js' import { TEXTE_FASSUNG } from './texte.js' import { AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN, @@ -544,3 +544,34 @@ export async function storniereAuftrag(id) { return updateAuftrag(id, { status: 'storniert', storniertAm: new Date().toISOString() }) }) } + +// --------------------------------------------------------------------------------------------------------------------- +// Projekt nachträglich zuordnen +// --------------------------------------------------------------------------------------------------------------------- + +/** + * Ordnet einer AB ohne Projekt nachträglich ein Projekt des Kunden zu: setzt projektId, bei leerer Zeitquelle das Ticket des + * Projekts, und hängt die Projektrechnungen der AB mit leerer projektId um (Stripe bleibt unverändert). PDF, Nummer, Status + * und Token bleiben. Zuerst die Projektrechnungen, zuletzt die AB: scheitert etwas dazwischen, bleibt die AB ohne Projekt + * und die Wiederholung führt alles zu Ende. + */ +export async function ordneProjektZu(id, projektId) { + if (!hatText(projektId)) throw new AuftragError(400, 'Projekt: Bitte ein Projekt angeben.') + if (!istDokumentId(projektId)) throw new AuftragError(400, 'Projekt: Ungültige Projekt-Kennung.') + return withLocks(sperreAuftrag(id), async () => { + const auftrag = await ladeAuftrag(id) + if (['storniert', 'ersetzt', 'widerrufen'].includes(auftrag.status)) { + throw new AuftragError(409, 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.') + } + if (hatText(auftrag.projektId)) throw new AuftragError(409, 'Diese Auftragsbestätigung gehört schon zu einem Projekt.') + const projekt = await ladeProjekt(projektId) + if (!projekt || projekt.customerId !== auftrag.kundenId) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.') + const rechnungen = await listProjektRechnungen({ auftragId: id, nurAktiv: false }) + for (const rechnung of rechnungen) { + if (rechnung.projektId === '') await updateProjektRechnung(rechnung.invoiceId, { projektId }) + } + const patch = { projektId } + if (auftrag.zeitquelle.length === 0) patch.zeitquelle = ticketsDesProjekts(projekt) + return updateAuftrag(id, patch) + }) +} diff --git a/test/auftraegeAbFolge.test.js b/test/auftraegeAbFolge.test.js index 87b17fc..0d79d3c 100644 --- a/test/auftraegeAbFolge.test.js +++ b/test/auftraegeAbFolge.test.js @@ -260,3 +260,129 @@ test('storniereAuftrag wartet auf die Sperre der AB (L5 hält sie beim Widerruf) assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinStorno]) assert.equal((await getAuftrag(a.id)).status, 'widerrufen') }) + +const OHNE_PROJEKT = { projektId: '', titel: 'Beratung ohne Projekt' } +const ZWEITES_PROJEKT = 'p_zweites02' +const legeZweitesProjektAn = () => aw.seed(config.collections.websiteProjects, projektDoc({ $id: ZWEITES_PROJEKT, projectName: 'Zweites Projekt', ticketId: ZWEITES_TICKET })) +/** Alle Felder außer den genannten (für den Vergleich „sonst hat sich nichts geändert“). */ +const ohne = (auftrag, ...felder) => Object.fromEntries(Object.entries(auftrag).filter(([name]) => !felder.includes(name))) + +test('ordneProjektZu: eine versendete AB ohne Projekt bekommt Projekt und Zeitquelle des Projekts; Nummer, Status, PDF, Prüfsumme, Token und Versanddaten bleiben', async () => { + const { deps, calls } = baueDeps() + const alt = await gesendet(deps, OHNE_PROJEKT) + assert.deepEqual([alt.projektId, alt.zeitquelle], ['', []]) + const neu = await ordneProjektZu(alt.id, PROJEKT_ID) + assert.deepEqual([neu.projektId, neu.zeitquelle], [PROJEKT_ID, [TICKET_ID]]) + assert.deepEqual(ohne(neu, 'projektId', 'zeitquelle', 'geaendertAm'), ohne(alt, 'projektId', 'zeitquelle', 'geaendertAm')) + assert.deepEqual(await getAuftrag(alt.id), neu) + assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id) // der Link gilt weiter + assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // keine neue Datei + assert.deepEqual(await lesePdf(alt.pdfDatei), Buffer.from(`%PDF-1.4 ${alt.nummer}`)) + assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [1, 1, 1]) // nichts neu erzeugt, keine Mail +}) + +/** Projektrechnung einer AB, wie L2 sie anlegt (hier ohne Projekt); die Rechnungs-ID muss zu /^in_\w{1,33}$/ passen. */ +const projektRechnung = (auftragId, patch = {}) => ({ + kundenId: KUNDE_ID, projektId: '', auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], + rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, ...patch, +}) + +test('ordneProjektZu: hängt auch die Projektrechnungen der AB ohne Projekt um (aktive und stornierte); schon gesetzte Projekte, andere ABs und alle übrigen Felder bleiben', async () => { + const a = await gesendet(baueDeps().deps, OHNE_PROJEKT) + const andere = await gesendet(baueDeps().deps, OHNE_PROJEKT) + await createProjektRechnung('in_OhneProjekt1', projektRechnung(a.id)) + await createProjektRechnung('in_OhneProjekt2', projektRechnung(a.id, { aktiv: false, rechnungsnummer: '' })) // storniert + await createProjektRechnung('in_MitProjekt', projektRechnung(a.id, { projektId: 'p_anderes', art: 'aufwand' })) + await createProjektRechnung('in_AndereAb', projektRechnung(andere.id)) + const mitProjekt = await getProjektRechnung('in_MitProjekt') + const vorher = await getProjektRechnung('in_OhneProjekt1') + await ordneProjektZu(a.id, PROJEKT_ID) + const nachher = await getProjektRechnung('in_OhneProjekt1') + assert.equal(nachher.projektId, PROJEKT_ID) + assert.deepEqual(ohne(nachher, 'projektId'), ohne(vorher, 'projektId')) // sonst hat sich nichts geändert + assert.equal((await getProjektRechnung('in_OhneProjekt2')).projektId, PROJEKT_ID, 'auch eine stornierte Projektrechnung') + assert.deepEqual(await getProjektRechnung('in_MitProjekt'), mitProjekt) // schon ein anderes Projekt: bleibt + assert.equal((await getProjektRechnung('in_AndereAb')).projektId, '') // andere AB: bleibt + assert.equal((await getAuftrag(a.id)).projektId, PROJEKT_ID) +}) + +test('ordneProjektZu: scheitert das Umhängen einer Projektrechnung, bleibt die AB ohne Projekt, und die Wiederholung führt alles zu Ende', async () => { + mock.method(console, 'error', () => {}) + const a = await gesendet(baueDeps().deps, OHNE_PROJEKT) + await createProjektRechnung('in_Wiederholung1', projektRechnung(a.id)) + await createProjektRechnung('in_Wiederholung2', projektRechnung(a.id)) + aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500, after: 1 }) // die erste geht durch, die zweite scheitert + await assert.rejects(ordneProjektZu(a.id, PROJEKT_ID), (err) => err.status === 500) + assert.deepEqual([(await getAuftrag(a.id)).projektId, (await getProjektRechnung('in_Wiederholung1')).projektId, (await getProjektRechnung('in_Wiederholung2')).projektId], ['', PROJEKT_ID, '']) + assert.equal((await ordneProjektZu(a.id, PROJEKT_ID)).projektId, PROJEKT_ID) // kein 409: die AB hatte noch kein Projekt + for (const id of ['in_Wiederholung1', 'in_Wiederholung2']) assert.equal((await getProjektRechnung(id)).projektId, PROJEKT_ID, id) +}) + +test('ordneProjektZu: auch ein Entwurf bekommt das Projekt (der Titel bleibt); eine gewählte Zeitquelle bleibt; ein Projekt ohne Ticket ergibt keine Leereinträge', async () => { + const entwurf = await legeEntwurfAn(festpreisEingabe({ ...OHNE_PROJEKT, titel: 'Freier Titel', zeitquelle: [ZWEITES_TICKET] }), { von: VON }) + const a = await ordneProjektZu(entwurf.id, PROJEKT_ID) + assert.deepEqual([a.status, a.nummer, a.projektId, a.titel, a.zeitquelle], ['entwurf', '', PROJEKT_ID, 'Freier Titel', [ZWEITES_TICKET]]) + aw.seed(config.collections.websiteProjects, projektDoc({ $id: 'p_ohneticket', projectName: 'Projekt ohne Ticket', ticketId: '' })) + const frei = await legeEntwurfAn(festpreisEingabe(OHNE_PROJEKT), { von: VON }) + const b = await ordneProjektZu(frei.id, 'p_ohneticket') + assert.deepEqual([b.projektId, b.zeitquelle], ['p_ohneticket', []]) +}) + +test('ordneProjektZu: fehlende oder ungültige Kennung, Projekt eines anderen Kunden und unbekanntes Projekt sind 400, eine unbekannte AB ist 404 – nichts ändert sich', async () => { + const a = await gesendet(baueDeps().deps, OHNE_PROJEKT) + const faelle = [ + ['Kennung fehlt', undefined, MELDUNG.projektFehlt], + ['Kennung nur Leerzeichen', ' ', MELDUNG.projektFehlt], + ['Kennung keine Zeichenkette', 5, MELDUNG.projektFehlt], + ['Kennung mit ../', '../x', MELDUNG.projektUngueltig], + ['Projekt eines anderen Kunden', FREMD.projekt, MELDUNG.projektFremd], + ['unbekanntes Projekt', 'p_gibtsnicht', MELDUNG.projektFremd], + ] + for (const [name, projektId, meldung] of faelle) await abgelehnt(ordneProjektZu(a.id, projektId), 400, meldung, name) + await abgelehnt(ordneProjektZu('a_gibtsnicht', PROJEKT_ID), 404, MELDUNG.auftragFehlt) + await abgelehnt(ordneProjektZu('../x', PROJEKT_ID), 404, MELDUNG.auftragFehlt) + assert.deepEqual(await getAuftrag(a.id), a) +}) + +test('ordneProjektZu: eine AB mit Projekt (auch dasselbe) sowie eine stornierte, ersetzte oder widerrufene AB lassen sich nicht zuordnen (409); versendet, bestätigt und abgerechnet gehen', async () => { + const mit = await gesendet(baueDeps().deps) // gehört schon zu PROJEKT_ID + legeZweitesProjektAn() + await abgelehnt(ordneProjektZu(mit.id, PROJEKT_ID), 409, MELDUNG.schonProjekt, 'dasselbe Projekt') + await abgelehnt(ordneProjektZu(mit.id, ZWEITES_PROJEKT), 409, MELDUNG.schonProjekt, 'anderes Projekt') + await abgelehnt(ordneProjektZu(mit.id, FREMD.projekt), 409, MELDUNG.schonProjekt, 'fremdes Projekt: der Stand der AB gilt zuerst') + assert.deepEqual(await getAuftrag(mit.id), mit) + const frei = await gesendet(baueDeps().deps, OHNE_PROJEKT) + for (const status of ['storniert', 'ersetzt', 'widerrufen']) { + await updateAuftrag(frei.id, { status }) + await abgelehnt(ordneProjektZu(frei.id, PROJEKT_ID), 409, MELDUNG.keinProjekt, status) + } + assert.equal((await getAuftrag(frei.id)).projektId, '') + for (const status of ['versendet', 'bestaetigt', 'abgerechnet']) { + await updateAuftrag(frei.id, { status, projektId: '', zeitquelle: [] }) + assert.equal((await ordneProjektZu(frei.id, PROJEKT_ID)).projektId, PROJEKT_ID, status) + } +}) + +test('ordneProjektZu wartet auf die Sperre der AB und prüft den Stand danach (L5 widerruft gerade: 409)', async () => { + const a = await gesendet(baueDeps().deps, OHNE_PROJEKT) + const lauf = await unterFremderSperre(a.id, { + aktion: () => ordneProjektZu(a.id, PROJEKT_ID), + waehrend: async () => { + assert.equal((await getAuftrag(a.id)).projektId, '') // noch nichts geschrieben + await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() }) + }, + }) + assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinProjekt]) + assert.equal((await getAuftrag(a.id)).projektId, '') +}) + +test('ordneProjektZu: zwei Aufrufe gleichzeitig (zwei Admins) – genau einer gelingt, der andere ist 409', async () => { + const a = await gesendet(baueDeps().deps, OHNE_PROJEKT) + legeZweitesProjektAn() + const ergebnisse = await Promise.allSettled([ordneProjektZu(a.id, PROJEKT_ID), ordneProjektZu(a.id, ZWEITES_PROJEKT)]) + assert.deepEqual(ergebnisse.map((r) => r.status).sort(), ['fulfilled', 'rejected']) + const abgewiesen = ergebnisse.find((r) => r.status === 'rejected').reason + assert.deepEqual([abgewiesen.status, abgewiesen.message], [409, MELDUNG.schonProjekt]) + const gewinner = ergebnisse.find((r) => r.status === 'fulfilled').value + assert.deepEqual(await getAuftrag(a.id), gewinner) +})