1455 lines
57 KiB
TypeScript
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;
|
|
});
|
|
}
|