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>
91 lines
5.4 KiB
TypeScript
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));
|
|
}
|