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 { 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)); }