Files
craftvia/src/server/services/billing/candidates.ts
T
msolarczekandClaude Opus 5 ac2d857e26 L14 Abrechnungsübersicht: Services für Einträge, Aufstellung, Abrechnen/Stornieren, Meilensteine und PDF
syncBillingCandidates (idempotent, Event-Hook für work_order.released_for_billing und report.approved), Aufstellung ohne Preise (nur freigegebene Zeiten, Fahrzeit getrennt, Pausen informativ, Anfahrten-Zählung, Material), markBilled mit eingefrorenem Snapshot und Positionszuordnung, voidBilling mit Grund und neuem offenen Eintrag (billed → released_for_billing nur über applyTransition-Option billingVoid), Meilenstein-Flow mit Events, Job billing-pdf (Dokument other/backoffice_only), Sync-Op milestone.reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:35:38 +02:00

91 lines
5.4 KiB
TypeScript

import type { Prisma } from "@prisma/client";
import { dayWindow, dbDateToKey } from "@/lib/reports/dates";
import type { ServiceCtx } from "@/server/services/context";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
/**
* Create missing `open` billing records of a work order (idempotent, L14 §3.2). Internal system
* operation: called from the event hook (work_order.released_for_billing, report.approved), from
* milestone confirmation and from scripts/billing-backfill.ts — it never checks user permissions
* and only reads/writes through the tenant client of `ctx`.
*
* Sources and periods:
* - order_completion: work order `released_for_billing` → from the end of the last billed section
* (or order creation) until now; contains everything of the order not billed yet.
* - milestone: confirmed milestone without record → from the end of the last billed section until
* the confirmation time; contains every unbilled position recorded before the confirmation.
* - daily_report: approved daily report of an order WITHOUT defined milestones → calendar day of
* the report (tenant time zone). A new report version replaces the report of a still open record.
*
* Duplicates are impossible: partial unique indexes (one non-voided record per source) + INSERT … ON
* CONFLICT DO NOTHING, which also keeps a surrounding transaction intact.
*/
export async function syncBillingCandidates(ctx: ServiceCtx, input: { workOrderId: string }, now = new Date()): Promise<{ created: number; updated: number }> {
const wo = await ctx.db.workOrder.findFirst({ where: { id: input.workOrderId, deletedAt: null }, select: { id: true, status: true, createdAt: true } });
if (!wo) return { created: 0, updated: 0 };
const [existing, milestones] = await Promise.all([
ctx.db.billingRecord.findMany({
where: { workOrderId: wo.id, status: { not: "voided" } },
select: { id: true, kind: true, status: true, milestoneId: true, reportId: true, periodTo: true },
}),
ctx.db.workOrderMilestone.findMany({ where: { workOrderId: wo.id, deletedAt: null }, select: { id: true, status: true, confirmedAt: true, billingRecordId: true } }),
]);
const billedTo = existing.filter((r) => r.status === "billed").map((r) => r.periodTo.getTime());
// nothing billed yet → from the first documented activity (work may be recorded before the order row was created, e.g. imports/Notdienst)
const periodFrom = billedTo.length ? new Date(Math.max(...billedTo)) : await firstActivity(ctx, wo.id, wo.createdAt);
const data: Prisma.BillingRecordCreateManyInput[] = [];
let updated = 0;
if (wo.status === "released_for_billing" && !existing.some((r) => r.kind === "order_completion")) {
data.push({ tenantId: ctx.tenantId, workOrderId: wo.id, kind: "order_completion", periodFrom, periodTo: now > periodFrom ? now : periodFrom });
}
for (const m of milestones) {
if (m.status !== "confirmed" || m.billingRecordId) continue;
if (existing.some((r) => r.kind === "milestone" && r.milestoneId === m.id)) continue;
const to = m.confirmedAt ?? now;
data.push({ tenantId: ctx.tenantId, workOrderId: wo.id, kind: "milestone", milestoneId: m.id, periodFrom: periodFrom < to ? periodFrom : to, periodTo: to });
}
if (milestones.length === 0) {
const reports = await ctx.db.report.findMany({
where: { workOrderId: wo.id, type: "daily", status: { in: ["approved", "superseded"] } },
select: { id: true, lineageId: true, status: true, reportDate: true },
});
const approved = reports.filter((r) => r.status === "approved");
if (approved.length) {
const tz = await tenantTimezone(ctx);
const lineageOf = new Map(reports.map((r) => [r.id, r.lineageId]));
for (const r of approved) {
const sameLineage = existing.filter((e) => e.kind === "daily_report" && e.reportId && lineageOf.get(e.reportId) === r.lineageId);
if (sameLineage.length) {
for (const e of sameLineage) {
if (e.status !== "open" || e.reportId === r.id) continue;
updated += (await ctx.db.billingRecord.updateMany({ where: { id: e.id, status: "open" }, data: { reportId: r.id } })).count;
}
continue;
}
const w = dayWindow(dbDateToKey(r.reportDate), tz);
data.push({ tenantId: ctx.tenantId, workOrderId: wo.id, kind: "daily_report", reportId: r.id, periodFrom: w.start, periodTo: w.end });
}
}
}
if (!data.length) return { created: 0, updated };
const res = await ctx.db.billingRecord.createMany({ data, skipDuplicates: true });
return { created: res.count, updated };
}
/** Earliest of: order creation, first time entry, first material entry, first approved report day. */
async function firstActivity(ctx: ServiceCtx, workOrderId: string, createdAt: Date): Promise<Date> {
const [entry, usage, report] = await Promise.all([
ctx.db.timeEntry.findFirst({ where: { workSession: { workOrderId } }, orderBy: { startedAt: "asc" }, select: { startedAt: true } }),
ctx.db.materialUsage.findFirst({ where: { workOrderId }, orderBy: { createdAt: "asc" }, select: { createdAt: true } }),
ctx.db.report.findFirst({ where: { workOrderId, status: "approved" }, orderBy: { reportDate: "asc" }, select: { reportDate: true } }),
]);
const times = [createdAt, entry?.startedAt, usage?.createdAt, report?.reportDate].filter((d): d is Date => !!d).map((d) => d.getTime());
return new Date(Math.min(...times));
}