Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/projects/projectService.server.ts
T
2026-07-08 16:15:37 +02:00

1455 lines
57 KiB
TypeScript

import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { randomUUID } from "node:crypto";
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox";
import { buildWorkflowTriggerOutboxEvent, type QueueJsonValue } from "@hivegrid/queue";
import { logActivity } from "@hivegrid/activity";
import { publishLeadSearchUpsert, publishProjectSearchUpsert } from "~/services/search/searchIndexProjection.server";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
export class ProjectServiceError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "ProjectServiceError";
this.messageKey = messageKey;
}
}
export type PipelineTypHint = "sales" | "auftrag" | "custom";
export type PipelineStageKind = "offen" | "gewonnen" | "verloren";
export type PipelinePrincipalType = "role" | "group" | "user";
export type ProjektPrioritaet = "niedrig" | "normal" | "hoch" | "dringend";
export type ProjektKontaktRolle = "auftraggeber" | "bauherr" | "rechnungsempfaenger" | "ansprechpartner";
export type ProjectVisibilityContext = {
userId: string;
permissions: readonly string[];
};
export function canReadAllProjects(ctx: ProjectVisibilityContext): boolean {
return ctx.permissions.includes("projects.read_all");
}
export function canReadProjectLead(ctx: ProjectVisibilityContext): boolean {
return ctx.permissions.includes("projects.read_own_pl") || canReadAllProjects(ctx);
}
export function ownerScopeWhere(ctx: ProjectVisibilityContext): { OR?: Array<Record<string, string>> } {
if (canReadAllProjects(ctx)) return {};
if (canReadProjectLead(ctx)) {
return { OR: [{ zustaendigePersonId: ctx.userId }, { projektleiterId: ctx.userId }] };
}
return { OR: [{ zustaendigePersonId: ctx.userId }] };
}
export type ProjektRow = {
id: string;
tenantId: string;
createdByUserId: string | null;
projektNummer: string;
projektname: string;
beschreibung: string | null;
projekttyp: string | null;
quelle: string | null;
prioritaet: ProjektPrioritaet;
pipelineId: string;
stageId: string;
potenziellerAuftragswert: unknown;
auftragsvolumen: unknown;
startDatum: Date | null;
deadline: Date | null;
zustaendigePersonId: string | null;
projektleiterId: string | null;
projektAdresseId: string | null;
rechnungsAdresseId: string | null;
bauherrAdresseId: string | null;
konvertiertAusLeadId: string | null;
lostReasonId: string | null;
wonAt: Date | null;
lostAt: Date | null;
undoStageChangeUntil: Date | null;
isDemoData: boolean;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
export type PipelineRow = {
id: string;
tenantId: string;
name: string;
typHint: PipelineTypHint;
position: number;
isDefault: boolean;
isDemoData: boolean;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export type PipelineStageRow = {
id: string;
tenantId: string;
pipelineId: string;
name: string;
stageKind: PipelineStageKind;
winProbability: number;
color: string;
position: number;
isDemoData: boolean;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export type LostReasonRow = {
id: string;
tenantId: string;
name: string;
position: number;
isDemoData: boolean;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export type ProjektStageChangeRow = {
id: string;
tenantId: string;
projektId: string;
fromStageId: string;
toStageId: string;
lostReasonId: string | null;
changedByUserId: string | null;
undoUntil: Date | null;
undoneAt: Date | null;
createdAt: Date;
};
export type PipelineAggregateSnapshotRow = {
id: string;
tenantId: string;
pipelineId: string;
stageId: string | null;
scopeKey: string;
projectCount: number;
potentialValue: unknown;
orderVolume: unknown;
weightedForecast: unknown;
realizedRevenue: unknown;
lostCount: number;
stageConversionRate: unknown;
lostReasonDistribution: unknown;
isDemoDataIncluded: boolean;
calculatedAt: Date;
};
export type ProjectBoardCard = {
id: string;
projektNummer: string;
projektname: string;
prioritaet: ProjektPrioritaet;
value: string | null;
valueVisible: boolean;
ownerUserId: string | null;
projektleiterId: string | null;
deadline: string | null;
undoStageChangeUntil: string | null;
isStuck: boolean;
};
export type ProjectBoardColumn = {
stage: PipelineStageRow;
projectCount: number;
aggregateValue: string | null;
aggregateVisible: boolean;
cards: ProjectBoardCard[];
};
export type ProjectBoardVM = {
pipeline: PipelineRow;
pipelines: PipelineRow[];
columns: ProjectBoardColumn[];
generatedAt: string;
};
export type ProjectListFilters = {
pipelineId?: string | null;
stageId?: string | null;
ownerUserId?: string | null;
q?: string | null;
onlyMine?: boolean;
includeDemoData?: boolean;
};
export type ProjectListSort = "updated_desc" | "updated_asc" | "name_asc" | "name_desc" | "number_asc" | "value_desc";
export type ProjectListRow = {
id: string;
projektNummer: string;
projektname: string;
pipelineName: string;
stageName: string;
value: string | null;
valueVisible: boolean;
ownerUserId: string | null;
projektleiterId: string | null;
updatedAt: string;
};
export type ProjectListResult = {
rows: ProjectListRow[];
total: number;
page: number;
pageSize: number;
};
export type PipelineForecastVM = {
pipelineId: string;
aggregateVisible: boolean;
projectCount: number;
potentialValue: string | null;
orderVolume: string | null;
weightedForecast: string | null;
realizedRevenue: string | null;
lostCount: number;
stageConversionRates: Array<{ stageId: string; stageName: string; rate: number | null }>;
lostReasonDistribution: Record<string, number>;
calculatedAt: string;
};
export type CreatePipelineStageInput = {
name: string;
stageKind: PipelineStageKind;
winProbability?: number;
color?: string;
position?: number;
};
export type PipelineAccessInput = {
principalType: PipelinePrincipalType;
principalId: string;
kannSehen?: boolean;
kannBearbeiten?: boolean;
};
export type PipelineFieldConfigInput = {
id?: string;
stageId?: string | null;
fieldKey: string;
sichtbar?: boolean;
pflicht?: boolean;
reihenfolge?: number;
};
export type PipelineFieldVisibilityInput = {
fieldConfigId: string;
principalType: PipelinePrincipalType;
principalId: string;
kannSehen?: boolean;
kannBearbeiten?: boolean;
};
const DEFAULT_SALES_STAGES: readonly CreatePipelineStageInput[] = Object.freeze([
{ name: "Neu", stageKind: "offen", winProbability: 10, color: "#64748b", position: 10 },
{ name: "Qualifiziert", stageKind: "offen", winProbability: 30, color: "#0ea5e9", position: 20 },
{ name: "Angebot", stageKind: "offen", winProbability: 60, color: "#f59e0b", position: 30 },
{ name: "Gewonnen", stageKind: "gewonnen", winProbability: 100, color: "#16a34a", position: 40 },
{ name: "Verloren", stageKind: "verloren", winProbability: 0, color: "#dc2626", position: 50 },
]);
const DEFAULT_LOST_REASONS = ["Preis", "Zeitpunkt", "Kein Bedarf", "Wettbewerber", "Nicht erreichbar"] as const;
function normalizeText(value?: string | null): string | null {
const normalized = value?.trim();
return normalized || null;
}
function hasPermission(ctx: ProjectVisibilityContext | undefined, permission: string): boolean {
return ctx?.permissions.includes(permission) ?? false;
}
function isProjectOwner(project: Pick<ProjektRow, "zustaendigePersonId" | "projektleiterId">, ctx: ProjectVisibilityContext | undefined): boolean {
if (!ctx) return false;
return project.zustaendigePersonId === ctx.userId || project.projektleiterId === ctx.userId;
}
function decimalToNumber(value: unknown): number {
if (value == null) return 0;
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
if (typeof value === "object" && "toNumber" in value && typeof value.toNumber === "function") {
const parsed = value.toNumber();
return Number.isFinite(parsed) ? parsed : 0;
}
if (typeof value === "object" && "toString" in value && typeof value.toString === "function") {
const parsed = Number(value.toString());
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function decimalToString(value: unknown): string {
return decimalToNumber(value).toFixed(2);
}
function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}
function projectListWhere(input: {
tenantId: string;
ctx?: ProjectVisibilityContext;
filters?: ProjectListFilters;
}): Record<string, unknown> {
const filters = input.filters;
const where: Record<string, unknown> = {
tenantId: input.tenantId,
archivedAt: null,
...(filters?.includeDemoData ? {} : { isDemoData: false }),
...(input.ctx ? ownerScopeWhere(input.ctx) : {}),
};
if (filters?.pipelineId) where.pipelineId = filters.pipelineId;
if (filters?.stageId) where.stageId = filters.stageId;
if (filters?.ownerUserId) where.zustaendigePersonId = filters.ownerUserId;
if (filters?.onlyMine && input.ctx) {
where.OR = [{ zustaendigePersonId: input.ctx.userId }, { projektleiterId: input.ctx.userId }];
}
const q = normalizeText(filters?.q);
if (q) {
where.AND = [
{
OR: [
{ projektname: { contains: q, mode: "insensitive" } },
{ projektNummer: { contains: q, mode: "insensitive" } },
],
},
];
}
return where;
}
function projectListOrderBy(sort: ProjectListSort | undefined): Record<string, "asc" | "desc"> {
switch (sort) {
case "updated_asc":
return { updatedAt: "asc" };
case "name_asc":
return { projektname: "asc" };
case "name_desc":
return { projektname: "desc" };
case "number_asc":
return { projektNummer: "asc" };
case "value_desc":
return { potenziellerAuftragswert: "desc" };
case "updated_desc":
default:
return { updatedAt: "desc" };
}
}
function csvCell(value: string | number | null | undefined): string {
const text = value == null ? "" : String(value);
return `"${text.replaceAll('"', '""')}"`;
}
export function validatePipelineStages(stages: readonly Pick<CreatePipelineStageInput, "stageKind" | "name">[]): void {
const active = stages.filter((stage) => normalizeText(stage.name));
if (!active.some((stage) => stage.stageKind === "gewonnen")) {
throw new ProjectServiceError("projects.error.pipelineNeedsWonStage");
}
if (!active.some((stage) => stage.stageKind === "verloren")) {
throw new ProjectServiceError("projects.error.pipelineNeedsLostStage");
}
}
function normalizePipelineStages(stages: readonly CreatePipelineStageInput[]): CreatePipelineStageInput[] {
const normalized = stages
.map((stage, index) => ({
name: normalizeText(stage.name) ?? "",
stageKind: stage.stageKind,
winProbability: stage.winProbability ?? (stage.stageKind === "gewonnen" ? 100 : stage.stageKind === "verloren" ? 0 : 0),
color: normalizeText(stage.color) ?? "#6b7280",
position: stage.position ?? (index + 1) * 10,
}))
.filter((stage) => stage.name);
validatePipelineStages(normalized);
return normalized;
}
export async function createPipeline(input: {
tenantId: string;
userId: string | null;
name: string;
typHint?: PipelineTypHint;
position?: number;
isDefault?: boolean;
isDemoData?: boolean;
stages: readonly CreatePipelineStageInput[];
}): Promise<PipelineRow & { stages: PipelineStageRow[] }> {
return withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new ProjectServiceError("projects.error.pipelineNameRequired");
const stages = normalizePipelineStages(input.stages);
const pipeline = await tx.pipeline.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
name,
typHint: input.typHint ?? "custom",
position: input.position ?? 0,
isDefault: input.isDefault ?? false,
isDemoData: input.isDemoData ?? false,
stages: {
create: stages.map((stage) => ({
tenantId: input.tenantId,
name: stage.name,
stageKind: stage.stageKind,
winProbability: stage.winProbability ?? 0,
color: stage.color ?? "#6b7280",
position: stage.position ?? 0,
isDemoData: input.isDemoData ?? false,
})),
},
},
include: { stages: { where: { archivedAt: null }, orderBy: { position: "asc" } } },
});
return pipeline as PipelineRow & { stages: PipelineStageRow[] };
});
}
export async function updatePipeline(input: { tenantId: string; pipelineId: string; name?: string | null; position?: number | null; typHint?: PipelineTypHint | null }): Promise<PipelineRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.pipeline.findFirst({ where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
const data: Record<string, string | number> = {};
const name = normalizeText(input.name);
if (input.name !== undefined) {
if (!name) throw new ProjectServiceError("projects.error.pipelineNameRequired");
data.name = name;
}
if (input.position !== undefined && input.position !== null) data.position = input.position;
if (input.typHint) data.typHint = input.typHint;
const row = await tx.pipeline.update({
where: { id: input.pipelineId },
data,
});
return row as PipelineRow;
});
}
export async function archivePipeline(input: { tenantId: string; pipelineId: string }): Promise<PipelineRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.pipeline.findFirst({ where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
const projects = await tx.projekt.count({ where: { tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null } });
if (projects > 0) throw new ProjectServiceError("projects.error.pipelineStillHasProjects");
const now = new Date();
const row = await tx.pipeline.update({
where: { id: input.pipelineId, tenantId: input.tenantId },
data: { archivedAt: now, stages: { updateMany: { where: { archivedAt: null }, data: { archivedAt: now } } } },
});
return row as PipelineRow;
});
}
export async function duplicatePipeline(input: { tenantId: string; userId: string | null; pipelineId: string; name: string }): Promise<PipelineRow & { stages: PipelineStageRow[] }> {
return withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new ProjectServiceError("projects.error.pipelineNameRequired");
const source = await tx.pipeline.findFirst({
where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null },
include: {
stages: { where: { archivedAt: null }, orderBy: { position: "asc" } },
accessRules: true,
fieldConfigs: { include: { visibilities: true } },
},
});
if (!source) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
validatePipelineStages(source.stages);
const copy = await tx.pipeline.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
name,
typHint: source.typHint,
position: source.position + 1,
isDefault: false,
isDemoData: source.isDemoData,
stages: {
create: source.stages.map((stage) => ({
tenantId: input.tenantId,
name: stage.name,
stageKind: stage.stageKind,
winProbability: stage.winProbability,
color: stage.color,
position: stage.position,
isDemoData: stage.isDemoData,
})),
},
accessRules: {
create: source.accessRules.map((rule) => ({
tenantId: input.tenantId,
principalType: rule.principalType,
principalId: rule.principalId,
kannSehen: rule.kannSehen,
kannBearbeiten: rule.kannBearbeiten,
})),
},
},
include: { stages: { where: { archivedAt: null }, orderBy: { position: "asc" } } },
});
const stageIdByName = new Map(copy.stages.map((stage) => [stage.name, stage.id]));
const sourceStageNameById = new Map(source.stages.map((stage) => [stage.id, stage.name]));
for (const config of source.fieldConfigs) {
const sourceStageName = config.stageId ? sourceStageNameById.get(config.stageId) : null;
const copyStageId = sourceStageName ? stageIdByName.get(sourceStageName) : null;
const createdConfig = await tx.pipelineFieldConfig.create({
data: {
tenantId: input.tenantId,
pipelineId: copy.id,
stageId: copyStageId ?? null,
fieldKey: config.fieldKey,
sichtbar: config.sichtbar,
pflicht: config.pflicht,
reihenfolge: config.reihenfolge,
},
});
if (config.visibilities.length > 0) {
await tx.pipelineFieldVisibility.createMany({
data: config.visibilities.map((visibility) => ({
tenantId: input.tenantId,
fieldConfigId: createdConfig.id,
principalType: visibility.principalType,
principalId: visibility.principalId,
kannSehen: visibility.kannSehen,
kannBearbeiten: visibility.kannBearbeiten,
})),
});
}
}
return copy as PipelineRow & { stages: PipelineStageRow[] };
});
}
export async function upsertPipelineAccess(input: { tenantId: string; pipelineId: string } & PipelineAccessInput): Promise<void> {
return withTenantRLS(input.tenantId, async (tx) => {
await tx.pipelineAccess.upsert({
where: { pipelineId_principalType_principalId: { pipelineId: input.pipelineId, principalType: input.principalType, principalId: input.principalId } },
update: { kannSehen: input.kannSehen ?? true, kannBearbeiten: input.kannBearbeiten ?? false },
create: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
principalType: input.principalType,
principalId: input.principalId,
kannSehen: input.kannSehen ?? true,
kannBearbeiten: input.kannBearbeiten ?? false,
},
});
});
}
export async function upsertPipelineFieldConfig(input: { tenantId: string; pipelineId: string } & PipelineFieldConfigInput): Promise<void> {
return withTenantRLS(input.tenantId, async (tx) => {
const fieldKey = normalizeText(input.fieldKey);
if (!fieldKey) throw new ProjectServiceError("projects.error.fieldKeyRequired");
const stageId = input.stageId ?? null;
const existing = await tx.pipelineFieldConfig.findFirst({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId, stageId, fieldKey },
select: { id: true },
});
const data = { sichtbar: input.sichtbar ?? true, pflicht: input.pflicht ?? false, reihenfolge: input.reihenfolge ?? 0 };
if (existing) {
await tx.pipelineFieldConfig.update({ where: { id: existing.id }, data });
return;
}
await tx.pipelineFieldConfig.create({
data: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
stageId,
fieldKey,
...data,
},
});
});
}
export async function upsertPipelineFieldVisibility(input: { tenantId: string } & PipelineFieldVisibilityInput): Promise<void> {
return withTenantRLS(input.tenantId, async (tx) => {
await tx.pipelineFieldVisibility.upsert({
where: {
fieldConfigId_principalType_principalId: {
fieldConfigId: input.fieldConfigId,
principalType: input.principalType,
principalId: input.principalId,
},
},
update: { kannSehen: input.kannSehen ?? true, kannBearbeiten: input.kannBearbeiten ?? false },
create: {
tenantId: input.tenantId,
fieldConfigId: input.fieldConfigId,
principalType: input.principalType,
principalId: input.principalId,
kannSehen: input.kannSehen ?? true,
kannBearbeiten: input.kannBearbeiten ?? false,
},
});
});
}
function projectNumber(prefix: string, nextNumber: number, padding: number): string {
return `${prefix}${String(nextNumber).padStart(padding, "0")}`;
}
async function reserveNumberWithTx(tx: TenantRlsClient, tenantId: string, entityType: "projekt" | "rechnung", prefix: string): Promise<string> {
const sequenceId = `seq_${randomUUID()}`;
await tx.$executeRaw`
INSERT INTO "HiveNumberSeq" ("id", "tenantId", "entityType", "prefix", "nextNumber", "padding", "createdAt", "updatedAt")
VALUES (${sequenceId}, ${tenantId}, ${entityType}, ${prefix}, 1, 5, now(), now())
ON CONFLICT ("tenantId", "entityType") DO NOTHING
`;
const rows = await tx.$queryRaw<Array<{ nextNumber: number; padding: number; prefix: string }>>`
SELECT "nextNumber", "padding", "prefix"
FROM "HiveNumberSeq"
WHERE "tenantId" = ${tenantId} AND "entityType" = ${entityType}
FOR UPDATE
`;
const current = rows[0];
if (!current) throw new ProjectServiceError("projects.error.numberSequenceMissing");
await tx.hiveNumberSeq.update({
where: { tenantId_entityType: { tenantId, entityType } },
data: { nextNumber: current.nextNumber + 1 },
});
return projectNumber(current.prefix || prefix, current.nextNumber, current.padding);
}
async function ensureDefaultLostReasonsWithTx(tx: TenantRlsClient, tenantId: string): Promise<void> {
for (const [index, name] of DEFAULT_LOST_REASONS.entries()) {
await tx.lostReason.upsert({
where: { tenantId_name: { tenantId, name } },
update: { archivedAt: null, position: (index + 1) * 10 },
create: { tenantId, name, position: (index + 1) * 10, isDemoData: true },
});
}
}
export async function ensureDefaultSalesPipelineWithTx(tx: TenantRlsClient, input: { tenantId: string; userId?: string | null }): Promise<{
pipeline: PipelineRow;
firstOpenStage: PipelineStageRow;
}> {
validatePipelineStages(DEFAULT_SALES_STAGES);
const existing = await tx.pipeline.findFirst({
where: { tenantId: input.tenantId, typHint: "sales", isDefault: true, archivedAt: null },
orderBy: { createdAt: "asc" },
});
const pipeline =
existing ??
(await tx.pipeline.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId ?? null,
name: "Sales",
typHint: "sales",
position: 10,
isDefault: true,
isDemoData: true,
},
}));
for (const stage of DEFAULT_SALES_STAGES) {
await tx.pipelineStage.upsert({
where: { pipelineId_name: { pipelineId: pipeline.id, name: stage.name } },
update: {
stageKind: stage.stageKind,
winProbability: stage.winProbability ?? 0,
color: stage.color ?? "#6b7280",
position: stage.position ?? 0,
archivedAt: null,
},
create: {
tenantId: input.tenantId,
pipelineId: pipeline.id,
name: stage.name,
stageKind: stage.stageKind,
winProbability: stage.winProbability ?? 0,
color: stage.color ?? "#6b7280",
position: stage.position ?? 0,
isDemoData: true,
},
});
}
await ensureDefaultLostReasonsWithTx(tx, input.tenantId);
const firstOpenStage = await tx.pipelineStage.findFirst({
where: { tenantId: input.tenantId, pipelineId: pipeline.id, stageKind: "offen", archivedAt: null },
orderBy: { position: "asc" },
});
if (!firstOpenStage) {
throw new ProjectServiceError("projects.error.pipelineNeedsOpenStage");
}
return { pipeline: pipeline as PipelineRow, firstOpenStage: firstOpenStage as PipelineStageRow };
}
export async function ensureDefaultSalesPipeline(input: { tenantId: string; userId?: string | null }): Promise<{
pipeline: PipelineRow;
firstOpenStage: PipelineStageRow;
}> {
return withTenantRLS(input.tenantId, (tx) => ensureDefaultSalesPipelineWithTx(tx, input));
}
export async function listProjects(input: { tenantId: string; ctx?: ProjectVisibilityContext; limit?: number; includeDemoData?: boolean }): Promise<ProjektRow[]> {
const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
return withTenantRLS(input.tenantId, async (tx) => {
const rows = await tx.projekt.findMany({
where: projectListWhere({ tenantId: input.tenantId, ctx: input.ctx, filters: { includeDemoData: input.includeDemoData } }),
orderBy: { updatedAt: "desc" },
take: limit,
});
return rows as ProjektRow[];
});
}
export async function getProject(input: { tenantId: string; projectId: string; ctx?: ProjectVisibilityContext }): Promise<ProjektRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const row = await tx.projekt.findFirst({
where: { id: input.projectId, tenantId: input.tenantId, archivedAt: null, ...(input.ctx ? ownerScopeWhere(input.ctx) : {}) },
});
if (!row) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
return row as ProjektRow;
});
}
export type CreateProjectInput = {
tenantId: string;
userId: string | null;
projektname: string;
beschreibung?: string | null;
projekttyp?: string | null;
quelle?: string | null;
prioritaet?: ProjektPrioritaet;
pipelineId?: string | null;
stageId?: string | null;
potenziellerAuftragswert?: string | number | null;
auftragsvolumen?: string | number | null;
zustaendigePersonId?: string | null;
projektleiterId?: string | null;
konvertiertAusLeadId?: string | null;
kontaktId?: string | null;
kontaktRolle?: ProjektKontaktRolle;
isDemoData?: boolean;
};
async function createProjectWithTx(tx: TenantRlsClient, input: CreateProjectInput): Promise<ProjektRow> {
const name = normalizeText(input.projektname);
if (!name) throw new ProjectServiceError("projects.error.nameRequired");
const defaultPipeline = input.pipelineId && input.stageId ? null : await ensureDefaultSalesPipelineWithTx(tx, { tenantId: input.tenantId, userId: input.userId });
const pipelineId = input.pipelineId ?? defaultPipeline?.pipeline.id;
const stageId = input.stageId ?? defaultPipeline?.firstOpenStage.id;
if (!pipelineId || !stageId) throw new ProjectServiceError("projects.error.pipelineRequired");
const nummer = await reserveNumberWithTx(tx, input.tenantId, "projekt", "P-");
const project = await tx.projekt.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
projektNummer: nummer,
projektname: name,
beschreibung: normalizeText(input.beschreibung),
projekttyp: normalizeText(input.projekttyp),
quelle: normalizeText(input.quelle),
prioritaet: input.prioritaet ?? "normal",
pipelineId,
stageId,
potenziellerAuftragswert: input.potenziellerAuftragswert == null ? undefined : input.potenziellerAuftragswert,
auftragsvolumen: input.auftragsvolumen == null ? undefined : input.auftragsvolumen,
zustaendigePersonId: normalizeText(input.zustaendigePersonId) ?? input.userId,
projektleiterId: normalizeText(input.projektleiterId),
konvertiertAusLeadId: normalizeText(input.konvertiertAusLeadId),
isDemoData: input.isDemoData ?? false,
},
});
if (input.kontaktId) {
await tx.projektKontakt.create({
data: {
tenantId: input.tenantId,
projektId: project.id,
kontaktId: input.kontaktId,
rolle: input.kontaktRolle ?? "ansprechpartner",
primaer: true,
},
});
}
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_stage_changed",
subjectType: "project",
subjectId: project.id,
summaryText: `Projekt ${project.projektNummer} wurde angelegt.`,
metadata: { pipelineId, stageId, source: input.konvertiertAusLeadId ? "lead.qualified" : "manual" },
},
{ client: tx },
);
await publishProjectSearchUpsert(tx, project as ProjektRow);
return project as ProjektRow;
}
export async function createProject(input: CreateProjectInput): Promise<ProjektRow> {
return withTenantRLS(input.tenantId, (tx) => createProjectWithTx(tx, input));
}
function leadProjectName(lead: { vorname: string | null; nachname: string | null; email: string | null; telefon: string | null }): string {
const name = `${lead.vorname ?? ""} ${lead.nachname ?? ""}`.trim();
return name ? `Projekt ${name}` : `Projekt ${lead.email ?? lead.telefon ?? "aus Lead"}`;
}
function workflowPayload(project: Pick<ProjektRow, "id" | "tenantId" | "pipelineId" | "stageId" | "konvertiertAusLeadId" | "zustaendigePersonId">): { [key: string]: QueueJsonValue } {
return {
projectId: project.id,
tenantId: project.tenantId,
pipelineId: project.pipelineId,
stageId: project.stageId,
konvertiertAusLeadId: project.konvertiertAusLeadId,
zustaendigePersonId: project.zustaendigePersonId,
};
}
export async function createProjectFromQualifiedLead(input: { tenantId: string; leadId: string; userId: string | null }): Promise<ProjektRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const lead = await tx.inboxLead.findFirst({ where: { id: input.leadId, tenantId: input.tenantId, archivedAt: null } });
if (!lead) throw new CatalogError(foundationErrors.notFound, { leadId: input.leadId });
if (lead.konvertiertProjektId) {
const existing = await tx.projekt.findFirst({ where: { id: lead.konvertiertProjektId, tenantId: input.tenantId, archivedAt: null } });
if (existing) return existing as ProjektRow;
}
if (lead.status !== "qualified" || lead.weiterverarbeitungFreigabe !== "ja") {
throw new ProjectServiceError("projects.error.leadNotQualified");
}
const matchedKontakt = lead.verknuepfterKontaktId
? { id: lead.verknuepfterKontaktId }
: lead.email
? await tx.kontakt.findFirst({ where: { tenantId: input.tenantId, archivedAt: null, primaerEmail: lead.email.toLowerCase() }, select: { id: true } })
: null;
const project = await createProjectWithTx(tx, {
tenantId: input.tenantId,
userId: input.userId,
projektname: leadProjectName(lead),
beschreibung: lead.message,
quelle: `lead:${lead.quelle}`,
zustaendigePersonId: lead.zustaendigerUserId ?? input.userId,
konvertiertAusLeadId: lead.id,
kontaktId: matchedKontakt?.id ?? null,
kontaktRolle: "ansprechpartner",
});
const updatedLead = await tx.inboxLead.update({
where: { id: lead.id },
data: {
status: "converted",
weiterverarbeitungFreigabe: "ja",
konvertiertProjektId: project.id,
verknuepfterKontaktId: matchedKontakt?.id ?? lead.verknuepfterKontaktId,
convertedAt: new Date(),
autoDeleteAt: null,
},
});
await publishLeadSearchUpsert(tx, updatedLead);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "lead.converted",
data: { leadId: lead.id, projectId: project.id, action: "auto_project_created" },
userId: input.userId,
sourceId: `lead:${lead.id}:project:${project.id}`,
}),
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "project.created",
data: workflowPayload(project),
userId: input.userId,
sourceId: `project:${project.id}:created`,
}),
);
return project;
});
}
export async function listPipelines(input: { tenantId: string }): Promise<Array<PipelineRow & { stages: PipelineStageRow[] }>> {
return withTenantRLS(input.tenantId, async (tx) => {
const rows = await tx.pipeline.findMany({
where: { tenantId: input.tenantId, archivedAt: null },
include: { stages: { where: { archivedAt: null }, orderBy: { position: "asc" } } },
orderBy: { position: "asc" },
});
return rows as Array<PipelineRow & { stages: PipelineStageRow[] }>;
});
}
export async function listLostReasons(input: { tenantId: string }): Promise<LostReasonRow[]> {
return withTenantRLS(input.tenantId, async (tx) => {
await ensureDefaultLostReasonsWithTx(tx, input.tenantId);
return (await tx.lostReason.findMany({ where: { tenantId: input.tenantId, archivedAt: null }, orderBy: { position: "asc" } })) as LostReasonRow[];
});
}
export async function upsertLostReason(input: { tenantId: string; reasonId?: string | null; name: string; position?: number | null; isDemoData?: boolean }): Promise<LostReasonRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new ProjectServiceError("projects.error.lostReasonNameRequired");
if (input.reasonId) {
const existing = await tx.lostReason.findFirst({ where: { id: input.reasonId, tenantId: input.tenantId }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { reasonId: input.reasonId });
const row = await tx.lostReason.update({
where: { id: input.reasonId },
data: { name, position: input.position ?? 0, archivedAt: null },
});
return row as LostReasonRow;
}
const row = await tx.lostReason.upsert({
where: { tenantId_name: { tenantId: input.tenantId, name } },
update: { position: input.position ?? 0, archivedAt: null },
create: {
tenantId: input.tenantId,
name,
position: input.position ?? 0,
isDemoData: input.isDemoData ?? false,
},
});
return row as LostReasonRow;
});
}
export async function archiveLostReason(input: { tenantId: string; reasonId: string }): Promise<LostReasonRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.lostReason.findFirst({ where: { id: input.reasonId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { reasonId: input.reasonId });
const row = await tx.lostReason.update({
where: { id: input.reasonId },
data: { archivedAt: new Date() },
});
return row as LostReasonRow;
});
}
async function assertPipelineReadableWithTx(tx: TenantRlsClient, input: { tenantId: string; pipelineId: string; ctx?: ProjectVisibilityContext }): Promise<PipelineRow> {
const pipeline = await tx.pipeline.findFirst({ where: { id: input.pipelineId, tenantId: input.tenantId, archivedAt: null } });
if (!pipeline) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
if (input.ctx) {
const access = await tx.pipelineAccess.findFirst({
where: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
principalType: "user",
principalId: input.ctx.userId,
},
});
if (access && !access.kannSehen) throw new CatalogError(foundationErrors.notFound, { pipelineId: input.pipelineId });
}
return pipeline as PipelineRow;
}
async function canViewFieldWithTx(
tx: TenantRlsClient,
input: {
tenantId: string;
pipelineId: string;
stageId?: string | null;
fieldKey: string;
ctx?: ProjectVisibilityContext;
backstopPermissions: readonly string[];
},
): Promise<boolean> {
if (!input.ctx) return false;
const config = await tx.pipelineFieldConfig.findFirst({
where: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
fieldKey: input.fieldKey,
OR: [{ stageId: input.stageId ?? null }, { stageId: null }],
},
include: {
visibilities: {
where: { tenantId: input.tenantId, principalType: "user", principalId: input.ctx.userId },
take: 1,
},
},
orderBy: [{ stageId: "desc" }, { reihenfolge: "asc" }],
});
const explicit = config?.visibilities[0];
if (explicit) return explicit.kannSehen;
if (config && !config.sichtbar) return false;
return input.backstopPermissions.some((permission) => hasPermission(input.ctx, permission));
}
async function canViewProjectVolumeWithTx(tx: TenantRlsClient, project: ProjektRow, ctx: ProjectVisibilityContext | undefined): Promise<boolean> {
if (!ctx) return false;
const backstops = hasPermission(ctx, "deals.read_volume_all") || (hasPermission(ctx, "deals.read_volume_own") && isProjectOwner(project, ctx));
if (!backstops) {
return canViewFieldWithTx(tx, {
tenantId: project.tenantId,
pipelineId: project.pipelineId,
stageId: project.stageId,
fieldKey: "auftragsvolumen",
ctx,
backstopPermissions: [],
});
}
return canViewFieldWithTx(tx, {
tenantId: project.tenantId,
pipelineId: project.pipelineId,
stageId: project.stageId,
fieldKey: "auftragsvolumen",
ctx,
backstopPermissions: ["deals.read_volume_all", "deals.read_volume_own"],
});
}
async function canViewAggregatesWithTx(tx: TenantRlsClient, input: { tenantId: string; pipelineId: string; ctx?: ProjectVisibilityContext }): Promise<boolean> {
return canViewFieldWithTx(tx, {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
fieldKey: "aggregate:forecast",
ctx: input.ctx,
backstopPermissions: ["deals.read_aggregates"],
});
}
function toBoardCard(project: ProjektRow, valueVisible: boolean, now: Date): ProjectBoardCard {
const valueSource = project.auftragsvolumen ?? project.potenziellerAuftragswert;
return {
id: project.id,
projektNummer: project.projektNummer,
projektname: project.projektname,
prioritaet: project.prioritaet,
value: valueVisible ? decimalToString(valueSource) : null,
valueVisible,
ownerUserId: project.zustaendigePersonId,
projektleiterId: project.projektleiterId,
deadline: project.deadline?.toISOString() ?? null,
undoStageChangeUntil: project.undoStageChangeUntil && project.undoStageChangeUntil > now ? project.undoStageChangeUntil.toISOString() : null,
isStuck: Boolean(project.deadline && project.deadline < now),
};
}
export async function loadProjectBoard(input: {
tenantId: string;
ctx: ProjectVisibilityContext;
pipelineId?: string | null;
onlyMine?: boolean;
includeDemoData?: boolean;
cardsPerStage?: number;
}): Promise<ProjectBoardVM> {
const cardsPerStage = Math.min(Math.max(input.cardsPerStage ?? 50, 1), 100);
return withTenantRLS(input.tenantId, async (tx) => {
const fallback = input.pipelineId ? null : await ensureDefaultSalesPipelineWithTx(tx, { tenantId: input.tenantId, userId: input.ctx.userId });
const pipelineId = input.pipelineId ?? fallback?.pipeline.id;
if (!pipelineId) throw new ProjectServiceError("projects.error.pipelineRequired");
const pipeline = await assertPipelineReadableWithTx(tx, { tenantId: input.tenantId, pipelineId, ctx: input.ctx });
const [pipelines, stages, aggregateVisible] = await Promise.all([
tx.pipeline.findMany({ where: { tenantId: input.tenantId, archivedAt: null }, orderBy: { position: "asc" } }),
tx.pipelineStage.findMany({ where: { tenantId: input.tenantId, pipelineId, archivedAt: null }, orderBy: { position: "asc" } }),
canViewAggregatesWithTx(tx, { tenantId: input.tenantId, pipelineId, ctx: input.ctx }),
]);
const now = new Date();
const columns: ProjectBoardColumn[] = [];
for (const stage of stages) {
const where = projectListWhere({
tenantId: input.tenantId,
ctx: input.ctx,
filters: { pipelineId, stageId: stage.id, onlyMine: input.onlyMine, includeDemoData: input.includeDemoData },
});
const [projects, projectCount] = await Promise.all([
tx.projekt.findMany({ where, orderBy: { updatedAt: "desc" }, take: cardsPerStage }),
tx.projekt.count({ where }),
]);
let aggregateValue = 0;
const cards: ProjectBoardCard[] = [];
for (const project of projects as ProjektRow[]) {
const canViewVolume = await canViewProjectVolumeWithTx(tx, project, input.ctx);
aggregateValue += decimalToNumber(project.auftragsvolumen ?? project.potenziellerAuftragswert);
cards.push(toBoardCard(project, canViewVolume, now));
}
columns.push({
stage: stage as PipelineStageRow,
projectCount,
aggregateValue: aggregateVisible ? aggregateValue.toFixed(2) : null,
aggregateVisible,
cards,
});
}
return {
pipeline: pipeline as PipelineRow,
pipelines: pipelines as PipelineRow[],
columns,
generatedAt: now.toISOString(),
};
});
}
export async function listProjectsForTable(input: {
tenantId: string;
ctx: ProjectVisibilityContext;
filters?: ProjectListFilters;
sort?: ProjectListSort;
page?: number;
pageSize?: number;
}): Promise<ProjectListResult> {
const page = Math.max(input.page ?? 1, 1);
const pageSize = Math.min(Math.max(input.pageSize ?? 50, 1), 200);
return withTenantRLS(input.tenantId, async (tx) => {
const where = projectListWhere({ tenantId: input.tenantId, ctx: input.ctx, filters: input.filters });
const [projects, total] = await Promise.all([
tx.projekt.findMany({
where,
include: { pipeline: true, stage: true },
orderBy: projectListOrderBy(input.sort),
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.projekt.count({ where }),
]);
const rows: ProjectListRow[] = [];
for (const project of projects as Array<ProjektRow & { pipeline: PipelineRow; stage: PipelineStageRow }>) {
const valueVisible = await canViewProjectVolumeWithTx(tx, project, input.ctx);
rows.push({
id: project.id,
projektNummer: project.projektNummer,
projektname: project.projektname,
pipelineName: project.pipeline.name,
stageName: project.stage.name,
value: valueVisible ? decimalToString(project.auftragsvolumen ?? project.potenziellerAuftragswert) : null,
valueVisible,
ownerUserId: project.zustaendigePersonId,
projektleiterId: project.projektleiterId,
updatedAt: project.updatedAt.toISOString(),
});
}
return { rows, total, page, pageSize };
});
}
export async function exportProjectListCsv(input: {
tenantId: string;
ctx: ProjectVisibilityContext;
filters?: ProjectListFilters;
sort?: ProjectListSort;
}): Promise<string> {
const result = await listProjectsForTable({ ...input, page: 1, pageSize: 500 });
const header = ["Nummer", "Name", "Pipeline", "Status", "Volumen", "Zuständig", "Aktualisiert"];
const rows = result.rows.map((row) =>
[
row.projektNummer,
row.projektname,
row.pipelineName,
row.stageName,
row.valueVisible ? row.value : "—",
row.ownerUserId,
row.updatedAt,
].map(csvCell).join(","),
);
return `${header.map(csvCell).join(",")}\n${rows.join("\n")}\n`;
}
export async function rebuildPipelineAggregateCache(input: { tenantId: string; pipelineId: string; includeDemoData?: boolean }): Promise<PipelineAggregateSnapshotRow[]> {
return withTenantRLS(input.tenantId, async (tx) => {
const pipeline = await assertPipelineReadableWithTx(tx, { tenantId: input.tenantId, pipelineId: input.pipelineId });
const [stages, projects, lostReasons] = await Promise.all([
tx.pipelineStage.findMany({ where: { tenantId: input.tenantId, pipelineId: pipeline.id, archivedAt: null }, orderBy: { position: "asc" } }),
tx.projekt.findMany({
where: {
tenantId: input.tenantId,
pipelineId: pipeline.id,
archivedAt: null,
...(input.includeDemoData ? {} : { isDemoData: false }),
},
}),
tx.lostReason.findMany({ where: { tenantId: input.tenantId }, select: { id: true, name: true } }),
]);
const now = new Date();
const lostReasonNameById = new Map(lostReasons.map((reason) => [reason.id, reason.name]));
const snapshots: PipelineAggregateSnapshotRow[] = [];
let totalProjectCount = 0;
let totalPotential = 0;
let totalOrder = 0;
let totalWeighted = 0;
let totalRealized = 0;
let totalLost = 0;
const totalLostReasons: Record<string, number> = {};
for (const stage of stages as PipelineStageRow[]) {
const stageProjects = (projects as ProjektRow[]).filter((project) => project.stageId === stage.id);
const projectCount = stageProjects.length;
const potentialValue = stageProjects.reduce((sum, project) => sum + decimalToNumber(project.potenziellerAuftragswert), 0);
const orderVolume = stageProjects.reduce((sum, project) => sum + decimalToNumber(project.auftragsvolumen), 0);
const weightedForecast = stageProjects.reduce((sum, project) => sum + decimalToNumber(project.potenziellerAuftragswert ?? project.auftragsvolumen) * (stage.winProbability / 100), 0);
const realizedRevenue = stage.stageKind === "gewonnen" ? stageProjects.reduce((sum, project) => sum + decimalToNumber(project.auftragsvolumen ?? project.potenziellerAuftragswert), 0) : 0;
const lostCount = stage.stageKind === "verloren" ? projectCount : 0;
const lostReasonDistribution: Record<string, number> = {};
for (const project of stageProjects) {
if (!project.lostReasonId) continue;
const name = lostReasonNameById.get(project.lostReasonId) ?? project.lostReasonId;
lostReasonDistribution[name] = (lostReasonDistribution[name] ?? 0) + 1;
totalLostReasons[name] = (totalLostReasons[name] ?? 0) + 1;
}
totalProjectCount += projectCount;
totalPotential += potentialValue;
totalOrder += orderVolume;
totalWeighted += weightedForecast;
totalRealized += realizedRevenue;
totalLost += lostCount;
const stageMoves = await tx.projektStageChange.count({ where: { tenantId: input.tenantId, toStageId: stage.id, undoneAt: null } });
const stageConversionRate = totalProjectCount > 0 ? stageMoves / totalProjectCount : null;
const snapshot = await tx.pipelineAggregateSnapshot.upsert({
where: { tenantId_pipelineId_scopeKey: { tenantId: input.tenantId, pipelineId: pipeline.id, scopeKey: `stage:${stage.id}` } },
update: {
stageId: stage.id,
projectCount,
potentialValue,
orderVolume,
weightedForecast,
realizedRevenue,
lostCount,
stageConversionRate,
lostReasonDistribution,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
},
create: {
tenantId: input.tenantId,
pipelineId: pipeline.id,
stageId: stage.id,
scopeKey: `stage:${stage.id}`,
projectCount,
potentialValue,
orderVolume,
weightedForecast,
realizedRevenue,
lostCount,
stageConversionRate,
lostReasonDistribution,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
},
});
snapshots.push(snapshot as PipelineAggregateSnapshotRow);
}
const pipelineSnapshot = await tx.pipelineAggregateSnapshot.upsert({
where: { tenantId_pipelineId_scopeKey: { tenantId: input.tenantId, pipelineId: pipeline.id, scopeKey: "pipeline" } },
update: {
stageId: null,
projectCount: totalProjectCount,
potentialValue: totalPotential,
orderVolume: totalOrder,
weightedForecast: totalWeighted,
realizedRevenue: totalRealized,
lostCount: totalLost,
stageConversionRate: null,
lostReasonDistribution: totalLostReasons,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
},
create: {
tenantId: input.tenantId,
pipelineId: pipeline.id,
stageId: null,
scopeKey: "pipeline",
projectCount: totalProjectCount,
potentialValue: totalPotential,
orderVolume: totalOrder,
weightedForecast: totalWeighted,
realizedRevenue: totalRealized,
lostCount: totalLost,
stageConversionRate: null,
lostReasonDistribution: totalLostReasons,
isDemoDataIncluded: input.includeDemoData ?? false,
calculatedAt: now,
},
});
return [pipelineSnapshot as PipelineAggregateSnapshotRow, ...snapshots];
});
}
export async function getPipelineForecast(input: { tenantId: string; pipelineId: string; ctx: ProjectVisibilityContext }): Promise<PipelineForecastVM> {
const snapshots = await rebuildPipelineAggregateCache({ tenantId: input.tenantId, pipelineId: input.pipelineId });
return withTenantRLS(input.tenantId, async (tx) => {
const aggregateVisible = await canViewAggregatesWithTx(tx, { tenantId: input.tenantId, pipelineId: input.pipelineId, ctx: input.ctx });
const stages = await tx.pipelineStage.findMany({ where: { tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null }, orderBy: { position: "asc" } });
const pipelineSnapshot = snapshots.find((snapshot) => snapshot.scopeKey === "pipeline");
if (!pipelineSnapshot) throw new ProjectServiceError("projects.error.aggregateMissing");
const stageConversionRates = stages.map((stage) => {
const snapshot = snapshots.find((candidate) => candidate.scopeKey === `stage:${stage.id}`);
return {
stageId: stage.id,
stageName: stage.name,
rate: snapshot?.stageConversionRate == null ? null : decimalToNumber(snapshot.stageConversionRate),
};
});
return {
pipelineId: input.pipelineId,
aggregateVisible,
projectCount: pipelineSnapshot.projectCount,
potentialValue: aggregateVisible ? decimalToString(pipelineSnapshot.potentialValue) : null,
orderVolume: aggregateVisible ? decimalToString(pipelineSnapshot.orderVolume) : null,
weightedForecast: aggregateVisible ? decimalToString(pipelineSnapshot.weightedForecast) : null,
realizedRevenue: aggregateVisible ? decimalToString(pipelineSnapshot.realizedRevenue) : null,
lostCount: pipelineSnapshot.lostCount,
stageConversionRates,
lostReasonDistribution: (pipelineSnapshot.lostReasonDistribution ?? {}) as Record<string, number>,
calculatedAt: pipelineSnapshot.calculatedAt.toISOString(),
};
});
}
export async function moveProjectStage(input: {
tenantId: string;
projectId: string;
targetStageId: string;
userId: string | null;
lostReasonId?: string | null;
undoWindowHours?: number;
}): Promise<ProjektRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const now = new Date();
const project = await tx.projekt.findFirst({ where: { id: input.projectId, tenantId: input.tenantId, archivedAt: null }, include: { stage: true } });
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
const targetStage = await tx.pipelineStage.findFirst({
where: { id: input.targetStageId, tenantId: input.tenantId, pipelineId: project.pipelineId, archivedAt: null },
});
if (!targetStage) throw new CatalogError(foundationErrors.notFound, { stageId: input.targetStageId });
const auftragStageConfig = await tx.auftragPipelineStageConfig.findFirst({ where: { tenantId: input.tenantId, stageId: targetStage.id } });
if (auftragStageConfig?.requiresAcceptanceProtocol) {
const acceptanceProtocol = await tx.abnahmeProtokoll.findFirst({
where: { tenantId: input.tenantId, projektId: project.id, gesperrtAt: { not: null }, archivedAt: null },
select: { id: true },
});
if (!acceptanceProtocol) throw new ProjectServiceError("projects.error.acceptanceProtocolRequired");
}
if (targetStage.stageKind === "verloren") {
const lostReasonId = normalizeText(input.lostReasonId);
if (!lostReasonId) throw new ProjectServiceError("projects.error.lostReasonRequired");
const lostReason = await tx.lostReason.findFirst({ where: { id: lostReasonId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
if (!lostReason) throw new CatalogError(foundationErrors.notFound, { lostReasonId });
}
const undoUntil = targetStage.stageKind === "gewonnen" || targetStage.stageKind === "verloren" ? addHours(now, input.undoWindowHours ?? 24) : null;
const updated = await tx.projekt.update({
where: { id: project.id },
data: {
stageId: targetStage.id,
wonAt: targetStage.stageKind === "gewonnen" ? now : targetStage.stageKind === "offen" ? null : project.wonAt,
lostAt: targetStage.stageKind === "verloren" ? now : targetStage.stageKind === "offen" ? null : project.lostAt,
lostReasonId: targetStage.stageKind === "verloren" ? input.lostReasonId : null,
undoStageChangeUntil: undoUntil,
},
});
await tx.projektStageChange.create({
data: {
tenantId: input.tenantId,
projektId: project.id,
fromStageId: project.stageId,
toStageId: targetStage.id,
lostReasonId: targetStage.stageKind === "verloren" ? input.lostReasonId : null,
changedByUserId: input.userId,
undoUntil,
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_stage_changed",
subjectType: "project",
subjectId: project.id,
summaryText: `Projekt ${project.projektNummer} wurde nach ${targetStage.name} verschoben.`,
metadata: { fromStageId: project.stageId, toStageId: targetStage.id, stageKind: targetStage.stageKind },
},
{ client: tx },
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "project.stage_changed",
data: { ...workflowPayload(updated as ProjektRow), fromStageId: project.stageId, toStageId: targetStage.id, stageKind: targetStage.stageKind },
userId: input.userId,
sourceId: `project:${project.id}:stage:${targetStage.id}:${now.getTime()}`,
}),
);
if (targetStage.stageKind === "gewonnen") {
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "deal.won",
data: { ...workflowPayload(updated as ProjektRow), wonAt: now.toISOString(), orderVolume: decimalToString(updated.auftragsvolumen ?? updated.potenziellerAuftragswert) },
userId: input.userId,
sourceId: `project:${project.id}:won:${now.getTime()}`,
}),
);
}
await publishProjectSearchUpsert(tx, updated as ProjektRow);
return updated as ProjektRow;
});
}
export async function undoLastProjectStageChange(input: { tenantId: string; projectId: string; userId: string | null }): Promise<ProjektRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const now = new Date();
const project = await tx.projekt.findFirst({ where: { id: input.projectId, tenantId: input.tenantId, archivedAt: null } });
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
const change = await tx.projektStageChange.findFirst({
where: {
tenantId: input.tenantId,
projektId: project.id,
toStageId: project.stageId,
undoneAt: null,
undoUntil: { gte: now },
},
orderBy: { createdAt: "desc" },
});
if (!change) throw new ProjectServiceError("projects.error.undoUnavailable");
const previousStage = await tx.pipelineStage.findFirst({ where: { id: change.fromStageId, tenantId: input.tenantId, pipelineId: project.pipelineId, archivedAt: null } });
if (!previousStage) throw new CatalogError(foundationErrors.notFound, { stageId: change.fromStageId });
const updated = await tx.projekt.update({
where: { id: project.id },
data: {
stageId: previousStage.id,
wonAt: previousStage.stageKind === "gewonnen" ? project.wonAt : null,
lostAt: previousStage.stageKind === "verloren" ? project.lostAt : null,
lostReasonId: previousStage.stageKind === "verloren" ? project.lostReasonId : null,
undoStageChangeUntil: null,
},
});
await tx.projektStageChange.update({ where: { id: change.id }, data: { undoneAt: now } });
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: input.userId ? "user" : "system",
activityType: "project_stage_changed",
subjectType: "project",
subjectId: project.id,
summaryText: `Projekt ${project.projektNummer} wurde auf ${previousStage.name} zurückgesetzt.`,
metadata: { undoneChangeId: change.id, fromStageId: change.toStageId, toStageId: change.fromStageId },
},
{ client: tx },
);
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: "project.stage_change_undone",
data: { ...workflowPayload(updated as ProjektRow), undoneChangeId: change.id, restoredStageId: previousStage.id },
userId: input.userId,
sourceId: `project:${project.id}:undo:${change.id}`,
}),
);
await publishProjectSearchUpsert(tx, updated as ProjektRow);
return updated as ProjektRow;
});
}