Kontrolle: Projektkonto und Rechnungsbau waehlen dieselbe Aufwands-AB

Gemeinsame Auswahl juengsteAufwandAb (gueltig, jueengste nach gesendetAm) im AB-Speicher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 15:19:48 +00:00
1 parent 4177b76d82
commit c2245b6905
4 files changed
+32 -8

No files matched your search

+14 -1
View File
@@ -8,7 +8,7 @@
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { AuftragError } from './fehler.js'
import { LIMITS, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { LIMITS, VEREINBARTE_STATUS, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AUFTRAEGE_COLLECTIONS } from './schema.js'
const JSON_SPALTEN = { kundeJson: 'kunde', zeitquelleJson: 'zeitquelle', positionenJson: 'positionen', zahlungsplanJson: 'zahlungsplan' }
@@ -188,3 +188,16 @@ export async function findAuftragByNummer(nummer) {
if (typeof nummer !== 'string' || !AB_NUMMER.test(nummer)) return null
return findeEine('nummer', nummer)
}
const sendezeit = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0
/** Jüngste zuerst nach Sendezeitpunkt (ersatzweise Anlagezeit), bei Gleichstand nach Kennung. */
export const juengsteZuerst = (a, b) => sendezeit(b) - sendezeit(a) || String(b.id).localeCompare(String(a.id))
/**
* Die Aufwands-AB, mit der ein Projekt abgerechnet und angezeigt wird: unter den gültigen (versendet, bestätigt) die jüngste
* nach gesendetAm, sonst null. Projektkonto und Rechnungsbau benutzen beide diese eine Auswahl.
*/
export function juengsteAufwandAb(liste) {
return liste.filter((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)).sort(juengsteZuerst)[0] ?? null
}
+2 -4
View File
@@ -8,7 +8,7 @@ import { KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './k
import { AuftragError } from './fehler.js'
import { auftraegeAktiv } from './schalter.js'
import { auftragFuerTeam } from './auftragSicht.js'
import { listAuftraege } from './auftragStore.js'
import { listAuftraege, juengsteAufwandAb } from './auftragStore.js'
import { listProjektRechnungen } from './projektRechnungenStore.js'
import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquelleFuerProjekt } from './arbeitsblaetter.js'
@@ -21,8 +21,6 @@ import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquel
const FINALISIERT = new Set(['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich'])
const summe = (liste, feld) => liste.reduce((s, eintrag) => s + eintrag[feld], 0)
const zeitpunkt = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0
const juengsteZuerst = (a, b) => zeitpunkt(b) - zeitpunkt(a) || String(b.id).localeCompare(String(a.id))
const zeitWert = (minuten, satzCents) => Math.round((minuten * satzCents) / 60)
// Wert der Zeit = Summe der je Berliner Tag gerundeten Werte (Karte: Tag -> Minuten), genau wie die Zeilen einer Aufwandsrechnung
// (aufwandPositionen in L2: eine Zeile je Tag mit Math.round(Minuten × Satz / 60)); so laufen Konto und Rechnung nicht um Cent auseinander
@@ -40,7 +38,7 @@ export function rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], ab
const festpreise = gueltig.filter((auftrag) => auftrag.art === 'festpreis')
const festpreisIds = new Set(festpreise.map((auftrag) => auftrag.id))
const festpreisCents = festpreise.length ? festpreise.reduce((s, auftrag) => s + (Number(auftrag.summeCents) || 0), 0) : null
const aufwandAb = gueltig.filter((auftrag) => auftrag.art === 'aufwand').sort(juengsteZuerst)[0] ?? null
const aufwandAb = juengsteAufwandAb(gueltig)
const aufwand = aufwandAb
? {
stundensatzCents: aufwandAb.stundensatzCents > 0 ? aufwandAb.stundensatzCents : STUNDENSATZ_STANDARD_CENTS,
+2 -2
View File
@@ -16,7 +16,7 @@ import {
anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
} from './positionen.js'
import { auftraegeAktiv } from './schalter.js'
import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js'
import { getAuftrag, listAuftraege, updateAuftrag, juengsteAufwandAb } from './auftragStore.js'
import { getProjektRechnung, listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js'
import {
ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt,
@@ -227,7 +227,7 @@ async function ladeAufwandAuftrag(kunde, projekt, auftragId) {
}
if (!auftraegeAktiv(kunde)) return null
const liste = await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id })
return liste.find((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)) ?? null
return juengsteAufwandAb(liste)
}
/** Ausdrueckliche Auswahl der Blaetter (Pruefung, Zugehoerigkeit zum Kunden, Doppelabrechnung mit Selbstheilung). */
+14 -1
View File
@@ -16,7 +16,8 @@ const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
const { createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { projektkontoDetail } = await import('../server/services/auftraege/projektkonto.js')
const { createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { umhaengen } = await import('../server/services/auftraege/projektRechnungenStore.js')
@@ -132,3 +133,15 @@ test('Finalisieren einer Schlussrechnung setzt die AB aus dem Projektdatensatz a
assert.equal((await getAuftrag(neu.id)).status, 'abgerechnet')
assert.equal((await getAuftrag(ab.id)).status, 'ersetzt')
})
test('Stundensatz: bei zwei gültigen Aufwands-ABs rechnen Rechnung und Projektkonto mit derselben (jüngste nach gesendetAm)', async () => {
const basis = { art: 'aufwand', positionen: [], summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
// A zuerst angelegt, aber später gesendet; B danach angelegt, aber früher gesendet
await legeAuftragAn({ ...basis, stundensatzCents: 8000 }, { nummer: 'AB-2026-0010', gesendetAm: '2026-10-09T08:00:00.000Z' })
await new Promise((resolve) => setTimeout(resolve, 15))
await legeAuftragAn({ ...basis, stundensatzCents: 12000 }, { nummer: 'AB-2026-0011', gesendetAm: '2026-10-06T08:00:00.000Z' })
const vorschau = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
const konto = await projektkontoDetail('p1')
assert.equal(konto.konto.zeit.satzCents, 8000)
assert.match(vorschau.positionen[0].beschreibung, /à 80,00\s€/)
})