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 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user