test-abrechnung-service (118 Prüfungen inkl. Mandantentrennung und PDF-Render-Smoke), test-abrechnung-sync (21), Fixture-Zeilen für die neuen Tenant-Modelle, Rollen-Matrix billing:*, smoke-auth um /billing, Detail, Druckansicht, Auftrags-Tab und mobile Meilensteine erweitert, scripts/billing-backfill.ts, docs/craftvia/lanes/abrechnung.md, ABNAHME §3 „Abrechnungsübersicht“ (keine Buchhaltung). Gate 69/69, RLS 69/69.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ok(releaseRecords.length===1&&releaseRecords[0].kind==="order_completion","bestehende Abrechnungsfreigabe (L2) erzeugt den Eintrag über das Event work_order.released_for_billing");
ok(dailyRecords.length===1&&dailyRecords[0].kind==="daily_report"&&dailyRecords[0].reportId===daily1.id,"freigegebener Tagesbericht → ein Tagesbericht-Eintrag (Event doppelt → kein Doppel)");
ok(dailyRecords[0].periodTo.getTime()-dailyRecords[0].periodFrom.getTime()>=23*3600_000&&dailyRecords[0].periodFrom.getTime()===berlinAt(-2,0).getTime(),"Zeitraum = Kalendertag des Berichts (Mandanten-Zeitzone)");
ok(countTrips([{startedAt: berlinAt(-2,7),endedAt: berlinAt(-2,7,30)},{startedAt: berlinAt(-2,7,5),endedAt: berlinAt(-2,7,30)}],"Europe/Berlin").count===1,"Kolonne: 2 Personen mit zeitgleicher Anfahrt = 1 Fahrt");
ok(countTrips([{startedAt: berlinAt(-2,7),endedAt: berlinAt(-2,7,30)},{startedAt: berlinAt(-1,7),endedAt: berlinAt(-1,7,30)}],"Europe/Berlin").count===2,"zwei Tage mit Anfahrt = 2 Fahrten");
ok(countTrips([{startedAt: berlinAt(-2,7),endedAt: berlinAt(-2,7,30)},{startedAt: berlinAt(-2,13),endedAt: berlinAt(-2,13,20)}],"Europe/Berlin").count===2,"getrennte Anfahrten am selben Tag = 2 Fahrten");
awaitprisma.customer.update({where:{id: A.customerId},data:{billingNotes:"Rechnung nur mit Bestellnummer 4711"}});
ok(s.trips.count===2&&s.trips.dates.join()===[dayKey(-2),dayKey(-1)].join(),"Anfahrten: Kolonne am Tag −2 = 1 Fahrt, Tag −1 = 1 Fahrt → 2 mit Datumsliste");
ok(s.materials.used.length===1&&s.materials.used[0].quantity==="12"&&s.materials.used[0].deviationReason==="Leitung länger","verwendetes Material mit Menge, Einheit und Abweichungsgrund");
ok(s.materials.additional.length===1&&s.materials.additional[0].name==="Kugelhahn","zusätzlich verwendetes Material getrennt");
ok(s.materials.notUsed.length===2&&s.materials.notUsed.some((m)=>m.name==="Pressfitting"&&m.status===null),"nicht verwendete Planpositionen informativ (erfasst „nicht verwendet“ + ohne Erfassung)");
ok(s.customer.billingNotes==="Rechnung nur mit Bestellnummer 4711"&&s.workOrder.externalOrderNumber==="EXT-9"&&s.workOrder.offerNumber==="ANG-3"&&s.workOrder.billingType==="time_material","Kopf: Abrechnungshinweise, externe/Angebotsnummer, Abrechnungsart");
ok(s.texts.additionalWork[0]?.text==="Zusätzlich Heizkörper entlüftet"&&s.texts.openItems[0]?.reportNumber==="B-00077"&&s.reports.length===1,"Zusatzleistungen/Abweichungen/Restarbeiten aus Berichten + Berichtsreferenz");
ok(JSON.stringify(s).match(/price|amount|tax|preis|betrag|steuer/i)===null,"Aufstellung enthält keine Preise, Beträge oder Steuern");
ok(frozen.time.totals.total===965&&frozen.customer.billingNotes==="Rechnung nur mit Bestellnummer 4711"&&frozen.record.status==="billed","Snapshot eingefroren: spätere Änderungen an Zeiten/Kunde ändern die Aufstellung nicht");
// a second section on the same order does not contain billed positions
ok(dailyStatement.positions.timeEntryIds.length===0&&dailyStatement.positions.materialUsageIds.length===0&&dailyStatement.time.pendingMinutes===60,"zweiter Abschnitt enthält bereits abgerechnete Positionen nicht (nur den offenen Hinweis)");
constvoided=awaitvoidBilling(bo,{recordId: rec.id,reason:"Rechnung an falschen Empfänger"});
ok(voided.voided.status==="voided"&&voided.voided.voidReason==="Rechnung an falschen Empfänger"&&!!voided.voided.voidedAt,"storniert mit Grund");
ok((awaitprisma.timeEntry.count({where:{billingRecordId: rec.id}}))===0&&(awaitprisma.materialUsage.count({where:{billingRecordId: rec.id}}))===0,"Positionen wieder frei");
ok(!!voided.reopened&&voided.reopened.status==="open"&&voided.reopened.kind==="order_completion"&&voided.reopened.id!==rec.id,"neuer offener Eintrag für dieselbe Quelle");
ok(woAfterVoid.status==="released_for_billing","Auftrag zurück auf „Zur Abrechnung freigegeben“ (nur über Stornierung)");
ok(!!(awaitprisma.workOrderStatusChange.findFirst({where:{workOrderId: wo.id,fromStatus:"billed",toStatus:"released_for_billing",reason:"Rechnung an falschen Empfänger"}})),"Statushistorie mit Stornogrund");
ok((awaitprisma.billingRecord.count({where:{workOrderId: wo.id,kind:"order_completion",status:"open"}}))===1,"genau ein offener Auftragsabschluss nach Stornierung (Event-Hook + Stornierung ohne Doppel)");
ok(!!rejNotif&&rejNotif.message.includes("Druckprotokoll fehlt")&&rejNotif.link===`/m/orders/${woM.id}`,"Event milestone.rejected → Meldender In-App mit Grund und Link");
awaitexpectCode(()=>rejectMilestone(bo,{milestoneId: m1.id,reason:"x"}),"conflict","offener Meilenstein kann nicht abgelehnt werden");
ok((awaitbuildBillingStatement(bo,m2Record.id)).positions.timeEntryIds.length===0,"nächster Meilenstein enthält die bereits abgerechneten Zeiten nicht");
ok((awaitprisma.workOrder.findUniqueOrThrow({where:{id: woM.id}})).status==="in_progress","Meilenstein-Abrechnung ändert den Auftragsstatus nicht");
awaitexpectCode(()=>buildBillingStatement(A.ctx.lead,rec.id),"forbidden","Aufstellung für Teamleiter → forbidden");
ok((awaitcountOpenBillingRecords(bo))===openList.total&&(awaitcountOpenBillingRecords(A.ctx.tech))===0,"Zähler „Bereit zur Abrechnung“ (0 ohne Recht)");
ok(listB.items.length===1&&listB.items[0].workOrder.id===woB.id,"Liste B enthält nur Einträge von B");
ok(!(awaitlistBillingRecords(B.ctx.backoffice,{status:"voided"})).items.some((i)=>i.id===rec.id),"stornierter Eintrag von A nicht in B");
awaitexpectCode(()=>getBillingRecordDetail(B.ctx.backoffice,rec.id),"not_found","Detail A aus B → not_found");
awaitexpectCode(()=>buildBillingStatement(B.ctx.backoffice,voided.reopened!.id),"not_found","Aufstellung A aus B → not_found");
awaitexpectCode(()=>markBilled(B.ctx.backoffice,{recordId: voided.reopened!.id,confirmPendingExcluded: true},noPdf),"not_found","abrechnen A aus B → not_found");
awaitexpectCode(()=>voidBilling(B.ctx.backoffice,{recordId: mRecords[0].id,reason:"x"}),"not_found","stornieren A aus B → not_found");
awaitexpectCode(()=>updateInvoiceNumber(B.ctx.backoffice,{recordId: mRecords[0].id,invoiceNumber:"x"}),"not_found","Rechnungsnummer A aus B → not_found");
awaitexpectCode(()=>createMilestone(B.ctx.backoffice,{workOrderId: woM.id,title:"x"}),"not_found","Meilenstein an Auftrag A aus B → not_found");
awaitexpectCode(()=>markMilestoneReached(B.ctx.admin,{milestoneId: m2.id}),"not_found","Meilenstein A melden aus B → not_found");
awaitexpectCode(()=>confirmMilestone(B.ctx.backoffice,m2.id),"not_found","Meilenstein A bestätigen aus B → not_found");
awaitexpectCode(()=>generateBillingPdf(B.ctx.backoffice,mRecords[0].id),"not_found","PDF von A aus B → not_found");
ok((awaitsyncBillingCandidates(B.ctx.backoffice,{workOrderId: woDone.id})).created===0,"Kandidaten-Sync mit Auftrag von A im Kontext B legt nichts an");
ok((awaitprisma.billingRecord.count({where:{tenantId: B.tenantId,workOrderId:{in:[wo.id,woM.id,woDone.id]}}}))===0,"keine Einträge mit Aufträgen von A in B");
ok((awaitcountOpenBillingRecords(B.ctx.backoffice))===1,"Zähler B unabhängig");
ok(!pdf.skipped&&doc.category==="other"&&doc.visibility==="backoffice_only"&&(doc.title??"").startsWith("Abrechnungsblatt")&&doc.workOrderId===woM.id,"PDF als Dokument (Kategorie other, backoffice_only, Titel „Abrechnungsblatt …“, am Auftrag)");
ok(SYNC_OP_TYPES.includes("milestone.reach")&&typeofEXTERNAL_OPS["milestone.reach"]==="function","Op milestone.reach im Envelope und in der Registry registriert");
ok(r3.status==="applied"&&after3.reachedAt?.getTime()===after1.reachedAt?.getTime()&&after3.reachedNote==="offline gemeldet","neue clientOpId für bereits gemeldeten Meilenstein → applied ohne Änderung");
ok((awaitprisma.syncOperation.count({where:{tenantId: A.tenantId,opType:"milestone.reach"}}))===2,"zwei gespeicherte Operationen, keine Doppelanlage");
ok(lead.status==="applied"&&(awaitprisma.workOrderMilestone.findUniqueOrThrow({where:{id: mOther.id}})).status==="reached","Teamleiter des Auftrags-Teams kann melden");
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.