AB-Kern: Neue Fassung und Ersetzen der alten AB beim Senden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0da5b30119
commit
cf87a7e1e0
2 files changed
+156
-2
No files matched your search
@@ -12,12 +12,13 @@ import { getSettings } from '../finance/settings.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { kundeSnapshot } from './auftragSicht.js'
|
||||
import { entfernePdf, lesePdf, speicherePdf } from './ablage.js'
|
||||
import { createAuftrag, deleteAuftrag, getAuftrag, updateAuftrag } from './auftragStore.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 { TEXTE_FASSUNG } from './texte.js'
|
||||
import {
|
||||
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
|
||||
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
|
||||
} from './konstanten.js'
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
@@ -407,6 +408,33 @@ async function versendeMail(d, gespeichert, { pdf, token, erinnerung = false })
|
||||
return { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' }
|
||||
}
|
||||
|
||||
const MELDUNG_ERSETZTE_UNGUELTIG = 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.'
|
||||
|
||||
/**
|
||||
* Prüft die AB, die der Entwurf ersetzen soll: gültig sind versendet, bestätigt, abgerechnet oder (bei der Wiederholung
|
||||
* nach einem Fehler) schon durch genau diesen Entwurf ersetzt. Zeigt ersetztId auf den Entwurf selbst (beschädigte Daten),
|
||||
* gibt es sofort 409, damit ersetzeAlteAb nicht den schon gehaltenen Schlüssel noch einmal sperrt.
|
||||
*/
|
||||
async function pruefeErsetzteAb(entwurf) {
|
||||
if (entwurf.ersetztId === entwurf.id) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG)
|
||||
const alt = await getAuftrag(entwurf.ersetztId)
|
||||
const gueltig = alt && (VEREINBARTE_STATUS.includes(alt.status) || (alt.status === 'ersetzt' && alt.ersetztDurchId === entwurf.id))
|
||||
if (!gueltig) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG)
|
||||
return alt
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt die alte AB (Status ersetzt + ersetztDurchId) und hängt ihre Projektrechnungen um – vor dem Speichern der neuen
|
||||
* AB, wiederholbar. Der Stand der alten AB wird unter ihrer eigenen Sperre noch einmal geprüft; ihr tokenHash bleibt.
|
||||
*/
|
||||
async function ersetzeAlteAb(entwurf) {
|
||||
await withLocks(sperreAuftrag(entwurf.ersetztId), async () => {
|
||||
const alt = await pruefeErsetzteAb(entwurf)
|
||||
if (alt.status !== 'ersetzt') await updateAuftrag(alt.id, { status: 'ersetzt', ersetztDurchId: entwurf.id })
|
||||
await umhaengen(alt.id, entwurf.id)
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) {
|
||||
void von // wird nicht gespeichert (V1 hat kein Feld dafür); die Team-Kopie der Mail belegt den Versand
|
||||
const d = await ladeDeps(deps, ['erzeugeAuftragPdf', 'neuerToken', 'sendeAuftragMail'])
|
||||
@@ -417,6 +445,7 @@ export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) {
|
||||
pruefeEingabe(eingabeAusAuftrag(entwurf))
|
||||
const kundeDoc = await ladeKunde(entwurf.kundenId)
|
||||
pruefeKundeFuerVersand(kundeDoc)
|
||||
if (entwurf.ersetztId) await pruefeErsetzteAb(entwurf) // früh: vor Nummer und PDF
|
||||
const kunde = kundeSnapshot(kundeDoc)
|
||||
const { steuernummer, bankDetailsText } = await getSettings()
|
||||
const { nummer, jahr, laufnummer } = await vergibNummer(jetzt)
|
||||
@@ -433,6 +462,7 @@ export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) {
|
||||
}
|
||||
try {
|
||||
const neu = d.neuerToken(jetzt)
|
||||
if (entwurf.ersetztId) await ersetzeAlteAb(entwurf)
|
||||
const gespeichertesDokument = await updateAuftrag(entwurf.id, {
|
||||
status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG,
|
||||
pdfDatei: ablage.pdfDatei, pdfSha256: ablage.pdfSha256, gesendetAn: kunde.email, tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(),
|
||||
@@ -480,3 +510,22 @@ export async function erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) {
|
||||
})
|
||||
return versendeMail(d, gespeichert, { pdf, token, erinnerung })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Neue Fassung
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Legt aus einer versendeten, bestätigten oder abgerechneten AB einen neuen Entwurf an (ersetztId); die alte bleibt. */
|
||||
export async function neueFassung(id, { von } = {}) {
|
||||
return withLocks(sperreAuftrag(id), async () => {
|
||||
const alt = await ladeAuftrag(id)
|
||||
if (!VEREINBARTE_STATUS.includes(alt.status)) {
|
||||
throw new AuftragError(409, 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.')
|
||||
}
|
||||
const vorhandene = await listAuftraege({ kundenId: alt.kundenId })
|
||||
if (vorhandene.some((a) => a.status === 'entwurf' && a.ersetztId === alt.id)) {
|
||||
throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.')
|
||||
}
|
||||
return createAuftrag({ kundenId: alt.kundenId, ...speicherFelder(alt), ersetztId: alt.id, erstelltVon: String(von || '') })
|
||||
})
|
||||
}
|
||||
@@ -119,3 +119,108 @@ test('erinnereAuftrag wartet auf die Sperre der AB und prüft den Stand danach (
|
||||
assert.equal(calls.mail.length, 1) // nur die Mail des ersten Sendens
|
||||
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash)
|
||||
})
|
||||
|
||||
test('neueFassung: Kopie als Entwurf mit ersetztId, ohne Nummer, Schnappschuss, PDF und Token; die alte AB bleibt unberührt', async () => {
|
||||
const alt = await gesendet(baueDeps().deps)
|
||||
const neu = await neueFassung(alt.id, { von: 'justin@webklar.com' })
|
||||
assert.deepEqual([neu.status, neu.ersetztId, neu.nummer, neu.kunde, neu.erstelltVon], ['entwurf', alt.id, '', null, 'justin@webklar.com'])
|
||||
assert.ok(!neu.pdfDatei && !neu.tokenHash && !neu.gesendetAm)
|
||||
for (const feld of ['kundenId', 'projektId', 'titel', 'zeitquelle', 'art', 'positionen', 'stundensatzCents', 'schaetzungMinuten', 'summeCents', 'zahlungsplan', 'zahlungszielTage', 'auftragVom', 'auftragWeg', 'startAm', 'fertigBis']) {
|
||||
assert.deepEqual(neu[feld], alt[feld], feld)
|
||||
}
|
||||
assert.deepEqual(await getAuftrag(alt.id), alt)
|
||||
})
|
||||
|
||||
test('neueFassung: nur für versendete, bestätigte und abgerechnete (409); je AB nur ein offener Entwurf', async () => {
|
||||
const alt = await gesendet(baueDeps().deps)
|
||||
for (const status of ['entwurf', 'storniert', 'ersetzt', 'widerrufen']) {
|
||||
await updateAuftrag(alt.id, { status })
|
||||
await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.keineNeueFassung, status)
|
||||
}
|
||||
for (const status of ['bestaetigt', 'abgerechnet', 'versendet']) {
|
||||
await updateAuftrag(alt.id, { status })
|
||||
const neu = await neueFassung(alt.id, { von: VON })
|
||||
await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.schonEntwurf, status)
|
||||
await loescheEntwurf(neu.id)
|
||||
}
|
||||
await abgelehnt(neueFassung('a_gibtsnicht', { von: VON }), 404, MELDUNG.auftragFehlt)
|
||||
})
|
||||
|
||||
test('Senden der neuen Fassung: die alte AB wird ersetzt, ihre Rechnungen wandern mit, der alte Link bleibt auffindbar', async () => {
|
||||
const { deps } = baueDeps()
|
||||
const alt = await gesendet(deps)
|
||||
await createProjektRechnung('in_Anzahlung1', {
|
||||
kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
|
||||
rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(),
|
||||
})
|
||||
const entwurf = await neueFassung(alt.id, { von: VON })
|
||||
await aendereEntwurf(entwurf.id, { positionen: [{ beschreibung: 'Webdesign Startseite', menge: 1, einzelpreisCents: 70000 }] })
|
||||
const { auftrag: neu } = await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: new Date('2026-10-07T10:00:00Z') })
|
||||
assert.deepEqual([neu.nummer, neu.status, neu.ersetztId, neu.summeCents], ['AB-2026-0002', 'versendet', alt.id, 70000])
|
||||
const altNachher = await getAuftrag(alt.id)
|
||||
assert.deepEqual([altNachher.status, altNachher.ersetztDurchId, altNachher.tokenHash], ['ersetzt', neu.id, alt.tokenHash])
|
||||
assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id)
|
||||
assert.equal((await getProjektRechnung('in_Anzahlung1')).auftragId, neu.id)
|
||||
})
|
||||
|
||||
test('Senden der neuen Fassung: ist die alte AB inzwischen storniert, gibt es 409 vor der Nummer', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const alt = await gesendet(deps)
|
||||
const neu = await neueFassung(alt.id, { von: VON })
|
||||
await updateAuftrag(alt.id, { status: 'storniert' })
|
||||
await abgelehnt(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig)
|
||||
assert.equal(calls.pdf.length, 1) // nur die PDF der ersten AB
|
||||
const danach = await getAuftrag(neu.id)
|
||||
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
|
||||
assert.equal((await getAuftrag(alt.id)).status, 'storniert')
|
||||
})
|
||||
|
||||
test('Senden der neuen Fassung: scheitert das Speichern erst nach dem Ersetzen der alten AB, führt die Wiederholung alles zu Ende', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const { deps } = baueDeps()
|
||||
const alt = await gesendet(deps)
|
||||
await createProjektRechnung('in_Anzahlung2', {
|
||||
kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
|
||||
rechnungsnummer: 'WEBKLAR-0008', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(),
|
||||
})
|
||||
const neu = await neueFassung(alt.id, { von: VON })
|
||||
// erster PATCH auf auftraege: alte AB ersetzt (geht durch); zweiter PATCH: Speichern der neuen AB (scheitert)
|
||||
aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500, after: 1 })
|
||||
await assert.rejects(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), (err) => err.status === 500)
|
||||
assert.equal((await getAuftrag(neu.id)).status, 'entwurf')
|
||||
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt
|
||||
const { auftrag } = await sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT })
|
||||
assert.equal(auftrag.nummer, 'AB-2026-0002')
|
||||
assert.deepEqual([(await getAuftrag(alt.id)).status, (await getAuftrag(alt.id)).ersetztDurchId], ['ersetzt', neu.id])
|
||||
assert.equal((await getProjektRechnung('in_Anzahlung2')).auftragId, neu.id)
|
||||
})
|
||||
|
||||
test('Senden der neuen Fassung wartet auf die Sperre der alten AB und prüft sie danach noch einmal (L5 widerruft gerade)', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const alt = await gesendet(deps)
|
||||
const neu = await neueFassung(alt.id, { von: VON })
|
||||
const lauf = await unterFremderSperre(alt.id, {
|
||||
aktion: () => sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }),
|
||||
bereit: () => calls.pdf.length === 2, // das PDF der neuen AB ist fertig, als Nächstes kommt das Ersetzen
|
||||
waehrend: async () => {
|
||||
assert.equal((await getAuftrag(alt.id)).status, 'versendet') // noch nicht ersetzt
|
||||
await updateAuftrag(alt.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
|
||||
},
|
||||
})
|
||||
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.ersetzteUngueltig])
|
||||
const danach = await getAuftrag(neu.id)
|
||||
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
|
||||
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt
|
||||
assert.equal((await getAuftrag(alt.id)).status, 'widerrufen')
|
||||
})
|
||||
|
||||
test('Senden: zeigt die ersetztId eines Entwurfs auf ihn selbst (beschädigte Daten), gibt es 409 statt eines Hängers an der eigenen Sperre', { timeout: 5000 }, async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await updateAuftrag(entwurf.id, { ersetztId: entwurf.id })
|
||||
await abgelehnt(sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig)
|
||||
assert.deepEqual([calls.pdf.length, calls.mail.length], [0, 0])
|
||||
const danach = await getAuftrag(entwurf.id)
|
||||
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
|
||||
assert.deepEqual(dateienIn(uploadDir), [])
|
||||
})
|
||||
Reference in new issue
Block a user