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>
82 lines
3.3 KiB
TypeScript
82 lines
3.3 KiB
TypeScript
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 };
|
|
}
|