AB-Kern: Projekt nachträglich einer Auftragsbestätigung zuordnen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:15:37 +00:00
1 parent c791146af4
commit 66ec8e0a44
2 files changed
+158 -1

No files matched your search

+32 -1
View File
@@ -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)
})
}
+126
View File
@@ -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)
})