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>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user