feat(iace): Customer-Standard-Reuse across customer's prior projects
CI / detect-changes (push) Successful in 10s
CI / guardrail-integrity (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-go (push) Successful in 47s
CI / nodejs-build (push) Successful in 2m46s
CI / iace-gt-coverage (push) Successful in 28s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / guardrail-integrity (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-go (push) Successful in 47s
CI / nodejs-build (push) Successful in 2m46s
CI / iace-gt-coverage (push) Successful in 28s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
[migration-approved] Task #22. The IACE module is used by a single Maschinenhersteller, but their plants land at many different end customers. When the safety expert commissions the second or third plant at the same customer, whole classes of mitigations (company-wide PPE rules, locked-out energy isolation, customer-standard signage) are already in place there — but rediscovered from scratch every project. Migration 031: iace_projects.customer_name TEXT + partial index. The customer is stored as a plain text field rather than a normalised iace_customers table (option A from the design discussion). A proper customer-management screen can promote this to a FK later without data loss. Backend store_customer_standards.go: - ListCustomerStandardSuggestions(projectID, includeVerified) collects mitigations from all non-archived prior projects sharing the same tenant_id AND case-insensitive customer_name. Aggregates by mitigation.name (since same-named measures from different prior projects collapse into one suggestion) and surfaces: • source_project_count + source_project_names • is_customer_standard / has_verified_instances flags includeVerified=false → strictly is_customer_standard=true includeVerified=true → also status='verified' - ImportCustomerStandardSuggestion(projectID, name): for every prior (mitigation.name → hazard.name) pairing, finds matching hazards in the current project (by name) and ensures a customer-standard mitigation exists. New rows via CreateMitigation (idempotent through the UNIQUE(hazard_id, name) from migration 030); existing rows are flipped to is_relevant=true + is_customer_standard=true + status='verified' via UPDATE. Routes: GET /api/v1/iace/projects/:id/customer-standards?include_verified= POST /api/v1/iace/projects/:id/customer-standards/import body {name} Frontend: - New page /sdk/iace/[projectId]/customer-standards with: • empty-state hint pointing to Auftrag → Kundenname • per-suggestion checkbox + per-row Übernehmen button • bulk "N übernehmen" button • toggle "Auch verifizierte einbeziehen" widening the pool • per-suggestion source_project_count + status badges - Sidebar item "Kundenstandards" (building icon) placed between Verifikation and Nachweise. - Order-page now mirrors Auftraggeber.Firmenname into the top-level customer_name column on save, so the Reuse feature is fed automatically without a separate input field. The same expert effect from migration 029's is_customer_standard flag — "I already know it's covered, no evidence needed" — now becomes a cross-project asset rather than a per-project annotation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
|
||||||
|
type Suggestion = {
|
||||||
|
name: string
|
||||||
|
reduction_type: 'design' | 'protection' | 'information' | string
|
||||||
|
description: string
|
||||||
|
source_project_count: number
|
||||||
|
source_project_names: string[]
|
||||||
|
is_customer_standard: boolean
|
||||||
|
has_verified_instances: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectInfo = { customer_name?: string; machine_name?: string }
|
||||||
|
|
||||||
|
// /sdk/iace/[projectId]/customer-standards
|
||||||
|
//
|
||||||
|
// Surfaces mitigations that the expert flagged as "Kundenstandard" (or
|
||||||
|
// successfully verified) in earlier projects of the SAME customer. Picking
|
||||||
|
// one and clicking "Übernehmen" applies it to all matching hazards in the
|
||||||
|
// current project — every match is set to is_relevant=true,
|
||||||
|
// is_customer_standard=true, status='verified'. Saves the round-trip
|
||||||
|
// through Massnahmen + Verifikation for the cases where the safety expert
|
||||||
|
// already knows the answer from a prior plant at the same site.
|
||||||
|
//
|
||||||
|
// Filter "Auch verifizierte einbeziehen" widens the pool beyond strictly
|
||||||
|
// is_customer_standard=true to also include status='verified' rows — useful
|
||||||
|
// when the customer-standard habit is not yet established in the corpus.
|
||||||
|
export default function CustomerStandardsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const projectId = params.projectId as string
|
||||||
|
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||||
|
const [project, setProject] = useState<ProjectInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [includeVerified, setIncludeVerified] = useState(false)
|
||||||
|
const [importing, setImporting] = useState<string | null>(null)
|
||||||
|
const [importedNames, setImportedNames] = useState<Set<string>>(new Set())
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [sgRes, prRes] = await Promise.all([
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards?include_verified=${includeVerified}`),
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
|
||||||
|
])
|
||||||
|
if (sgRes.ok) {
|
||||||
|
const j = await sgRes.json()
|
||||||
|
setSuggestions(j.suggestions || [])
|
||||||
|
}
|
||||||
|
if (prRes.ok) {
|
||||||
|
const j = await prRes.json()
|
||||||
|
const p = j.project || j
|
||||||
|
setProject({ customer_name: p.customer_name, machine_name: p.machine_name })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId, includeVerified])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
function toggleSelect(name: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(name)) next.delete(name); else next.add(name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importOne(name: string) {
|
||||||
|
setImporting(name)
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards/import`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
})
|
||||||
|
if (r.ok) {
|
||||||
|
setImportedNames((prev) => new Set(prev).add(name))
|
||||||
|
setSelected((prev) => { const n = new Set(prev); n.delete(name); return n })
|
||||||
|
} else {
|
||||||
|
const j = await r.json().catch(() => null)
|
||||||
|
setError(j?.error || `HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setImporting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSelected() {
|
||||||
|
const names = Array.from(selected)
|
||||||
|
for (const n of names) {
|
||||||
|
await importOne(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// No customer set → guide the user to set it first
|
||||||
|
const hasCustomer = !!(project?.customer_name && project.customer_name.trim() !== '')
|
||||||
|
if (!hasCustomer) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<h1 className="text-2xl font-bold">Kundenstandards</h1>
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
Dieses Projekt hat noch keinen <em>Kundennamen</em>. Damit Massnahmen aus früheren
|
||||||
|
Anlagen desselben Kunden wiederverwendet werden können, trage den Kundennamen
|
||||||
|
unter <a className="text-purple-700 underline" href={`/sdk/iace/${projectId}/order`}>Auftrag → Kunde</a> ein.
|
||||||
|
Sobald der Kundenname gesetzt ist, erscheint hier die Liste der wiederverwendbaren
|
||||||
|
Maßnahmen aus seinen Vorprojekten.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Kundenstandards</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Übernimm Maßnahmen, die der Kunde <strong>{project?.customer_name}</strong> in
|
||||||
|
anderen Anlagen bereits als Standard etabliert hat. Übernehmen setzt sie für alle
|
||||||
|
passenden Gefährdungen <em>relevant</em> und <em>verifiziert</em> ohne Nachweis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||||
|
<input type="checkbox" checked={includeVerified}
|
||||||
|
onChange={(e) => setIncludeVerified(e.target.checked)}
|
||||||
|
className="accent-purple-600" />
|
||||||
|
Auch <em>verifizierte</em> einbeziehen
|
||||||
|
</label>
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<button onClick={importSelected} disabled={!!importing}
|
||||||
|
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{importing ? 'Übernehme…' : `${selected.size} übernehmen`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-red-600 text-sm">Fehler: {error}</div>}
|
||||||
|
|
||||||
|
{suggestions.length === 0 && (
|
||||||
|
<div className="rounded-md border border-gray-200 bg-gray-50 px-4 py-6 text-sm text-gray-600">
|
||||||
|
Keine wiederverwendbaren Maßnahmen für <strong>{project?.customer_name}</strong> gefunden.
|
||||||
|
{!includeVerified && ' Aktiviere „Auch verifizierte einbeziehen" oben rechts, um den Pool zu erweitern.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[28px_2fr_120px_100px_120px] gap-3 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
<div />
|
||||||
|
<div>Massnahme</div>
|
||||||
|
<div className="text-center">Vorprojekte</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div className="text-right">Aktion</div>
|
||||||
|
</div>
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const imported = importedNames.has(s.name)
|
||||||
|
return (
|
||||||
|
<div key={s.name} className={`grid grid-cols-[28px_2fr_120px_100px_120px] gap-3 px-4 py-2.5 border-t border-gray-100 dark:border-gray-700 ${imported ? 'bg-green-50/40' : ''} ${selected.has(s.name) ? 'bg-purple-50' : ''}`}>
|
||||||
|
<div className="pt-0.5">
|
||||||
|
<input type="checkbox" checked={selected.has(s.name)} onChange={() => toggleSelect(s.name)} disabled={imported}
|
||||||
|
className="accent-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm text-gray-900 dark:text-white">{s.name}</div>
|
||||||
|
{s.description && <div className="text-[11px] text-gray-500 mt-0.5 line-clamp-2">{s.description}</div>}
|
||||||
|
{s.source_project_names.length > 0 && (
|
||||||
|
<div className="text-[10px] text-gray-400 mt-1">aus: {s.source_project_names.slice(0,3).join(', ')}{s.source_project_names.length > 3 ? ` (+${s.source_project_names.length - 3})` : ''}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-center self-center">
|
||||||
|
<span className="text-sm font-semibold text-purple-700">{s.source_project_count}×</span>
|
||||||
|
</div>
|
||||||
|
<div className="self-center flex flex-wrap gap-1">
|
||||||
|
{s.is_customer_standard && <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700">Kundenstandard</span>}
|
||||||
|
{s.has_verified_instances && !s.is_customer_standard && <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700">Verifiziert</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-right self-center">
|
||||||
|
{imported ? (
|
||||||
|
<span className="text-[11px] text-green-700">✓ Übernommen</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => importOne(s.name)} disabled={!!importing}
|
||||||
|
className="px-2.5 py-1 text-[11px] bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{importing === s.name ? 'Übernehme…' : 'Übernehmen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -68,10 +68,14 @@ export default function OrderPage() {
|
|||||||
setSaveState('saving')
|
setSaveState('saving')
|
||||||
try {
|
try {
|
||||||
const merged = { ...existingMetaRef.current, order_data: next }
|
const merged = { ...existingMetaRef.current, order_data: next }
|
||||||
|
// Mirror Auftraggeber.Firmenname into the top-level customer_name
|
||||||
|
// column so the Customer-Standards-Reuse feature can index by it.
|
||||||
|
// Empty string → null on the backend, no broken reuse for fresh projects.
|
||||||
|
const customerName = (next.client.company || '').trim()
|
||||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ metadata: merged }),
|
body: JSON.stringify({ metadata: merged, customer_name: customerName }),
|
||||||
})
|
})
|
||||||
existingMetaRef.current = merged
|
existingMetaRef.current = merged
|
||||||
setSaveState('saved')
|
setSaveState('saved')
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const IACE_NAV_ITEMS = [
|
|||||||
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
|
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
|
||||||
{ id: 'clarifications', label: 'Klärungen', href: '/clarifications', icon: 'chat' },
|
{ id: 'clarifications', label: 'Klärungen', href: '/clarifications', icon: 'chat' },
|
||||||
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
|
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
|
||||||
|
{ id: 'customer-standards', label: 'Kundenstandards', href: '/customer-standards', icon: 'building' },
|
||||||
{ id: 'evidence', label: 'Nachweise', href: '/evidence', icon: 'document' },
|
{ id: 'evidence', label: 'Nachweise', href: '/evidence', icon: 'document' },
|
||||||
{ id: 'tech-file', label: 'CE-Akte', href: '/tech-file', icon: 'folder' },
|
{ id: 'tech-file', label: 'CE-Akte', href: '/tech-file', icon: 'folder' },
|
||||||
]
|
]
|
||||||
@@ -67,6 +68,12 @@ function NavIcon({ icon, className }: { icon: string; className?: string }) {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
case 'building':
|
||||||
|
return (
|
||||||
|
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-16 0H3m4-4h2m-2-4h2m-2-4h2m4 8h2m-2-4h2m-2-4h2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
case 'document':
|
case 'document':
|
||||||
return (
|
return (
|
||||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListCustomerStandardSuggestions handles
|
||||||
|
// GET /api/v1/iace/projects/:id/customer-standards?include_verified=true|false
|
||||||
|
//
|
||||||
|
// Returns the set of reusable mitigations from prior projects of the same
|
||||||
|
// customer. Empty array when the project has no customer_name or no
|
||||||
|
// matching priors. The include_verified query flag controls whether
|
||||||
|
// status='verified' mitigations are included alongside the explicit
|
||||||
|
// is_customer_standard=true ones.
|
||||||
|
func (h *IACEHandler) ListCustomerStandardSuggestions(c *gin.Context) {
|
||||||
|
pid, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
includeVerified := c.Query("include_verified") == "true"
|
||||||
|
suggestions, err := h.store.ListCustomerStandardSuggestions(c.Request.Context(), pid, includeVerified)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if suggestions == nil {
|
||||||
|
suggestions = []iace.CustomerStandardSuggestion{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"count": len(suggestions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportCustomerStandardSuggestion handles
|
||||||
|
// POST /api/v1/iace/projects/:id/customer-standards/import
|
||||||
|
// Body: { "name": "Sicherheitszeichen nach ISO 7010" }
|
||||||
|
//
|
||||||
|
// Applies one suggestion to all matching hazards in the current project.
|
||||||
|
// New mitigations are created idempotently; existing ones are flipped to
|
||||||
|
// is_relevant=true + is_customer_standard=true + status='verified'.
|
||||||
|
func (h *IACEHandler) ImportCustomerStandardSuggestion(c *gin.Context) {
|
||||||
|
pid, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.store.ImportCustomerStandardSuggestion(c.Request.Context(), pid, body.Name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"imported": n,
|
||||||
|
"name": body.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -459,6 +459,11 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
|||||||
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
|
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
|
||||||
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
|
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
|
||||||
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
|
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
|
||||||
|
|
||||||
|
// Customer-Standard Reuse (migration 031): pull reusable mitigations
|
||||||
|
// across prior projects of the same customer.
|
||||||
|
iaceRoutes.GET("/projects/:id/customer-standards", h.ListCustomerStandardSuggestions)
|
||||||
|
iaceRoutes.POST("/projects/:id/customer-standards/import", h.ImportCustomerStandardSuggestion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type CreateProjectRequest struct {
|
|||||||
MachineName string `json:"machine_name" binding:"required"`
|
MachineName string `json:"machine_name" binding:"required"`
|
||||||
MachineType string `json:"machine_type" binding:"required"`
|
MachineType string `json:"machine_type" binding:"required"`
|
||||||
Manufacturer string `json:"manufacturer" binding:"required"`
|
Manufacturer string `json:"manufacturer" binding:"required"`
|
||||||
|
CustomerName string `json:"customer_name,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
NarrativeText string `json:"narrative_text,omitempty"`
|
NarrativeText string `json:"narrative_text,omitempty"`
|
||||||
CEMarkingTarget string `json:"ce_marking_target,omitempty"`
|
CEMarkingTarget string `json:"ce_marking_target,omitempty"`
|
||||||
@@ -27,6 +28,7 @@ type UpdateProjectRequest struct {
|
|||||||
MachineName *string `json:"machine_name,omitempty"`
|
MachineName *string `json:"machine_name,omitempty"`
|
||||||
MachineType *string `json:"machine_type,omitempty"`
|
MachineType *string `json:"machine_type,omitempty"`
|
||||||
Manufacturer *string `json:"manufacturer,omitempty"`
|
Manufacturer *string `json:"manufacturer,omitempty"`
|
||||||
|
CustomerName *string `json:"customer_name,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
NarrativeText *string `json:"narrative_text,omitempty"`
|
NarrativeText *string `json:"narrative_text,omitempty"`
|
||||||
CEMarkingTarget *string `json:"ce_marking_target,omitempty"`
|
CEMarkingTarget *string `json:"ce_marking_target,omitempty"`
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type Project struct {
|
|||||||
MachineName string `json:"machine_name"`
|
MachineName string `json:"machine_name"`
|
||||||
MachineType string `json:"machine_type"`
|
MachineType string `json:"machine_type"`
|
||||||
Manufacturer string `json:"manufacturer"`
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
// CustomerName is the end customer (Anlagenbetreiber). Optional —
|
||||||
|
// projects without a customer are still valid, but customer-standard
|
||||||
|
// reuse only fires across projects sharing the same non-empty value
|
||||||
|
// (case-insensitive match, see customerKey()).
|
||||||
|
CustomerName string `json:"customer_name,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
NarrativeText string `json:"narrative_text,omitempty"`
|
NarrativeText string `json:"narrative_text,omitempty"`
|
||||||
Status ProjectStatus `json:"status"`
|
Status ProjectStatus `json:"status"`
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package iace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerStandardSuggestion aggregates one reusable mitigation across prior
|
||||||
|
// projects of the same customer. The same mitigation name may appear in
|
||||||
|
// multiple prior projects; we collapse them into a single suggestion and
|
||||||
|
// count the prior occurrences so the expert sees a confidence signal.
|
||||||
|
type CustomerStandardSuggestion struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReductionType string `json:"reduction_type"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
// SourceProjectCount tells the expert in how many of the customer's
|
||||||
|
// earlier projects this mitigation was already flagged. Higher count
|
||||||
|
// = stronger reuse signal.
|
||||||
|
SourceProjectCount int `json:"source_project_count"`
|
||||||
|
SourceProjectNames []string `json:"source_project_names"`
|
||||||
|
IsCustomerStandard bool `json:"is_customer_standard"`
|
||||||
|
HasVerifiedInstances bool `json:"has_verified_instances"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCustomerStandardSuggestions returns reusable mitigations from prior
|
||||||
|
// projects of the same customer as projectID. The customer key is the
|
||||||
|
// case-insensitive trimmed customer_name; an empty customer_name short-
|
||||||
|
// circuits to an empty result.
|
||||||
|
//
|
||||||
|
// includeVerified=false → only mitigations with is_customer_standard=true
|
||||||
|
// includeVerified=true → also include status='verified' mitigations
|
||||||
|
// (broader pool, useful when the customer-standard
|
||||||
|
// habit isn't yet established in the data)
|
||||||
|
func (s *Store) ListCustomerStandardSuggestions(
|
||||||
|
ctx context.Context,
|
||||||
|
projectID uuid.UUID,
|
||||||
|
includeVerified bool,
|
||||||
|
) ([]CustomerStandardSuggestion, error) {
|
||||||
|
// Resolve the customer + tenant for the current project.
|
||||||
|
var tenantID uuid.UUID
|
||||||
|
var customerName string
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT tenant_id, COALESCE(customer_name, '') FROM iace_projects WHERE id = $1`,
|
||||||
|
projectID,
|
||||||
|
).Scan(&tenantID, &customerName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve project for customer-standards: %w", err)
|
||||||
|
}
|
||||||
|
customerName = strings.TrimSpace(customerName)
|
||||||
|
if customerName == "" {
|
||||||
|
return []CustomerStandardSuggestion{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filterClause := "m.is_customer_standard = TRUE"
|
||||||
|
if includeVerified {
|
||||||
|
filterClause = "(m.is_customer_standard = TRUE OR m.status = 'verified')"
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
m.name,
|
||||||
|
m.reduction_type,
|
||||||
|
MAX(m.description) AS description,
|
||||||
|
COUNT(DISTINCT p.id) AS source_count,
|
||||||
|
array_agg(DISTINCT p.machine_name ORDER BY p.machine_name) AS source_names,
|
||||||
|
BOOL_OR(m.is_customer_standard) AS has_customer_std,
|
||||||
|
BOOL_OR(m.status = 'verified') AS has_verified
|
||||||
|
FROM iace_mitigations m
|
||||||
|
JOIN iace_hazards h ON h.id = m.hazard_id
|
||||||
|
JOIN iace_projects p ON p.id = h.project_id
|
||||||
|
WHERE p.tenant_id = $1
|
||||||
|
AND p.id <> $2
|
||||||
|
AND p.archived_at IS NULL
|
||||||
|
AND LOWER(TRIM(COALESCE(p.customer_name, ''))) = LOWER($3)
|
||||||
|
AND %s
|
||||||
|
GROUP BY m.name, m.reduction_type
|
||||||
|
ORDER BY source_count DESC, m.name
|
||||||
|
`, filterClause)
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, tenantID, projectID, customerName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query customer-standards: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []CustomerStandardSuggestion
|
||||||
|
for rows.Next() {
|
||||||
|
var sg CustomerStandardSuggestion
|
||||||
|
if scanErr := rows.Scan(
|
||||||
|
&sg.Name, &sg.ReductionType, &sg.Description,
|
||||||
|
&sg.SourceProjectCount, &sg.SourceProjectNames,
|
||||||
|
&sg.IsCustomerStandard, &sg.HasVerifiedInstances,
|
||||||
|
); scanErr != nil {
|
||||||
|
return nil, fmt.Errorf("scan customer-standards: %w", scanErr)
|
||||||
|
}
|
||||||
|
out = append(out, sg)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportCustomerStandardSuggestion applies a suggestion to the current
|
||||||
|
// project: for every hazard in the project whose name matches one of the
|
||||||
|
// suggestion's source hazards (by mitigation.name → hazard.name pairing in
|
||||||
|
// prior projects), it ensures a relevant + customer-standard mitigation
|
||||||
|
// exists. New mitigations are inserted via CreateMitigation (idempotent
|
||||||
|
// via UNIQUE(hazard_id, name)), existing ones are flipped to
|
||||||
|
// is_relevant=true + is_customer_standard=true + status='verified'.
|
||||||
|
//
|
||||||
|
// Returns the number of mitigations affected (created + updated).
|
||||||
|
func (s *Store) ImportCustomerStandardSuggestion(
|
||||||
|
ctx context.Context,
|
||||||
|
projectID uuid.UUID,
|
||||||
|
mitigationName string,
|
||||||
|
) (int, error) {
|
||||||
|
// Find tenant + customer of the target project.
|
||||||
|
var tenantID uuid.UUID
|
||||||
|
var customerName string
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT tenant_id, COALESCE(customer_name, '') FROM iace_projects WHERE id = $1`,
|
||||||
|
projectID,
|
||||||
|
).Scan(&tenantID, &customerName); err != nil {
|
||||||
|
return 0, fmt.Errorf("resolve project: %w", err)
|
||||||
|
}
|
||||||
|
customerName = strings.TrimSpace(customerName)
|
||||||
|
if customerName == "" {
|
||||||
|
return 0, fmt.Errorf("project has no customer_name — nothing to reuse")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the hazard names this mitigation was attached to in the
|
||||||
|
// customer's prior projects + a representative reduction_type/description.
|
||||||
|
priorRows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT DISTINCT h.name, m.reduction_type, COALESCE(m.description, '')
|
||||||
|
FROM iace_mitigations m
|
||||||
|
JOIN iace_hazards h ON h.id = m.hazard_id
|
||||||
|
JOIN iace_projects p ON p.id = h.project_id
|
||||||
|
WHERE p.tenant_id = $1
|
||||||
|
AND p.id <> $2
|
||||||
|
AND p.archived_at IS NULL
|
||||||
|
AND LOWER(TRIM(COALESCE(p.customer_name, ''))) = LOWER($3)
|
||||||
|
AND m.name = $4
|
||||||
|
`, tenantID, projectID, customerName, mitigationName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("collect prior hazards: %w", err)
|
||||||
|
}
|
||||||
|
defer priorRows.Close()
|
||||||
|
|
||||||
|
type proto struct{ hazardName, reductionType, description string }
|
||||||
|
var prototypes []proto
|
||||||
|
for priorRows.Next() {
|
||||||
|
var p proto
|
||||||
|
if err := priorRows.Scan(&p.hazardName, &p.reductionType, &p.description); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
prototypes = append(prototypes, p)
|
||||||
|
}
|
||||||
|
if len(prototypes) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For every prototype hazard name, find the matching hazard in the
|
||||||
|
// current project (same name) and ensure a relevant + customer-standard
|
||||||
|
// mitigation with mitigationName exists for it.
|
||||||
|
affected := 0
|
||||||
|
for _, p := range prototypes {
|
||||||
|
var hazardIDs []uuid.UUID
|
||||||
|
hazRows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id FROM iace_hazards WHERE project_id = $1 AND name = $2`,
|
||||||
|
projectID, p.hazardName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return affected, fmt.Errorf("find target hazards: %w", err)
|
||||||
|
}
|
||||||
|
for hazRows.Next() {
|
||||||
|
var hid uuid.UUID
|
||||||
|
if scanErr := hazRows.Scan(&hid); scanErr != nil {
|
||||||
|
hazRows.Close()
|
||||||
|
return affected, scanErr
|
||||||
|
}
|
||||||
|
hazardIDs = append(hazardIDs, hid)
|
||||||
|
}
|
||||||
|
hazRows.Close()
|
||||||
|
|
||||||
|
for _, hid := range hazardIDs {
|
||||||
|
// Idempotent insert; UPDATE sets relevance + verified state.
|
||||||
|
_, err := s.CreateMitigation(ctx, CreateMitigationRequest{
|
||||||
|
HazardID: hid,
|
||||||
|
Name: mitigationName,
|
||||||
|
Description: p.description,
|
||||||
|
ReductionType: ReductionType(p.reductionType),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return affected, fmt.Errorf("create mitigation: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE iace_mitigations
|
||||||
|
SET is_relevant = TRUE,
|
||||||
|
is_customer_standard = TRUE,
|
||||||
|
status = 'verified',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE hazard_id = $1 AND name = $2
|
||||||
|
`, hid, mitigationName); err != nil {
|
||||||
|
return affected, fmt.Errorf("upgrade mitigation: %w", err)
|
||||||
|
}
|
||||||
|
affected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ func (s *Store) CreateProject(ctx context.Context, tenantID uuid.UUID, req Creat
|
|||||||
MachineName: req.MachineName,
|
MachineName: req.MachineName,
|
||||||
MachineType: req.MachineType,
|
MachineType: req.MachineType,
|
||||||
Manufacturer: req.Manufacturer,
|
Manufacturer: req.Manufacturer,
|
||||||
|
CustomerName: req.CustomerName,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
NarrativeText: req.NarrativeText,
|
NarrativeText: req.NarrativeText,
|
||||||
Status: ProjectStatusDraft,
|
Status: ProjectStatusDraft,
|
||||||
@@ -35,19 +36,19 @@ func (s *Store) CreateProject(ctx context.Context, tenantID uuid.UUID, req Creat
|
|||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
INSERT INTO iace_projects (
|
INSERT INTO iace_projects (
|
||||||
id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer,
|
id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer,
|
||||||
description, narrative_text, status, ce_marking_target,
|
customer_name, description, narrative_text, status, ce_marking_target,
|
||||||
completeness_score, risk_summary, triggered_regulations, metadata,
|
completeness_score, risk_summary, triggered_regulations, metadata,
|
||||||
created_at, updated_at, archived_at
|
created_at, updated_at, archived_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6,
|
$1, $2, $3, $4, $5, $6,
|
||||||
$7, $8, $9, $10,
|
$7, $8, $9, $10, $11,
|
||||||
$11, $12, $13, $14,
|
$12, $13, $14, $15,
|
||||||
$15, $16, $17
|
$16, $17, $18
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
project.ID, project.TenantID, project.ParentProjectID,
|
project.ID, project.TenantID, project.ParentProjectID,
|
||||||
project.MachineName, project.MachineType, project.Manufacturer,
|
project.MachineName, project.MachineType, project.Manufacturer,
|
||||||
project.Description, project.NarrativeText, string(project.Status), project.CEMarkingTarget,
|
project.CustomerName, project.Description, project.NarrativeText, string(project.Status), project.CEMarkingTarget,
|
||||||
project.CompletenessScore, nil, project.TriggeredRegulations, project.Metadata,
|
project.CompletenessScore, nil, project.TriggeredRegulations, project.Metadata,
|
||||||
project.CreatedAt, project.UpdatedAt, project.ArchivedAt,
|
project.CreatedAt, project.UpdatedAt, project.ArchivedAt,
|
||||||
)
|
)
|
||||||
@@ -67,7 +68,7 @@ func (s *Store) GetProject(ctx context.Context, id uuid.UUID) (*Project, error)
|
|||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer,
|
id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer,
|
||||||
description, narrative_text, status, ce_marking_target,
|
COALESCE(customer_name, ''), description, narrative_text, status, ce_marking_target,
|
||||||
completeness_score, risk_summary, triggered_regulations, metadata,
|
completeness_score, risk_summary, triggered_regulations, metadata,
|
||||||
created_at, updated_at, archived_at
|
created_at, updated_at, archived_at
|
||||||
FROM iace_projects WHERE id = $1
|
FROM iace_projects WHERE id = $1
|
||||||
@@ -97,7 +98,7 @@ func (s *Store) ListProjects(ctx context.Context, tenantID uuid.UUID) ([]Project
|
|||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer,
|
id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer,
|
||||||
description, narrative_text, status, ce_marking_target,
|
COALESCE(customer_name, ''), description, narrative_text, status, ce_marking_target,
|
||||||
completeness_score, risk_summary, triggered_regulations, metadata,
|
completeness_score, risk_summary, triggered_regulations, metadata,
|
||||||
created_at, updated_at, archived_at
|
created_at, updated_at, archived_at
|
||||||
FROM iace_projects WHERE tenant_id = $1
|
FROM iace_projects WHERE tenant_id = $1
|
||||||
@@ -116,7 +117,7 @@ func (s *Store) ListProjects(ctx context.Context, tenantID uuid.UUID) ([]Project
|
|||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer,
|
&p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer,
|
||||||
&p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget,
|
&p.CustomerName, &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget,
|
||||||
&p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata,
|
&p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata,
|
||||||
&p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt,
|
&p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt,
|
||||||
)
|
)
|
||||||
@@ -156,6 +157,9 @@ func (s *Store) UpdateProject(ctx context.Context, id uuid.UUID, req UpdateProje
|
|||||||
if req.Manufacturer != nil {
|
if req.Manufacturer != nil {
|
||||||
project.Manufacturer = *req.Manufacturer
|
project.Manufacturer = *req.Manufacturer
|
||||||
}
|
}
|
||||||
|
if req.CustomerName != nil {
|
||||||
|
project.CustomerName = *req.CustomerName
|
||||||
|
}
|
||||||
if req.Description != nil {
|
if req.Description != nil {
|
||||||
project.Description = *req.Description
|
project.Description = *req.Description
|
||||||
}
|
}
|
||||||
@@ -174,11 +178,13 @@ func (s *Store) UpdateProject(ctx context.Context, id uuid.UUID, req UpdateProje
|
|||||||
_, err = s.pool.Exec(ctx, `
|
_, err = s.pool.Exec(ctx, `
|
||||||
UPDATE iace_projects SET
|
UPDATE iace_projects SET
|
||||||
machine_name = $2, machine_type = $3, manufacturer = $4,
|
machine_name = $2, machine_type = $3, manufacturer = $4,
|
||||||
description = $5, narrative_text = $6, ce_marking_target = $7,
|
customer_name = $5,
|
||||||
metadata = $8, updated_at = $9
|
description = $6, narrative_text = $7, ce_marking_target = $8,
|
||||||
|
metadata = $9, updated_at = $10
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`,
|
`,
|
||||||
id, project.MachineName, project.MachineType, project.Manufacturer,
|
id, project.MachineName, project.MachineType, project.Manufacturer,
|
||||||
|
project.CustomerName,
|
||||||
project.Description, project.NarrativeText, project.CEMarkingTarget,
|
project.Description, project.NarrativeText, project.CEMarkingTarget,
|
||||||
project.Metadata, project.UpdatedAt,
|
project.Metadata, project.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -250,7 +256,7 @@ func (s *Store) ListVariants(ctx context.Context, parentID uuid.UUID) ([]Project
|
|||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer,
|
id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer,
|
||||||
description, narrative_text, status, ce_marking_target,
|
COALESCE(customer_name, ''), description, narrative_text, status, ce_marking_target,
|
||||||
completeness_score, risk_summary, triggered_regulations, metadata,
|
completeness_score, risk_summary, triggered_regulations, metadata,
|
||||||
created_at, updated_at, archived_at
|
created_at, updated_at, archived_at
|
||||||
FROM iace_projects WHERE parent_project_id = $1
|
FROM iace_projects WHERE parent_project_id = $1
|
||||||
@@ -269,7 +275,7 @@ func (s *Store) ListVariants(ctx context.Context, parentID uuid.UUID) ([]Project
|
|||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer,
|
&p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer,
|
||||||
&p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget,
|
&p.CustomerName, &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget,
|
||||||
&p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata,
|
&p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata,
|
||||||
&p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt,
|
&p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Migration 031: customer_name on iace_projects + reuse-helper index
|
||||||
|
-- ==========================================================================
|
||||||
|
-- The IACE module is operated by a single Maschinenhersteller (the SDK
|
||||||
|
-- user), but their plants land at many different end customers. A safety
|
||||||
|
-- expert who commissions the second or third plant at the same customer
|
||||||
|
-- often finds that whole classes of mitigations are already in place
|
||||||
|
-- there (company-wide PPE rules, locked-out energy isolation, customer-
|
||||||
|
-- standard signage, etc.). Today, this expert knowledge is rediscovered
|
||||||
|
-- per project.
|
||||||
|
--
|
||||||
|
-- This migration introduces a plain customer_name field on the project
|
||||||
|
-- (no separate customer table yet — Option A from the design discussion;
|
||||||
|
-- normalised iace_customers can come later when a real customer-management
|
||||||
|
-- screen is built). The field is optional so existing projects without a
|
||||||
|
-- customer remain valid.
|
||||||
|
--
|
||||||
|
-- The partial index makes the customer-standards lookup cheap: only
|
||||||
|
-- projects with a non-empty customer_name participate, since reuse is
|
||||||
|
-- meaningless without it.
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
ALTER TABLE iace_projects
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_name TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_iace_projects_customer_name
|
||||||
|
ON iace_projects(customer_name)
|
||||||
|
WHERE customer_name IS NOT NULL AND customer_name <> '';
|
||||||
Reference in New Issue
Block a user