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:
@@ -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