From 6b8cdf543b12101e086aa65c96476e0ba7e9d15f Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:53:16 +0200 Subject: [PATCH] =?UTF-8?q?Abrechnen=20nur=20=C3=BCber=20die=20Abrechnungs?= =?UTF-8?q?=C3=BCbersicht,=20solange=20das=20Modul=20aktiv=20ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direkter Übergang nach billed → invalid use_billing_overview (nach der Rechteprüfung), Button im Auftragsdetail ausgeblendet, markBilled läuft über den Abrechnungseintrag. Co-Authored-By: Claude Opus 5 --- scripts/test-auftraege-status.ts | 6 ++ scripts/test-billed-guard.ts | 55 +++++++++++++++++++ src/app/(app)/work-orders/[id]/page.tsx | 10 +++- src/server/services/billing/records.ts | 2 +- .../services/work-orders/release-billing.ts | 24 +++++++- src/server/services/work-orders/transition.ts | 13 +++++ 6 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 scripts/test-billed-guard.ts diff --git a/scripts/test-auftraege-status.ts b/scripts/test-auftraege-status.ts index 0ec9c83..d3138bb 100644 --- a/scripts/test-auftraege-status.ts +++ b/scripts/test-auftraege-status.ts @@ -53,6 +53,12 @@ const needsReason = (from: WorkOrderStatus, to: WorkOrderStatus) => async function main() { await cleanupTenants([SLUG]); const f = await createFixture(SLUG); + // role × transition table incl. the direct `billed` transition → billing overview module off (guard: test-billed-guard) + await prisma.tenantModule.upsert({ + where: { tenantId_moduleKey: { tenantId: f.tenantId, moduleKey: "billing" } }, + update: { enabled: false }, + create: { tenantId: f.tenantId, moduleKey: "billing", enabled: false }, + }); console.log("\n— (1) Übergangstabelle —"); let tableOk = true; diff --git a/scripts/test-billed-guard.ts b/scripts/test-billed-guard.ts new file mode 100644 index 0000000..2fe485a --- /dev/null +++ b/scripts/test-billed-guard.ts @@ -0,0 +1,55 @@ +// L14 follow-up: direct transition to `billed` is blocked while the billing overview module is active. +// (1) billing module active (default) → transitionWorkOrder(to billed) → invalid use_billing_overview +// (2) billing module disabled → direct transition allowed (legacy behaviour) +// +// Lauf: npx tsx scripts/test-billed-guard.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { ServiceError } from "../src/server/services/context"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { createTenant, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG = "zz-billed-guard"; + +async function releasedOrder(A: TenantFixture) { + return prisma.workOrder.create({ + data: { + tenantId: A.tenantId, + number: `BG-${randomUUID().slice(0, 6)}`, + customerId: A.customerId, + siteId: A.siteId, + title: "Abrechnungsschutz", + status: "released_for_billing", + assignedTeamId: A.teamId, + }, + }); +} + +void runSuite("Direktes Abrechnen gesperrt", [SLUG], async () => { + const A = await createTenant(SLUG); + + section("Modul billing aktiv"); + const wo1 = await releasedOrder(A); + let code = "ok"; + let message = ""; + try { + await transitionWorkOrder(A.ctx.backoffice, { workOrderId: wo1.id, to: "billed" }); + } catch (err) { + code = err instanceof ServiceError ? err.code : "error"; + message = err instanceof Error ? err.message : ""; + } + ok(code === "invalid" && message === "use_billing_overview", `(1) direkter Übergang → invalid use_billing_overview (erhalten ${code} ${message})`); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo1.id } })).status === "released_for_billing", "(1) Status unverändert"); + + section("Modul billing deaktiviert"); + await prisma.tenantModule.upsert({ + where: { tenantId_moduleKey: { tenantId: A.tenantId, moduleKey: "billing" } }, + update: { enabled: false }, + create: { tenantId: A.tenantId, moduleKey: "billing", enabled: false }, + }); + const wo2 = await releasedOrder(A); + const res = await transitionWorkOrder(A.ctx.backoffice, { workOrderId: wo2.id, to: "billed" }); + ok(res.status === "billed", "(2) ohne Abrechnungsmodul ist der direkte Übergang erlaubt"); +}); diff --git a/src/app/(app)/work-orders/[id]/page.tsx b/src/app/(app)/work-orders/[id]/page.tsx index 1d47d08..551480b 100644 --- a/src/app/(app)/work-orders/[id]/page.tsx +++ b/src/app/(app)/work-orders/[id]/page.tsx @@ -102,7 +102,15 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa showEdit ? listOrderTypes(ctx) : Promise.resolve([]), ]); - const billingTransitions = transitions.filter((to) => ["released_for_billing", "billed"].includes(to) || (wo.status === "in_review" && to === "in_progress") || (wo.status === "released_for_billing" && to === "in_review")); + // L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button + const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } }); + const billingOverviewActive = !billingModule || billingModule.enabled; + const billingTransitions = transitions.filter( + (to) => + (to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) || + (wo.status === "in_review" && to === "in_progress") || + (wo.status === "released_for_billing" && to === "in_review"), + ); const tabProps = { ctx, wo, locale, tz, canEdit: canEditPlanning }; diff --git a/src/server/services/billing/records.ts b/src/server/services/billing/records.ts index 085bb64..29a6ab5 100644 --- a/src/server/services/billing/records.ts +++ b/src/server/services/billing/records.ts @@ -90,7 +90,7 @@ export async function markBilled(ctx: ServiceCtx, raw: MarkBilledInput, deps: Bi } if (record.kind === "order_completion") { const wo = await loadVisibleWorkOrder(tx, record.workOrderId); - if (wo.status === "released_for_billing") await applyTransition(tx, wo, "billed"); + if (wo.status === "released_for_billing") await applyTransition(tx, wo, "billed", { fromBilling: true }); } const after = await tx.db.billingRecord.findFirstOrThrow({ where: { id: record.id } }); diff --git a/src/server/services/work-orders/release-billing.ts b/src/server/services/work-orders/release-billing.ts index 5ad97fb..c17ad3f 100644 --- a/src/server/services/work-orders/release-billing.ts +++ b/src/server/services/work-orders/release-billing.ts @@ -1,5 +1,9 @@ import type { ServiceCtx } from "@/server/services/context"; -import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition"; +import { syncBillingCandidates } from "@/server/services/billing/candidates"; +import { markBilled as markRecordBilled } from "@/server/services/billing/records"; +import { assertCan, ServiceError } from "@/server/services/context"; +import { assertBaseVersion, loadVisibleWorkOrder } from "@/server/services/work-orders/_shared"; +import { billingModuleActive, transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition"; /** * Billing workflow (US-009). All paths go through transitionWorkOrder, which enforces @@ -29,6 +33,22 @@ export async function revokeBillingRelease( return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_review", reason: input.reason, baseVersion: input.baseVersion }); } +/** + * Mark the order billed. With the billing overview module active (L14) this goes through the order + * completion record of the overview (snapshot + position assignment); otherwise direct transition. + */ export async function markBilled(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }): Promise { - return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "billed", baseVersion: input.baseVersion }); + if (!(await billingModuleActive(ctx))) { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "billed", baseVersion: input.baseVersion }); + } + assertCan(ctx, "billing:write"); + const wo = await loadVisibleWorkOrder(ctx, input.workOrderId); + assertBaseVersion(wo, input.baseVersion); + if (wo.status !== "released_for_billing") throw new ServiceError("invalid", "transition_invalid", { from: wo.status, to: "billed" }); + await syncBillingCandidates(ctx, { workOrderId: wo.id }); + const record = await ctx.db.billingRecord.findFirst({ where: { workOrderId: wo.id, kind: "order_completion", status: "open" } }); + if (!record) throw new ServiceError("not_found", "billing_record_not_found"); + await markRecordBilled(ctx, { recordId: record.id }); + const after = await ctx.db.workOrder.findFirstOrThrow({ where: { id: wo.id }, select: { id: true, status: true, version: true } }); + return { ...after, from: wo.status }; } diff --git a/src/server/services/work-orders/transition.ts b/src/server/services/work-orders/transition.ts index 2aacae0..22e664c 100644 --- a/src/server/services/work-orders/transition.ts +++ b/src/server/services/work-orders/transition.ts @@ -78,12 +78,19 @@ export async function applyTransition( eventData?: Record; /** L14: billed → released_for_billing is ONLY allowed when a billing record is voided (services/billing/records.ts) */ billingVoid?: boolean; + /** L14: set only by services/billing (markBilled) – the direct transition to `billed` is blocked while the billing module is active. */ + fromBilling?: boolean; } = {}, ): Promise { const from = wo.status; const voidRevert = opts.billingVoid === true && from === "billed" && to === "released_for_billing"; if (!voidRevert && !canTransition(from, to)) throw new ServiceError("invalid", "transition_not_allowed", { from, to }); if (voidRevert ? !can(ctx, "work_order:release_billing") : !mayTransition(ctx, from, to)) throw new ServiceError("forbidden", "transition_forbidden", { from, to }); + // after the permission check: roles without billing rights keep getting `forbidden` + if (to === "billed" && !opts.fromBilling && (await billingModuleActive(ctx))) { + // billed must be set via the billing overview: it freezes the statement and assigns time/material positions + throw new ServiceError("invalid", "use_billing_overview", { from, to }); + } assertBaseVersion(wo, opts.baseVersion); if ((voidRevert || reasonRequired(from, to)) && !opts.reason?.trim()) throw new ServiceError("invalid", "reason_required", { from, to }); @@ -108,3 +115,9 @@ export async function applyTransition( }); return { id: wo.id, status: to, version, from }; } + +/** L14: billing overview active for the tenant (missing TenantModule row = enabled, like requireModule). */ +export async function billingModuleActive(ctx: ServiceCtx): Promise { + const row = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } }); + return !row || row.enabled; +}