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() {
|
async function main() {
|
||||||
await cleanupTenants([SLUG]);
|
await cleanupTenants([SLUG]);
|
||||||
const f = await createFixture(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 —");
|
console.log("\n— (1) Übergangstabelle —");
|
||||||
let tableOk = true;
|
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([]),
|
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 };
|
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") {
|
if (record.kind === "order_completion") {
|
||||||
const wo = await loadVisibleWorkOrder(tx, record.workOrderId);
|
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 } });
|
const after = await tx.db.billingRecord.findFirstOrThrow({ where: { id: record.id } });
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { ServiceCtx } from "@/server/services/context";
|
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
|
* 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 });
|
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> {
|
export async function markBilled(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }): Promise<TransitionResult> {
|
||||||
|
if (!(await billingModuleActive(ctx))) {
|
||||||
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "billed", baseVersion: input.baseVersion });
|
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>;
|
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) */
|
/** L14: billed → released_for_billing is ONLY allowed when a billing record is voided (services/billing/records.ts) */
|
||||||
billingVoid?: boolean;
|
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> {
|
): Promise<TransitionResult> {
|
||||||
const from = wo.status;
|
const from = wo.status;
|
||||||
const voidRevert = opts.billingVoid === true && from === "billed" && to === "released_for_billing";
|
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 && !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 });
|
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);
|
assertBaseVersion(wo, opts.baseVersion);
|
||||||
if ((voidRevert || reasonRequired(from, to)) && !opts.reason?.trim()) throw new ServiceError("invalid", "reason_required", { from, to });
|
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 };
|
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