Abrechnen nur über die Abrechnungsübersicht, solange das Modul aktiv ist
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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<TransitionResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -78,12 +78,19 @@ export async function applyTransition(
|
||||
eventData?: Record<string, string | number | boolean | null>;
|
||||
/** 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<TransitionResult> {
|
||||
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<boolean> {
|
||||
const row = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } });
|
||||
return !row || row.enabled;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user