L2 Aufträge & Backoffice: Service-Schicht Aufträge + Tests

Statusmaschine (transitionWorkOrder inkl. eventData), Zuweisung, Completion-Guards,
Materialvorgabe, Checklisten/Pflichtfotos, Liste/Dashboard-Presets, Suche,
Sync-Konflikte, Einstellungen (Auftragsarten, Vorlagen, Nummernkreise).
Tests: Übergangsmatrix je Rolle, Kernlogik, Scope/Mandantentrennung, Nummernkreis-Parallelität.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:29:15 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 2311b35d8c
29 changed files with 2863 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
import { assignSchema, type AssignInput } from "@/lib/work-orders/schemas";
import { emitEvent } from "@/server/events";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import {
assertBaseVersion,
assertNotLocked,
auditWorkOrder,
loadVisibleWorkOrder,
parseInput,
PLANNING_LOCKED,
writeWithVersion,
} from "@/server/services/work-orders/_shared";
/**
* Assign a team (+ optional individual technicians and a responsible team lead) — US-004.
* draft/review_required/planned → assigned; accepted with a changed team → back to assigned.
*/
export async function assignWorkOrder(
ctx: ServiceCtx,
raw: AssignInput,
): Promise<{ id: string; version: number; status: WorkOrderStatus }> {
assertCan(ctx, "work_order:assign");
const input = parseInput(assignSchema, raw);
const wo = await loadVisibleWorkOrder(ctx, input.workOrderId);
assertNotLocked(wo, PLANNING_LOCKED);
assertBaseVersion(wo, input.baseVersion);
const team = await ctx.db.team.findFirst({
where: { id: input.teamId, deletedAt: null, status: "active" },
select: { id: true, name: true, leaderUserId: true },
});
if (!team) throw new ServiceError("invalid", "team_not_found");
const userIds = [...new Set(input.userIds)];
const leadId = input.teamLeadUserId ?? team.leaderUserId ?? null;
const toCheck = [...new Set([...userIds, ...(leadId ? [leadId] : [])])];
if (toCheck.length) {
const found = await ctx.db.user.count({ where: { id: { in: toCheck }, status: "ACTIVE" } });
if (found !== toCheck.length) throw new ServiceError("invalid", "user_not_found");
}
const previous = await ctx.db.workOrderAssignee.findMany({ where: { workOrderId: wo.id }, select: { userId: true } });
let to: WorkOrderStatus | null = null;
if (["draft", "review_required", "planned"].includes(wo.status)) to = "assigned";
else if (wo.status === "accepted" && wo.assignedTeamId !== team.id) to = "assigned";
if (to && !canTransition(wo.status, to)) to = null;
const version = await writeWithVersion(ctx, wo, {
assignedTeamId: team.id,
teamLeadUserId: leadId,
...(to ? { status: to } : {}),
});
await ctx.db.workOrderAssignee.deleteMany({ where: { workOrderId: wo.id, userId: { notIn: userIds } } });
if (userIds.length) {
await ctx.db.workOrderAssignee.createMany({
data: userIds.map((userId) => ({ tenantId: ctx.tenantId, workOrderId: wo.id, userId })),
skipDuplicates: true,
});
}
if (to) {
await ctx.db.workOrderStatusChange.create({
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: wo.status, toStatus: to, actorId: ctx.userId },
});
}
await auditWorkOrder(ctx, {
action: "update",
workOrderId: wo.id,
before: { assignedTeamId: wo.assignedTeamId, teamLeadUserId: wo.teamLeadUserId, userIds: previous.map((p) => p.userId), status: wo.status },
after: { assignedTeamId: team.id, teamLeadUserId: leadId, userIds, status: to ?? wo.status, version },
});
await emitEvent(ctx, {
type: "work_order.assigned",
entityType: "work_order",
entityId: wo.id,
data: { number: wo.number, teamId: team.id, teamName: team.name },
});
return { id: wo.id, version, status: to ?? wo.status };
}