diff --git a/server/services/auftraege/auftragStore.js b/server/services/auftraege/auftragStore.js index dcb2545..d6d5363 100644 --- a/server/services/auftraege/auftragStore.js +++ b/server/services/auftraege/auftragStore.js @@ -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 +} diff --git a/server/services/auftraege/projektkonto.js b/server/services/auftraege/projektkonto.js index 542048e..4692138 100644 --- a/server/services/auftraege/projektkonto.js +++ b/server/services/auftraege/projektkonto.js @@ -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, diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js index da55fe6..b33ea8a 100644 --- a/server/services/auftraege/rechnungsbau.js +++ b/server/services/auftraege/rechnungsbau.js @@ -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). */ diff --git a/test/auftraegeKontrolleRechnung.test.js b/test/auftraegeKontrolleRechnung.test.js index 27d5082..9dc28e5 100644 --- a/test/auftraegeKontrolleRechnung.test.js +++ b/test/auftraegeKontrolleRechnung.test.js @@ -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€/) +})