Iteration Teil D: Lieferantenmanagement (VDA-ISA 2027 Kap. 6 + NIS2)
Datenmodell (Prisma, RLS): Supplier + SupplierAsset, SupplierAssessment, Contract, Nda, SupplierEvidence, ServiceResponsibility, Subcontractor, ManagementDecision, SupplierControl (globaler Katalog) + SupplierControlMaturity. - VDA-ISA-2027-Kap.-6-Mini-Katalog (Controls 6.1.1–6.1.3) mit Zielbild, Muss/Soll, Anforderungen für hoch/sehr hoch, Simplified Group Assessment, Ziel-Reifegrad 3 und Cross-Referenzen (ISO/NIST/BSI); im Seed befüllt - Lieferantenverzeichnis mit KPIs (gesamt, NIS2-relevant, ablaufend, Reviews fällig), Kritikalität, NIS2-Flag, Review-Fristen - Detail-Popup: Stammdaten (Sektor/Leistung/Datenkategorien/CIA), betroffene Assets, VDA-ISA-Reifegrade je Control (Ziel 3, farbige Balken), Nachweise (Angemessenheit + Ablauf), Assessments, Verträge (AV/DPA, Flow-down, Fristen), NDAs (Fristen), Shared-Responsibility- Matrix, Subunternehmer, Managemententscheidungen - Bearbeiten-Popup: Stammdaten (ein Speichern), Reifegrad je Control, Add/Delete für alle Kind-Entitäten, Löschen im ⋯-Menü - Regel 6.1.1: fehlt geprüfter Audit-/TISAX-Nachweis → Warnung, dass eine dokumentierte risikobasierte Managemententscheidung nötig ist - Server-Actions mit Zod/RBAC/Audit-Log; Seed mit Demo-Lieferant (TISAX-Label, AV/DPA, NDA, Assessment, RACI, Reifegrade) - Menüpunkt „Lieferanten" aktiv; Lieferant ↔ Asset verknüpft (Graph) Verifiziert: Register/Detail/Bearbeiten dunkel & vollständig, Reifegrad- Save (6.1.2→4 mit Audit), Managemententscheidungs-Logik. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
18bd82e54a
commit
32ab91efbf
@@ -0,0 +1,335 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const level = z.coerce.number().int().min(1).max(4);
|
||||
|
||||
const supplierSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
sector: z.string().trim().max(200).optional(),
|
||||
services: z.string().trim().max(2000).optional(),
|
||||
criticality: level,
|
||||
dataCategories: z.string().optional(),
|
||||
confidentiality: level,
|
||||
integrity: level,
|
||||
availability: level,
|
||||
nis2Relevant: z.union([z.literal("on"), z.literal(null), z.string()]).optional(),
|
||||
status: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
|
||||
contact: z.string().trim().max(200).optional(),
|
||||
nextReview: z.string().optional(),
|
||||
notes: z.string().trim().max(5000).optional(),
|
||||
});
|
||||
|
||||
function parseSupplier(formData: FormData) {
|
||||
const p = supplierSchema.parse({
|
||||
name: formData.get("name"),
|
||||
sector: formData.get("sector") || undefined,
|
||||
services: formData.get("services") || undefined,
|
||||
criticality: formData.get("criticality"),
|
||||
dataCategories: formData.get("dataCategories") || undefined,
|
||||
confidentiality: formData.get("confidentiality"),
|
||||
integrity: formData.get("integrity"),
|
||||
availability: formData.get("availability"),
|
||||
nis2Relevant: formData.get("nis2Relevant"),
|
||||
status: formData.get("status"),
|
||||
contact: formData.get("contact") || undefined,
|
||||
nextReview: formData.get("nextReview") || undefined,
|
||||
notes: formData.get("notes") || undefined,
|
||||
});
|
||||
return {
|
||||
name: p.name,
|
||||
sector: p.sector || null,
|
||||
services: p.services || null,
|
||||
criticality: p.criticality,
|
||||
dataCategories: p.dataCategories
|
||||
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [],
|
||||
confidentiality: p.confidentiality,
|
||||
integrity: p.integrity,
|
||||
availability: p.availability,
|
||||
nis2Relevant: p.nis2Relevant === "on",
|
||||
status: p.status,
|
||||
contact: p.contact || null,
|
||||
nextReview: p.nextReview ? new Date(p.nextReview) : null,
|
||||
notes: p.notes || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSupplier(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const data = parseSupplier(formData);
|
||||
const last = await prisma.supplier.aggregate({
|
||||
where: { tenantId: session.user.tenantId },
|
||||
_max: { refNo: true },
|
||||
});
|
||||
const supplier = await db.supplier.create({
|
||||
data: {
|
||||
...data,
|
||||
refNo: (last._max.refNo ?? 0) + 1,
|
||||
tenantId: session.user.tenantId,
|
||||
createdBy: session.user.id,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "create",
|
||||
entity: "supplier",
|
||||
entityId: supplier.id,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/suppliers");
|
||||
redirect(`/suppliers?edit=${supplier.id}`);
|
||||
}
|
||||
|
||||
export async function updateSupplier(supplierId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.supplier.findUnique({ where: { id: supplierId } });
|
||||
if (!before) throw new Error("Lieferant nicht gefunden");
|
||||
const data = parseSupplier(formData);
|
||||
await db.supplier.update({ where: { id: supplierId }, data });
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "update",
|
||||
entity: "supplier",
|
||||
entityId: supplierId,
|
||||
before,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/suppliers");
|
||||
redirect(`/suppliers?detail=${supplierId}`);
|
||||
}
|
||||
|
||||
export async function deleteSupplier(supplierId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const before = await db.supplier.findUnique({ where: { id: supplierId } });
|
||||
if (!before) throw new Error("Lieferant nicht gefunden");
|
||||
await db.supplier.delete({ where: { id: supplierId } });
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "supplier",
|
||||
entityId: supplierId,
|
||||
before,
|
||||
});
|
||||
revalidatePath("/suppliers");
|
||||
redirect("/suppliers");
|
||||
}
|
||||
|
||||
/** Generischer Helper zum Anlegen einer Kind-Entität (mit Tenant/Owner-Prüfung). */
|
||||
async function ensureSupplier(session: Awaited<ReturnType<typeof requireSession>>, supplierId: string) {
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const count = await db.supplier.count({ where: { id: supplierId } });
|
||||
if (count !== 1) throw new Error("Lieferant nicht gefunden");
|
||||
return db;
|
||||
}
|
||||
|
||||
const optDate = (v: FormDataEntryValue | null) =>
|
||||
v && String(v) ? new Date(String(v)) : null;
|
||||
|
||||
export async function addAssessment(supplierId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = await ensureSupplier(session, supplierId);
|
||||
await db.supplierAssessment.create({
|
||||
data: {
|
||||
supplierId,
|
||||
tenantId: session.user.tenantId,
|
||||
type: z.enum(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"]).parse(formData.get("type")),
|
||||
status: z
|
||||
.enum(["SENT", "RECEIVED", "EVALUATED", "OVERDUE"])
|
||||
.parse(formData.get("status") ?? "SENT"),
|
||||
score: formData.get("score") ? Number(formData.get("score")) : null,
|
||||
date: optDate(formData.get("date")),
|
||||
nextReview: optDate(formData.get("nextReview")),
|
||||
result: (formData.get("result") as string)?.trim() || null,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: supplierId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
export async function addContract(supplierId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = await ensureSupplier(session, supplierId);
|
||||
const bool = (n: string) => formData.get(n) === "on";
|
||||
await db.contract.create({
|
||||
data: {
|
||||
supplierId,
|
||||
tenantId: session.user.tenantId,
|
||||
type: (formData.get("type") as string) || "service",
|
||||
avDpa: bool("avDpa"),
|
||||
securityClauses: bool("securityClauses"),
|
||||
flowdown: bool("flowdown"),
|
||||
customerTransparency: bool("customerTransparency"),
|
||||
validFrom: optDate(formData.get("validFrom")),
|
||||
validTo: optDate(formData.get("validTo")),
|
||||
reference: (formData.get("reference") as string)?.trim() || null,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: supplierId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
export async function addNda(supplierId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = await ensureSupplier(session, supplierId);
|
||||
await db.nda.create({
|
||||
data: {
|
||||
supplierId,
|
||||
tenantId: session.user.tenantId,
|
||||
parties: (formData.get("parties") as string)?.trim() || null,
|
||||
infoScope: (formData.get("infoScope") as string)?.trim() || null,
|
||||
subject: (formData.get("subject") as string)?.trim() || null,
|
||||
validFrom: optDate(formData.get("validFrom")),
|
||||
validTo: optDate(formData.get("validTo")),
|
||||
obligations: (formData.get("obligations") as string)?.trim() || null,
|
||||
extensionStatus: (formData.get("extensionStatus") as string)?.trim() || null,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: supplierId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
export async function addEvidence(supplierId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = await ensureSupplier(session, supplierId);
|
||||
await db.supplierEvidence.create({
|
||||
data: {
|
||||
supplierId,
|
||||
tenantId: session.user.tenantId,
|
||||
kind: z
|
||||
.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"])
|
||||
.parse(formData.get("kind")),
|
||||
name: (formData.get("name") as string)?.trim() || null,
|
||||
protectsCia: (formData.get("protectsCia") as string)?.trim() || null,
|
||||
validTo: optDate(formData.get("validTo")),
|
||||
adequacyChecked: formData.get("adequacyChecked") === "on",
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: supplierId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
export async function addResponsibility(supplierId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = await ensureSupplier(session, supplierId);
|
||||
await db.serviceResponsibility.create({
|
||||
data: {
|
||||
supplierId,
|
||||
tenantId: session.user.tenantId,
|
||||
itService: z.string().trim().min(1).parse(formData.get("itService")),
|
||||
requirement: z.string().trim().min(1).parse(formData.get("requirement")),
|
||||
responsibleParty: z
|
||||
.enum(["CLIENT", "SUPPLIER", "SHARED"])
|
||||
.parse(formData.get("responsibleParty") ?? "SHARED"),
|
||||
isaApplicability: (formData.get("isaApplicability") as string)?.trim() || null,
|
||||
evidence: (formData.get("evidence") as string)?.trim() || null,
|
||||
integratedLocalControls: (formData.get("integratedLocalControls") as string)?.trim() || null,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_responsibility", entityId: supplierId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
export async function addSubcontractor(supplierId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = await ensureSupplier(session, supplierId);
|
||||
await db.subcontractor.create({
|
||||
data: {
|
||||
supplierId,
|
||||
tenantId: session.user.tenantId,
|
||||
name: z.string().trim().min(1).parse(formData.get("name")),
|
||||
flowdownObligation: formData.get("flowdownObligation") === "on",
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "subcontractor", entityId: supplierId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
export async function addDecision(supplierId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = await ensureSupplier(session, supplierId);
|
||||
await db.managementDecision.create({
|
||||
data: {
|
||||
supplierId,
|
||||
tenantId: session.user.tenantId,
|
||||
reasonNoAudit: z.string().trim().min(1).parse(formData.get("reasonNoAudit")),
|
||||
decision: z.string().trim().min(1).parse(formData.get("decision")),
|
||||
decidedBy: (formData.get("decidedBy") as string)?.trim() || session.user.name || null,
|
||||
recordRef: (formData.get("recordRef") as string)?.trim() || null,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: supplierId });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
export async function saveMaturity(supplierId: string, controlId: string, formData: FormData) {
|
||||
await setControlMaturity(supplierId, controlId, Number(formData.get("maturity")));
|
||||
}
|
||||
|
||||
export async function setControlMaturity(supplierId: string, controlId: string, maturity: number) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = await ensureSupplier(session, supplierId);
|
||||
const m = z.coerce.number().int().min(0).max(5).parse(maturity);
|
||||
await db.supplierControlMaturity.upsert({
|
||||
where: { supplierId_controlId: { supplierId, controlId } },
|
||||
update: { maturity: m },
|
||||
create: { supplierId, controlId, maturity: m, tenantId: session.user.tenantId },
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier_control_maturity", entityId: supplierId, after: { controlId, maturity: m } });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
|
||||
/** Generisches Löschen einer Kind-Entität. */
|
||||
export async function deleteChild(
|
||||
kind:
|
||||
| "assessment"
|
||||
| "contract"
|
||||
| "nda"
|
||||
| "evidence"
|
||||
| "responsibility"
|
||||
| "subcontractor"
|
||||
| "decision",
|
||||
id: string
|
||||
) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const map = {
|
||||
assessment: db.supplierAssessment,
|
||||
contract: db.contract,
|
||||
nda: db.nda,
|
||||
evidence: db.supplierEvidence,
|
||||
responsibility: db.serviceResponsibility,
|
||||
subcontractor: db.subcontractor,
|
||||
decision: db.managementDecision,
|
||||
} as const;
|
||||
// @ts-expect-error dynamischer Delegate-Zugriff
|
||||
await map[kind].delete({ where: { id } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
|
||||
revalidatePath("/suppliers");
|
||||
}
|
||||
@@ -37,6 +37,16 @@ const TENANT_MODELS = new Set<string>([
|
||||
"RiskAsset",
|
||||
"Measure",
|
||||
"RiskMeasure",
|
||||
"Supplier",
|
||||
"SupplierAsset",
|
||||
"SupplierAssessment",
|
||||
"Contract",
|
||||
"Nda",
|
||||
"SupplierEvidence",
|
||||
"ServiceResponsibility",
|
||||
"Subcontractor",
|
||||
"ManagementDecision",
|
||||
"SupplierControlMaturity",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user