From 5c5416dd101b6ce21d96f6c6bbfa85a1933fc057 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:24:34 -0700 Subject: [PATCH 01/10] feat(suppliers): track data gap time to fix per supplier Open one gap event per flagged missing KDE or failed CTE validation, close it only when a passing record for the same lot and CTE arrives, and store a receipt hash of the closing record. Adds a per supplier view with open gaps, median time to fix, oldest open gap and a 24 hour at risk flag citing 21 CFR 1.1455(c)(1). Migration included but not applied. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- docs/security/route-security-manifest.json | 17 +- .../components/gap-time-to-fix.test.tsx | 71 +++++ .../pages/suppliers-metrics.test.tsx | 1 + .../pages/suppliers-removal.test.tsx | 1 + .../dashboard/suppliers/gap-time-to-fix.tsx | 81 +++++ frontend/src/app/dashboard/suppliers/page.tsx | 19 ++ frontend/src/lib/api-client.ts | 6 + frontend/src/types/api.ts | 16 + .../admin/app/supplier_compliance_routes.py | 52 ++- services/ingestion/app/webhook_compat.py | 37 ++- .../ingestion/app/webhook_router_v2/routes.py | 59 ++++ services/shared/data_gap_tracking.py | 300 ++++++++++++++++++ .../shared/tests/test_data_gap_tracking.py | 260 +++++++++++++++ .../20261024060000_data_gap_time_to_fix.sql | 96 ++++++ 14 files changed, 1012 insertions(+), 4 deletions(-) create mode 100644 frontend/src/__tests__/components/gap-time-to-fix.test.tsx create mode 100644 frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx create mode 100644 services/shared/data_gap_tracking.py create mode 100644 services/shared/tests/test_data_gap_tracking.py create mode 100644 supabase/migrations/20261024060000_data_gap_time_to_fix.sql diff --git a/docs/security/route-security-manifest.json b/docs/security/route-security-manifest.json index 6b7a7828d..a4794c99f 100644 --- a/docs/security/route-security-manifest.json +++ b/docs/security/route-security-manifest.json @@ -3021,6 +3021,19 @@ "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", "test_adapter": "tenant_dual_session_csrf" }, + { + "operation": "admin:GET:/v1/supplier/gap-time-to-fix", + "owner": "admin", + "method": "GET", + "path": "/v1/supplier/gap-time-to-fix", + "classification": "tenant-scoped", + "authentication": "bearer-token-or-opaque-session-cookie", + "required_permissions": [ + "authenticated" + ], + "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", + "test_adapter": "tenant_dual_session_csrf" + }, { "operation": "admin:GET:/v1/supplier/gaps", "owner": "admin", @@ -4905,13 +4918,13 @@ } ], "summary": { - "enabled_operation_count": 254, + "enabled_operation_count": 255, "disabled_operation_count": 180, "disabled_unique_path_count": 163, "enabled_by_classification": { "global-sysadmin": 16, "public": 30, - "tenant-scoped": 201, + "tenant-scoped": 202, "workload-only": 7 } } diff --git a/frontend/src/__tests__/components/gap-time-to-fix.test.tsx b/frontend/src/__tests__/components/gap-time-to-fix.test.tsx new file mode 100644 index 000000000..0c53b0d3c --- /dev/null +++ b/frontend/src/__tests__/components/gap-time-to-fix.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { GapTimeToFix } from '@/app/dashboard/suppliers/gap-time-to-fix'; +import type { SupplierGapTimeToFix } from '@/types/api'; + +const NOW = Date.UTC(2026, 0, 2); +const HOUR_MS = 3_600_000; + +const summary = (overrides: Partial): SupplierGapTimeToFix => ({ + supplier_key: 'k', + open_gap_count: 0, + median_time_to_fix_seconds: null, + oldest_open_gap_opened_at: null, + at_risk: false, + has_data: true, + ...overrides, +}); + +describe('GapTimeToFix', () => { + it('shows No data when the supplier has no gap history', () => { + render(); + expect(screen.getByText('Gap time to fix: No data')).toBeInTheDocument(); + expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); + }); + + it('shows No data when the summary reports has_data false', () => { + render(); + expect(screen.getByText('Gap time to fix: No data')).toBeInTheDocument(); + }); + + it('cites 21 CFR 1.1455(c)(1) in the empty state', () => { + render(); + expect(screen.getByRole('link', { name: /21 CFR 1\.1455\(c\)\(1\)/ })).toHaveAttribute( + 'href', + 'https://www.ecfr.gov/current/title-21/section-1.1455', + ); + }); + + it('says unavailable instead of No data when the request failed', () => { + render(); + expect(screen.getByText('Gap time to fix unavailable')).toBeInTheDocument(); + expect(screen.queryByText(/No data/)).not.toBeInTheDocument(); + }); + + it('renders open count, median, oldest open gap, and the at risk badge', () => { + render( + , + ); + expect(screen.getByText('2 open gaps')).toBeInTheDocument(); + expect(screen.getByText('Median time to fix: 1 h 30 min')).toBeInTheDocument(); + expect(screen.getByText('Oldest open gap: 1 d 1 h')).toBeInTheDocument(); + expect(screen.getByText('At risk: gap open over 24 hours')).toBeInTheDocument(); + }); + + it('says No fixes yet when no gap has closed and hides the badge when not at risk', () => { + render(); + expect(screen.getByText('1 open gap')).toBeInTheDocument(); + expect(screen.getByText('Median time to fix: No fixes yet')).toBeInTheDocument(); + expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/NaN|undefined/); + }); +}); diff --git a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx index 8e09da6a4..9e468b2b9 100644 --- a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx @@ -22,6 +22,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceScore: mocks.score, getSupplierComplianceGaps: mocks.gaps, listSupplierTLCs: mocks.tlcs, + getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], at_risk_threshold_seconds: 86400, evaluated_at: '' }), }, })); diff --git a/frontend/src/__tests__/pages/suppliers-removal.test.tsx b/frontend/src/__tests__/pages/suppliers-removal.test.tsx index 60ce32178..54b1c423a 100644 --- a/frontend/src/__tests__/pages/suppliers-removal.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-removal.test.tsx @@ -24,6 +24,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceScore: () => Promise.resolve(null), getSupplierComplianceGaps: () => Promise.resolve({ gaps: [] }), listSupplierTLCs: () => Promise.resolve({ items: [] }), + getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], at_risk_threshold_seconds: 86400, evaluated_at: '' }), checkFacilityRemoval: mocks.checkRemoval, deleteSupplierFacility: mocks.deleteFacility, }, diff --git a/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx b/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx new file mode 100644 index 000000000..5d65d6dac --- /dev/null +++ b/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx @@ -0,0 +1,81 @@ +import { ExternalLink } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import type { SupplierGapTimeToFix } from '@/types/api'; + +const RECORDS_REQUEST_URL = 'https://www.ecfr.gov/current/title-21/section-1.1455'; + +/** Whole seconds to a short duration, largest two units. */ +function formatDuration(totalSeconds: number): string { + if (totalSeconds < 60) return 'under 1 min'; + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor((totalSeconds % 86_400) / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + if (days > 0) return hours > 0 ? `${days} d ${hours} h` : `${days} d`; + if (hours > 0) return minutes > 0 ? `${hours} h ${minutes} min` : `${hours} h`; + return `${minutes} min`; +} + +function openFor(openedAt: string | null, now: number): string | null { + if (!openedAt) return null; + const opened = Date.parse(openedAt); + if (Number.isNaN(opened)) return null; + return formatDuration(Math.max(0, (now - opened) / 1000)); +} + +interface GapTimeToFixProps { + summary: SupplierGapTimeToFix | undefined; + /** True when the time to fix request failed. */ + unavailable: boolean; + now: number; +} + +/** Per facility data gap time to fix: open gaps, median fix time, oldest open gap. */ +export function GapTimeToFix({ summary, unavailable, now }: GapTimeToFixProps) { + if (unavailable) { + return

Gap time to fix unavailable

; + } + + const citation = ( + + 21 CFR 1.1455(c)(1) + + ); + + if (!summary || !summary.has_data) { + return ( +
+ Gap time to fix: No data + {citation} +
+ ); + } + + const median = summary.median_time_to_fix_seconds; + const oldest = openFor(summary.oldest_open_gap_opened_at, now); + + return ( +
+ + {summary.open_gap_count} open gap{summary.open_gap_count !== 1 ? 's' : ''} + + Median time to fix: {median === null ? 'No fixes yet' : formatDuration(median)} + {oldest !== null && Oldest open gap: {oldest}} + {summary.at_risk && ( + + At risk: gap open over 24 hours + + )} + {citation} +
+ ); +} diff --git a/frontend/src/app/dashboard/suppliers/page.tsx b/frontend/src/app/dashboard/suppliers/page.tsx index 22bf62a40..f204284c2 100644 --- a/frontend/src/app/dashboard/suppliers/page.tsx +++ b/frontend/src/app/dashboard/suppliers/page.tsx @@ -48,7 +48,9 @@ import type { PortalLink, IntegrationProfile, FacilityRemovalCheck, + SupplierGapTimeToFix, } from '@/types/api'; +import { GapTimeToFix } from './gap-time-to-fix'; import { NO_COMPLIANCE_DATA, countOrNull, @@ -140,6 +142,8 @@ export default function SupplierDashboardPage() { const [error, setError] = useState(null); const [workbenchSummary, setWorkbenchSummary] = useState(null); const [now, setNow] = useState(Date.now); + const [gapTimeToFix, setGapTimeToFix] = useState>({}); + const [gapTimeToFixUnavailable, setGapTimeToFixUnavailable] = useState(false); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); @@ -226,6 +230,16 @@ export default function SupplierDashboardPage() { .then((res) => setIntegrationProfiles(res.profiles || [])) .catch(() => setIntegrationProfiles([])); + apiClient.getSupplierGapTimeToFix() + .then((res) => { + setGapTimeToFix(Object.fromEntries(res.suppliers.map((s) => [s.supplier_key, s]))); + setGapTimeToFixUnavailable(false); + }) + .catch(() => { + setGapTimeToFix({}); + setGapTimeToFixUnavailable(true); + }); + if (tenantId) { fetchWorkbenchReadinessSummary(tenantId) .then(setWorkbenchSummary) @@ -865,6 +879,11 @@ export default function SupplierDashboardPage() { )} + {comp.gaps.length > 0 && (
    {comp.gaps.map((gap, index) => { diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 01988f06a..fd02334d6 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -28,6 +28,7 @@ import type { SupplierCTEEventResponse, SupplierComplianceGapsResponse, SupplierComplianceScore, + SupplierGapTimeToFixResponse, SupplierBulkUploadCommitResponse, SupplierBulkUploadParseResponse, SupplierBulkUploadStatusResponse, @@ -871,6 +872,11 @@ class APIClient { return data; } + async getSupplierGapTimeToFix(): Promise { + const { data } = await this.adminClient.get('/v1/supplier/gap-time-to-fix'); + return data; + } + async getSupplierFDAExportPreview( facilityId?: string, limit: number = 25, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d830d4189..52846cbf0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -490,6 +490,22 @@ export interface SupplierComplianceGapsResponse { evaluated_at: string; } +export interface SupplierGapTimeToFix { + /** Supplier facility id. */ + supplier_key: string; + open_gap_count: number; + median_time_to_fix_seconds: number | null; + oldest_open_gap_opened_at: string | null; + at_risk: boolean; + has_data: boolean; +} + +export interface SupplierGapTimeToFixResponse { + suppliers: SupplierGapTimeToFix[]; + at_risk_threshold_seconds: number; + evaluated_at: string; +} + export interface SupplierFDAExportRow { event_id: string; tlc_code: string; diff --git a/services/admin/app/supplier_compliance_routes.py b/services/admin/app/supplier_compliance_routes.py index f17fed22d..7527994b6 100644 --- a/services/admin/app/supplier_compliance_routes.py +++ b/services/admin/app/supplier_compliance_routes.py @@ -13,12 +13,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from shared.data_gap_tracking import AT_RISK_THRESHOLD, fetch_supplier_summaries +from sqlalchemy import select from sqlalchemy.orm import Session from .database import get_session from .dependencies import get_current_user from .models import TenantContext -from .sqlalchemy_models import UserModel +from .sqlalchemy_models import SupplierFacilityModel, UserModel from .supplier_onboarding_routes import ( SupplierComplianceGap, SupplierComplianceGapsResponse, @@ -116,6 +119,53 @@ async def get_compliance_gaps( ) +class SupplierGapTimeToFix(BaseModel): + supplier_key: str + open_gap_count: int + median_time_to_fix_seconds: float | None + oldest_open_gap_opened_at: str | None + at_risk: bool + has_data: bool + + +class SupplierGapTimeToFixResponse(BaseModel): + suppliers: list[SupplierGapTimeToFix] + at_risk_threshold_seconds: int + evaluated_at: str + + +@router.get("/gap-time-to-fix", response_model=SupplierGapTimeToFixResponse) +async def get_gap_time_to_fix( + current_user: UserModel = Depends(get_current_user), + db: Session = Depends(get_session), +) -> SupplierGapTimeToFixResponse: + """Per facility data gap time to fix for the current supplier's facilities. + + Scoped to the tenant and to facilities owned by the calling user; RLS on + ``public.data_gap_events`` enforces the tenant boundary as well. + """ + tenant_id = TenantContext.get_tenant_context(db) + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant context required") + + facility_ids = [ + str(facility_id) + for facility_id in db.scalars( + select(SupplierFacilityModel.id).where( + SupplierFacilityModel.tenant_id == tenant_id, + SupplierFacilityModel.supplier_user_id == current_user.id, + ) + ).all() + ] + now = datetime.now(timezone.utc) + summaries = fetch_supplier_summaries(db, str(tenant_id), facility_ids, now) + return SupplierGapTimeToFixResponse( + suppliers=[SupplierGapTimeToFix(supplier_key=key, **summary) for key, summary in summaries.items()], + at_risk_threshold_seconds=int(AT_RISK_THRESHOLD.total_seconds()), + evaluated_at=now.isoformat(), + ) + + @router.get("/export/fda-records/preview", response_model=SupplierFDAExportPreviewResponse) async def preview_fda_records_export( facility_id: str | None = None, diff --git a/services/ingestion/app/webhook_compat.py b/services/ingestion/app/webhook_compat.py index 308f42e4b..71cb3964b 100644 --- a/services/ingestion/app/webhook_compat.py +++ b/services/ingestion/app/webhook_compat.py @@ -25,6 +25,12 @@ from fastapi import Depends, Header, HTTPException, Request from shared.api_key_store import APIKeyResponse from shared.auth import APIKey, require_api_key +from shared.data_gap_tracking import ( + ValidationOutcome, + gap_from_rule_reason, + gaps_from_kde_errors, + record_outcomes, +) from shared.database import get_db_session as _get_db_session from shared.permissions import has_permission from shared.tenant_context import TenantContext, resolve_tenant_context @@ -297,13 +303,15 @@ async def ingest_events( accepted = 0 rejected = 0 seen_in_batch: set[str] = set() + # Data gap time to fix: written after the ingest commit, fail-open. + gap_outcomes: list[ValidationOutcome] = [] try: if source_file is not None: # The receipt is written first so a run exists even if every row # is rejected; the same transaction carries run, blob and events. run_id = _open_ingestion_run(db_session, tenant_id, payload.source, source_file) - for event in payload.events: + for event_index, event in enumerate(payload.events): dedup_key = _batch_dedup_key(event) if dedup_key in seen_in_batch: results.append( @@ -329,6 +337,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_index, + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + gaps=gaps_from_kde_errors(errors), + ) + ) continue alerts = _generate_alerts(event) @@ -457,6 +473,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_index, + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + gaps=(gap_from_rule_reason(reject_reason),), + ) + ) continue # Accepted path. Savepoint commits the primary CTE write. @@ -472,6 +496,15 @@ async def ingest_events( ) ) accepted += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_index, + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + closing_record_id=store_result.event_id, + closing_record_hash=store_result.sha256_hash, + ) + ) _publish_graph_sync(store_result.event_id, event, tenant_id) # Non-blocking failures → exception queue. Separate @@ -522,6 +555,8 @@ async def ingest_events( ) if db_session: db_session.commit() + # After the commit so closes reference durable records; never raises. + record_outcomes(db_session, tenant_id, gap_outcomes) except Exception: if db_session: db_session.rollback() diff --git a/services/ingestion/app/webhook_router_v2/routes.py b/services/ingestion/app/webhook_router_v2/routes.py index 03c7eee36..bb8902e7c 100644 --- a/services/ingestion/app/webhook_router_v2/routes.py +++ b/services/ingestion/app/webhook_router_v2/routes.py @@ -20,6 +20,12 @@ from typing import Optional from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request +from shared.data_gap_tracking import ( + ValidationOutcome, + gap_from_rule_reason, + gaps_from_kde_errors, + record_outcomes, +) from shared.database import get_db_session from shared.idempotency import IdempotencyDependency from sqlalchemy.exc import SQLAlchemyError @@ -141,6 +147,11 @@ async def ingest_events( # Batch deduplication — detect identical events within the same payload seen_in_batch: set[str] = set() + # Data gap time to fix: validation outcomes queued during the request and + # written after the ingest commit (fail-open, never blocks ingest). + gap_outcomes: list[ValidationOutcome] = [] + event_order = {id(e): i for i, e in enumerate(payload.events)} + # Get persistence layer from injected db_session (Depends(get_db_session)) persistence = None if db_session is None: @@ -215,6 +226,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(event)], + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + gaps=gaps_from_kde_errors(errors), + ) + ) continue # Generate alerts @@ -250,6 +269,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(_event)], + lot_code=_event.traceability_lot_code, + cte_type=_event.cte_type.value, + gaps=(gap_from_rule_reason(_reason),), + ) + ) continue _filtered.append((_event, _alerts)) pre_eval_summaries.append(_summary) @@ -292,6 +319,7 @@ async def ingest_events( # recorded above. _pre_batch_results_len = len(results) _pre_batch_accepted = accepted + _pre_batch_gap_outcomes_len = len(gap_outcomes) try: store_results = persistence.store_events_batch( @@ -314,6 +342,15 @@ async def ingest_events( accepted += 1 # Billed below, only after db_session.commit() succeeds. billable_event_ids.append(store_result.event_id) + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(event)], + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + closing_record_id=store_result.event_id, + closing_record_hash=store_result.sha256_hash, + ) + ) # Tenant-configured obligation-CTE rules (#2901). Runs # after the store because it keys its fsma.compliance_alerts @@ -385,6 +422,7 @@ async def ingest_events( # for the same physical label -- bypassing the # UNIQUE(tenant_id, cte_event_id) idempotency guard entirely. del results[_pre_batch_results_len:] + del gap_outcomes[_pre_batch_gap_outcomes_len:] accepted = _pre_batch_accepted billable_event_ids.clear() # Fall back to per-event persistence @@ -407,6 +445,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(event)], + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + gaps=(gap_from_rule_reason(_fb_reason),), + ) + ) continue # Each event gets its own savepoint (same pattern as the # webhook_compat v1 path) so one event's storage failure @@ -467,6 +513,15 @@ async def ingest_events( # releases the nested savepoint; it does not make # this row durable against a later outer rollback. billable_event_ids.append(store_result.event_id) + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(event)], + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + closing_record_id=store_result.event_id, + closing_record_hash=store_result.sha256_hash, + ) + ) _pkg._publish_graph_sync(store_result.event_id, event, tenant_id) try: @@ -524,6 +579,10 @@ async def ingest_events( if db_session: db_session.commit() + # Data gap time to fix. After the commit so closes reference durable + # records; record_outcomes catches and logs its own errors. + record_outcomes(db_session, tenant_id, gap_outcomes) + except Exception as e: if db_session: db_session.rollback() diff --git a/services/shared/data_gap_tracking.py b/services/shared/data_gap_tracking.py new file mode 100644 index 000000000..a09ffb67c --- /dev/null +++ b/services/shared/data_gap_tracking.py @@ -0,0 +1,300 @@ +"""Data gap time to fix. + +A *gap* is a validation failure on a traceability lot: a required KDE missing +from a CTE record (``missing_kde``) or a CTE record that failed rule +validation (``cte_validation_failed``). Gaps live in ``public.data_gap_events`` +(``supabase/migrations/20261024060000_data_gap_time_to_fix.sql``). + +State transitions, per (tenant, lot_code, cte_type): + +* a record that fails validation opens one gap per failure, idempotently + (``ON CONFLICT DO NOTHING`` on the partial unique index over open rows), and + never closes anything; +* a record that passes validation and is persisted closes every open gap for + that lot and CTE, stamping the closing record id and its canonical SHA-256 + (the persisted ledger hash) as the receipt. + +Gap logging is observability. It must never block or roll back ingest, so the +writer catches every error, logs it, and returns. +""" + +from __future__ import annotations + +import logging +import re +import statistics +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any, Iterable, Optional, Sequence + +from sqlalchemy import text + +logger = logging.getLogger("data-gap-tracking") + +GAP_KIND_MISSING_KDE = "missing_kde" +GAP_KIND_CTE_VALIDATION_FAILED = "cte_validation_failed" +UNASSIGNED_SUPPLIER_KEY = "unassigned" + +# 21 CFR 1.1455(c)(1): records must reach FDA within 24 hours of a request. +AT_RISK_THRESHOLD = timedelta(hours=24) + +_MISSING_KDE_RE = re.compile(r"Missing required KDE '([^']+)'") +_HARVESTER_PHONE_ERROR = "Harvester business phone must be a non-empty string" +_RECEIPT_HASH_RE = re.compile(r"^[0-9a-f]{64}$") + + +@dataclass(frozen=True) +class Gap: + gap_kind: str + gap_key: str + + +@dataclass(frozen=True) +class ValidationOutcome: + """One record's validation result, queued for the gap writer. + + ``order`` is the record's position in the request so a fail then pass for + the same lot and CTE in one batch is applied in arrival order. + """ + + order: int + lot_code: str + cte_type: str + gaps: tuple[Gap, ...] = field(default_factory=tuple) + closing_record_id: Optional[str] = None + closing_record_hash: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Pure functions +# --------------------------------------------------------------------------- + + +def gaps_from_kde_errors(errors: Iterable[str]) -> tuple[Gap, ...]: + """Map ``_validate_event_kdes`` messages to missing KDE gaps. + + An unrecognised message still opens a gap (keyed on the message) so a new + validator message is never silently dropped from time to fix. + """ + gaps: list[Gap] = [] + for error in errors: + match = _MISSING_KDE_RE.search(error) + if match: + key = match.group(1) + elif error == _HARVESTER_PHONE_ERROR: + key = "harvester_business_phone" + else: + key = error.strip() + gap = Gap(GAP_KIND_MISSING_KDE, key) + if key and gap not in gaps: + gaps.append(gap) + return tuple(gaps) + + +def gap_from_rule_reason(reason: Optional[str]) -> Gap: + """Map a rules pre-eval reject reason (``": "``) to a gap keyed on rule id.""" + rule_id = (reason or "").split(":", 1)[0].strip() + return Gap(GAP_KIND_CTE_VALIDATION_FAILED, rule_id or "rule_violation") + + +def gap_identity( + tenant_id: str, + supplier_key: str, + lot_code: str, + cte_type: str, + gap: Gap, +) -> tuple[str, str, str, str, str, str]: + """The open gap idempotency key, column for column with ``uq_data_gap_events_open``.""" + return ( + str(tenant_id).strip().lower(), + supplier_key.strip(), + lot_code.strip(), + cte_type.strip().lower(), + gap.gap_kind, + gap.gap_key.strip(), + ) + + +def decide_transition(gaps: Sequence[Gap]) -> str: + """``"open"`` when the record failed validation, ``"close"`` only when it passed.""" + return "open" if gaps else "close" + + +def is_receipt_hash(value: Optional[str]) -> bool: + """True for a lowercase SHA-256 hex digest, the only accepted closing receipt.""" + return bool(value) and _RECEIPT_HASH_RE.match(value) is not None + + +def median_seconds(durations: Sequence[float]) -> Optional[float]: + """Median of fix durations in seconds; None when nothing has been fixed yet.""" + if not durations: + return None + return float(statistics.median(durations)) + + +def oldest_opened_at(opened_ats: Sequence[datetime]) -> Optional[datetime]: + return min(opened_ats) if opened_ats else None + + +def is_at_risk( + open_opened_ats: Sequence[datetime], + now: datetime, + threshold: timedelta = AT_RISK_THRESHOLD, +) -> bool: + """True when any open gap has been open strictly longer than ``threshold``.""" + return any(now - opened > threshold for opened in open_opened_ats) + + +def summarize_supplier( + open_opened_ats: Sequence[datetime], + fix_durations_seconds: Sequence[float], + now: datetime, +) -> dict[str, Any]: + oldest = oldest_opened_at(open_opened_ats) + return { + "open_gap_count": len(open_opened_ats), + "median_time_to_fix_seconds": median_seconds(fix_durations_seconds), + "oldest_open_gap_opened_at": oldest.isoformat() if oldest else None, + "at_risk": is_at_risk(open_opened_ats, now), + "has_data": bool(open_opened_ats) or bool(fix_durations_seconds), + } + + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- + +_OPEN_GAP_SQL = text( + """ + INSERT INTO public.data_gap_events + (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) + SELECT + CAST(:tid AS uuid), + COALESCE( + (SELECT l.facility_id::text + FROM public.supplier_traceability_lots l + WHERE l.tenant_id = CAST(:tid AS uuid) + AND l.tlc_code = :lot + LIMIT 1), + :unassigned + ), + :lot, :cte, :kind, :key + ON CONFLICT (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) + WHERE closed_at IS NULL + DO NOTHING + """ +) + +_CLOSE_GAPS_SQL = text( + """ + UPDATE public.data_gap_events + SET closed_at = GREATEST(now(), opened_at), + closing_record_id = CAST(:rid AS uuid), + closing_record_hash = :rhash + WHERE tenant_id = CAST(:tid AS uuid) + AND lot_code = :lot + AND cte_type = :cte + AND closed_at IS NULL + """ +) + + +def _apply_outcome(db_session: Any, tenant_id: str, outcome: ValidationOutcome) -> None: + lot_code = outcome.lot_code.strip() + cte_type = outcome.cte_type.strip().lower() + if decide_transition(outcome.gaps) == "open": + for gap in outcome.gaps: + db_session.execute( + _OPEN_GAP_SQL, + { + "tid": tenant_id, + "lot": lot_code, + "cte": cte_type, + "kind": gap.gap_kind, + "key": gap.gap_key.strip(), + "unassigned": UNASSIGNED_SUPPLIER_KEY, + }, + ) + return + if not outcome.closing_record_id or not is_receipt_hash(outcome.closing_record_hash): + # No receipt, no close: a gap must never close without proof. + logger.warning("data_gap_close_skipped_missing_receipt tenant=%s", tenant_id) + return + db_session.execute( + _CLOSE_GAPS_SQL, + { + "tid": tenant_id, + "lot": lot_code, + "cte": cte_type, + "rid": outcome.closing_record_id, + "rhash": outcome.closing_record_hash, + }, + ) + + +def record_outcomes(db_session: Any, tenant_id: str, outcomes: Sequence[ValidationOutcome]) -> None: + """Apply queued outcomes in arrival order, then commit. Never raises. + + Call after the ingest transaction has committed: closes then reference + durable records, and a failure here cannot roll back accepted events. + Each outcome gets its own savepoint so one bad row does not drop the rest. + """ + if db_session is None or not outcomes: + return + try: + for outcome in sorted(outcomes, key=lambda o: o.order): + savepoint = db_session.begin_nested() + try: + _apply_outcome(db_session, tenant_id, outcome) + savepoint.commit() + except Exception as exc: # noqa: BLE001 -- gap logging is fail-open + savepoint.rollback() + logger.warning( + "data_gap_write_failed tenant=%s error_type=%s error=%s", + tenant_id, + type(exc).__name__, + exc, + ) + db_session.commit() + except Exception as exc: # noqa: BLE001 -- gap logging must never block ingest + logger.warning( + "data_gap_record_failed tenant=%s error_type=%s error=%s", + tenant_id, + type(exc).__name__, + exc, + ) + try: + db_session.rollback() + except Exception: # noqa: BLE001 + logger.warning("data_gap_rollback_failed tenant=%s", tenant_id) + + +_SUMMARY_ROWS_SQL = text( + """ + SELECT supplier_key, + opened_at, + EXTRACT(EPOCH FROM (closed_at - opened_at)) AS fix_seconds + FROM public.data_gap_events + WHERE tenant_id = CAST(:tid AS uuid) + AND supplier_key = ANY(:keys) + """ +) + + +def fetch_supplier_summaries( + db_session: Any, + tenant_id: str, + supplier_keys: Sequence[str], + now: datetime, +) -> dict[str, dict[str, Any]]: + """Per supplier time to fix summary for ``supplier_keys`` (every key is present).""" + open_by_key: dict[str, list[datetime]] = {key: [] for key in supplier_keys} + fixed_by_key: dict[str, list[float]] = {key: [] for key in supplier_keys} + if supplier_keys: + rows = db_session.execute(_SUMMARY_ROWS_SQL, {"tid": tenant_id, "keys": list(supplier_keys)}).fetchall() + for supplier_key, opened_at, fix_seconds in rows: + if fix_seconds is None: + open_by_key[supplier_key].append(opened_at) + else: + fixed_by_key[supplier_key].append(float(fix_seconds)) + return {key: summarize_supplier(open_by_key[key], fixed_by_key[key], now) for key in supplier_keys} diff --git a/services/shared/tests/test_data_gap_tracking.py b/services/shared/tests/test_data_gap_tracking.py new file mode 100644 index 000000000..f4bdee5a2 --- /dev/null +++ b/services/shared/tests/test_data_gap_tracking.py @@ -0,0 +1,260 @@ +"""Data gap time to fix: pure logic and writer state transitions. + +No database. The writer runs against a recording fake session so the tests +check which statements a validation outcome produces, not their effect. +""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timedelta, timezone + +from shared.data_gap_tracking import ( + AT_RISK_THRESHOLD, + GAP_KIND_CTE_VALIDATION_FAILED, + GAP_KIND_MISSING_KDE, + Gap, + ValidationOutcome, + decide_transition, + gap_from_rule_reason, + gap_identity, + gaps_from_kde_errors, + is_at_risk, + is_receipt_hash, + median_seconds, + oldest_opened_at, + record_outcomes, + summarize_supplier, +) + +NOW = datetime(2026, 1, 2, tzinfo=timezone.utc) +RECEIPT = hashlib.sha256(b"x").hexdigest() + + +# --------------------------------------------------------------------------- +# Median / oldest / at risk +# --------------------------------------------------------------------------- + + +def test_median_empty_is_none(): + assert median_seconds([]) is None + + +def test_median_odd_count(): + assert median_seconds([30.0, 10.0, 20.0]) == 20.0 + + +def test_median_even_count_averages_middle_pair(): + # Matches percentile_cont(0.5): interpolates between the middle values. + assert median_seconds([1.0, 2.0, 3.0, 10.0]) == 2.5 + + +def test_oldest_opened_at(): + assert oldest_opened_at([]) is None + assert oldest_opened_at([NOW, NOW - timedelta(hours=1)]) == NOW - timedelta(hours=1) + + +def test_at_risk_threshold_is_24_hours(): + assert AT_RISK_THRESHOLD == timedelta(hours=24) + + +def test_gap_open_exactly_24_hours_is_not_at_risk(): + assert is_at_risk([NOW - timedelta(hours=24)], NOW) is False + + +def test_gap_open_just_over_24_hours_is_at_risk(): + assert is_at_risk([NOW - timedelta(hours=24, microseconds=1)], NOW) is True + + +def test_no_open_gaps_is_not_at_risk(): + assert is_at_risk([], NOW) is False + + +def test_summary_without_history_has_no_data(): + assert summarize_supplier([], [], NOW) == { + "open_gap_count": 0, + "median_time_to_fix_seconds": None, + "oldest_open_gap_opened_at": None, + "at_risk": False, + "has_data": False, + } + + +def test_summary_with_only_closed_gaps_has_data(): + result = summarize_supplier([], [60.0], NOW) + assert result["has_data"] is True + assert result["open_gap_count"] == 0 + assert result["median_time_to_fix_seconds"] == 60.0 + + +def test_summary_with_open_gaps(): + opened = NOW - timedelta(hours=30) + result = summarize_supplier([opened, NOW], [], NOW) + assert result["open_gap_count"] == 2 + assert result["oldest_open_gap_opened_at"] == opened.isoformat() + assert result["at_risk"] is True + + +# --------------------------------------------------------------------------- +# Gap extraction and idempotency key +# --------------------------------------------------------------------------- + + +def test_missing_kde_errors_map_to_kde_names(): + errors = ["Missing required KDE 'a' for b CTE", "Missing required KDE 'c' for b CTE"] + assert gaps_from_kde_errors(errors) == (Gap(GAP_KIND_MISSING_KDE, "a"), Gap(GAP_KIND_MISSING_KDE, "c")) + + +def test_harvester_phone_error_maps_to_kde_name(): + assert gaps_from_kde_errors(["Harvester business phone must be a non-empty string"]) == ( + Gap(GAP_KIND_MISSING_KDE, "harvester_business_phone"), + ) + + +def test_duplicate_kde_errors_collapse(): + errors = ["Missing required KDE 'a' for b CTE"] * 2 + assert gaps_from_kde_errors(errors) == (Gap(GAP_KIND_MISSING_KDE, "a"),) + + +def test_unrecognised_error_still_opens_a_gap(): + assert gaps_from_kde_errors(["z"]) == (Gap(GAP_KIND_MISSING_KDE, "z"),) + + +def test_rule_reason_keys_on_rule_id(): + assert gap_from_rule_reason("r1: why") == Gap(GAP_KIND_CTE_VALIDATION_FAILED, "r1") + + +def test_rule_reason_without_id_falls_back(): + assert gap_from_rule_reason(None) == Gap(GAP_KIND_CTE_VALIDATION_FAILED, "rule_violation") + assert gap_from_rule_reason("") == Gap(GAP_KIND_CTE_VALIDATION_FAILED, "rule_violation") + + +def test_gap_identity_is_deterministic_and_normalized(): + gap = Gap(GAP_KIND_MISSING_KDE, "a") + first = gap_identity("T", " s ", " l ", "Shipping", gap) + assert first == gap_identity("t", "s", "l", "shipping", gap) + assert first == ("t", "s", "l", "shipping", GAP_KIND_MISSING_KDE, "a") + + +def test_gap_identity_differs_by_kind_and_key(): + base = gap_identity("t", "s", "l", "c", Gap(GAP_KIND_MISSING_KDE, "a")) + assert base != gap_identity("t", "s", "l", "c", Gap(GAP_KIND_CTE_VALIDATION_FAILED, "a")) + assert base != gap_identity("t", "s", "l", "c", Gap(GAP_KIND_MISSING_KDE, "b")) + + +# --------------------------------------------------------------------------- +# State transitions +# --------------------------------------------------------------------------- + + +def test_failing_record_opens_and_passing_record_closes(): + assert decide_transition([Gap(GAP_KIND_MISSING_KDE, "a")]) == "open" + assert decide_transition([]) == "close" + + +def test_receipt_hash_validation(): + assert is_receipt_hash(RECEIPT) is True + assert is_receipt_hash(RECEIPT.upper()) is False + assert is_receipt_hash(RECEIPT[:-1]) is False + assert is_receipt_hash(None) is False + assert is_receipt_hash("") is False + + +class _Savepoint: + def __init__(self, log: list[str]): + self._log = log + + def commit(self): + self._log.append("savepoint_commit") + + def rollback(self): + self._log.append("savepoint_rollback") + + +class _RecordingSession: + def __init__(self, fail_on: str | None = None): + self.log: list[str] = [] + self.statements: list[tuple[str, dict]] = [] + self._fail_on = fail_on + + def begin_nested(self): + return _Savepoint(self.log) + + def execute(self, statement, params): + sql = str(statement).strip().split()[0].upper() + if self._fail_on == sql: + raise RuntimeError("boom") + self.statements.append((sql, params)) + + def commit(self): + self.log.append("commit") + + def rollback(self): + self.log.append("rollback") + + +def _kinds(session: _RecordingSession) -> list[str]: + return [sql for sql, _ in session.statements] + + +def test_failing_outcome_only_inserts_and_never_closes(): + session = _RecordingSession() + outcome = ValidationOutcome( + order=0, + lot_code="l", + cte_type="c", + gaps=(Gap(GAP_KIND_MISSING_KDE, "a"), Gap(GAP_KIND_MISSING_KDE, "b")), + ) + record_outcomes(session, "t", [outcome]) + assert _kinds(session) == ["INSERT", "INSERT"] + assert session.log[-1] == "commit" + + +def test_passing_outcome_closes_with_unmodified_receipt(): + session = _RecordingSession() + outcome = ValidationOutcome(order=0, lot_code="l", cte_type="c", closing_record_id="r", closing_record_hash=RECEIPT) + record_outcomes(session, "t", [outcome]) + assert _kinds(session) == ["UPDATE"] + params = session.statements[0][1] + assert params["rhash"] == RECEIPT + assert params["rid"] == "r" + assert (params["lot"], params["cte"]) == ("l", "c") + + +def test_passing_outcome_without_receipt_does_not_close(): + session = _RecordingSession() + record_outcomes(session, "t", [ValidationOutcome(order=0, lot_code="l", cte_type="c", closing_record_id="r")]) + assert _kinds(session) == [] + + +def test_outcomes_apply_in_arrival_order(): + session = _RecordingSession() + passed = ValidationOutcome(order=1, lot_code="l", cte_type="c", closing_record_id="r", closing_record_hash=RECEIPT) + failed = ValidationOutcome(order=0, lot_code="l", cte_type="c", gaps=(Gap(GAP_KIND_MISSING_KDE, "a"),)) + record_outcomes(session, "t", [passed, failed]) + assert _kinds(session) == ["INSERT", "UPDATE"] + + +def test_write_errors_are_swallowed_per_outcome(): + session = _RecordingSession(fail_on="INSERT") + failed = ValidationOutcome(order=0, lot_code="l", cte_type="c", gaps=(Gap(GAP_KIND_MISSING_KDE, "a"),)) + passed = ValidationOutcome(order=1, lot_code="l", cte_type="c", closing_record_id="r", closing_record_hash=RECEIPT) + record_outcomes(session, "t", [failed, passed]) + assert "savepoint_rollback" in session.log + assert _kinds(session) == ["UPDATE"] + assert session.log[-1] == "commit" + + +def test_commit_failure_never_raises(): + class _CommitFails(_RecordingSession): + def commit(self): + raise RuntimeError("boom") + + session = _CommitFails() + failed = ValidationOutcome(order=0, lot_code="l", cte_type="c", gaps=(Gap(GAP_KIND_MISSING_KDE, "a"),)) + record_outcomes(session, "t", [failed]) + assert session.log[-1] == "rollback" + + +def test_no_session_is_a_no_op(): + record_outcomes(None, "t", [ValidationOutcome(order=0, lot_code="l", cte_type="c")]) diff --git a/supabase/migrations/20261024060000_data_gap_time_to_fix.sql b/supabase/migrations/20261024060000_data_gap_time_to_fix.sql new file mode 100644 index 000000000..da6c181a2 --- /dev/null +++ b/supabase/migrations/20261024060000_data_gap_time_to_fix.sql @@ -0,0 +1,96 @@ +-- Data gap time to fix. +-- +-- One row per data gap on a traceability lot: a required KDE missing from a +-- CTE record, or a CTE record that failed rule validation. A gap opens when a +-- record for (lot_code, cte_type) fails validation and closes when a later +-- record for the same lot_code and cte_type passes. The closing record's id and +-- its canonical SHA-256 (the persisted ledger hash) are kept as the receipt. +-- +-- supplier_key is the supplier facility id (public.supplier_traceability_lots +-- maps each tenant TLC to one facility) or 'unassigned' when the lot is not +-- registered to a facility. +-- +-- Idempotent: safe to re-run. + +create table if not exists public.data_gap_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null, + supplier_key text not null, + lot_code text not null, + cte_type text not null, + gap_kind text not null, + gap_key text not null, + opened_at timestamptz not null default now(), + closed_at timestamptz, + closing_record_id uuid, + closing_record_hash text, + constraint data_gap_events_gap_kind_check + check (gap_kind in ('missing_kde', 'cte_validation_failed')), + -- An open gap carries no receipt; a closed gap always carries one. + constraint data_gap_events_close_receipt_check + check ( + (closed_at is null and closing_record_id is null and closing_record_hash is null) + or ( + closed_at is not null + and closed_at >= opened_at + and closing_record_hash ~ '^[0-9a-f]{64}$' + ) + ) +); + +-- Exactly one open row per gap identity. Closed rows are history and may repeat. +create unique index if not exists uq_data_gap_events_open + on public.data_gap_events (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) + where closed_at is null; + +-- Close path: every open gap for a tenant lot and CTE, regardless of supplier. +create index if not exists idx_data_gap_events_open_lot + on public.data_gap_events (tenant_id, lot_code, cte_type) + where closed_at is null; + +-- Read path: per supplier summary. +create index if not exists idx_data_gap_events_supplier + on public.data_gap_events (tenant_id, supplier_key); + +alter table public.data_gap_events enable row level security; +alter table public.data_gap_events force row level security; + +-- CASE form, not OR: public.get_tenant_context() raises when app.tenant_id is +-- unset and Postgres does not order OR operands, so the sysadmin arm is tested +-- first (same predicate shape as alembic v096 and v134). +drop policy if exists tenant_isolation_data_gap_events on public.data_gap_events; +create policy tenant_isolation_data_gap_events on public.data_gap_events + for all + using ( + case + when (select current_setting('regengine.is_sysadmin', true)) = 'true' + and current_user = 'regengine_sysadmin' then true + else tenant_id = (select public.get_tenant_context()) + end + ) + with check ( + case + when (select current_setting('regengine.is_sysadmin', true)) = 'true' + and current_user = 'regengine_sysadmin' then true + else tenant_id = (select public.get_tenant_context()) + end + ); + +-- Runtime role allowlist (alembic v111): new tables are inaccessible to the +-- app role until a migration names them. No DELETE: gap history is evidence. +-- Not exposed through the Supabase Data API; the backend reads and writes it. +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'anon') then + revoke all on public.data_gap_events from anon; + end if; + if exists (select 1 from pg_roles where rolname = 'authenticated') then + revoke all on public.data_gap_events from authenticated; + end if; + if exists (select 1 from pg_roles where rolname = 'regengine') then + grant select, insert, update on public.data_gap_events to regengine; + end if; + if exists (select 1 from pg_roles where rolname = 'regengine_sysadmin') then + grant select on public.data_gap_events to regengine_sysadmin; + end if; +end $$; From 50b49e5cd3caba447286f31770ce62f8a062fd4e Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:43:55 -0700 Subject: [PATCH 02/10] feat(alembic): add v135 data_gap_events revision and redact gap write errors Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- ..._41f96588d87c_v135_data_gap_time_to_fix.py | 135 ++++++++++++++++++ services/shared/data_gap_tracking.py | 9 +- 2 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/20261024_41f96588d87c_v135_data_gap_time_to_fix.py diff --git a/alembic/versions/20261024_41f96588d87c_v135_data_gap_time_to_fix.py b/alembic/versions/20261024_41f96588d87c_v135_data_gap_time_to_fix.py new file mode 100644 index 000000000..a72445c1f --- /dev/null +++ b/alembic/versions/20261024_41f96588d87c_v135_data_gap_time_to_fix.py @@ -0,0 +1,135 @@ +"""Data gap time to fix: public.data_gap_events. + +Mirrors supabase/migrations/20261024060000_data_gap_time_to_fix.sql exactly: +same table, columns, constraints, indexes, RLS policy and grants. One row per +data gap on a traceability lot (a missing KDE or a failed CTE validation). A +partial unique index allows exactly one open row per gap identity. A closed +gap carries the closing record id and its SHA-256 as the receipt. + +Additive and idempotent (IF NOT EXISTS, drop-if-exists-then-create policy, +role-guarded grants), so the plain SQL file can run ahead of it. Downgrade +drops only what this revision created. + +Revision ID: 41f96588d87c +Revises: 1faec7148e78 +Create Date: 2026-10-24 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "41f96588d87c" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "1faec7148e78" # pragma: allowlist secret +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS: tuple[str, ...] = ( + """ +create table if not exists public.data_gap_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null, + supplier_key text not null, + lot_code text not null, + cte_type text not null, + gap_kind text not null, + gap_key text not null, + opened_at timestamptz not null default now(), + closed_at timestamptz, + closing_record_id uuid, + closing_record_hash text, + constraint data_gap_events_gap_kind_check + check (gap_kind in ('missing_kde', 'cte_validation_failed')), + -- An open gap carries no receipt; a closed gap always carries one. + constraint data_gap_events_close_receipt_check + check ( + (closed_at is null and closing_record_id is null and closing_record_hash is null) + or ( + closed_at is not null + and closed_at >= opened_at + and closing_record_hash ~ '^[0-9a-f]{64}$' + ) + ) +); +""", + """ +-- Exactly one open row per gap identity. Closed rows are history and may repeat. +create unique index if not exists uq_data_gap_events_open + on public.data_gap_events (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) + where closed_at is null; +""", + """ +-- Close path: every open gap for a tenant lot and CTE, regardless of supplier. +create index if not exists idx_data_gap_events_open_lot + on public.data_gap_events (tenant_id, lot_code, cte_type) + where closed_at is null; +""", + """ +-- Read path: per supplier summary. +create index if not exists idx_data_gap_events_supplier + on public.data_gap_events (tenant_id, supplier_key); +""", + """ +alter table public.data_gap_events enable row level security; +""", + """ +alter table public.data_gap_events force row level security; +""", + """ +-- CASE form, not OR: public.get_tenant_context() raises when app.tenant_id is +-- unset and Postgres does not order OR operands, so the sysadmin arm is tested +-- first (same predicate shape as alembic v096 and v134). +drop policy if exists tenant_isolation_data_gap_events on public.data_gap_events; +""", + """ +create policy tenant_isolation_data_gap_events on public.data_gap_events + for all + using ( + case + when (select current_setting('regengine.is_sysadmin', true)) = 'true' + and current_user = 'regengine_sysadmin' then true + else tenant_id = (select public.get_tenant_context()) + end + ) + with check ( + case + when (select current_setting('regengine.is_sysadmin', true)) = 'true' + and current_user = 'regengine_sysadmin' then true + else tenant_id = (select public.get_tenant_context()) + end + ); +""", + """ +-- Runtime role allowlist (alembic v111): new tables are inaccessible to the +-- app role until a migration names them. No DELETE: gap history is evidence. +-- Not exposed through the Supabase Data API; the backend reads and writes it. +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'anon') then + revoke all on public.data_gap_events from anon; + end if; + if exists (select 1 from pg_roles where rolname = 'authenticated') then + revoke all on public.data_gap_events from authenticated; + end if; + if exists (select 1 from pg_roles where rolname = 'regengine') then + grant select, insert, update on public.data_gap_events to regengine; + end if; + if exists (select 1 from pg_roles where rolname = 'regengine_sysadmin') then + grant select on public.data_gap_events to regengine_sysadmin; + end if; +end $$; +""", +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + op.execute("DROP POLICY IF EXISTS tenant_isolation_data_gap_events ON public.data_gap_events") + op.execute("DROP INDEX IF EXISTS public.idx_data_gap_events_supplier") + op.execute("DROP INDEX IF EXISTS public.idx_data_gap_events_open_lot") + op.execute("DROP INDEX IF EXISTS public.uq_data_gap_events_open") + op.execute("DROP TABLE IF EXISTS public.data_gap_events") diff --git a/services/shared/data_gap_tracking.py b/services/shared/data_gap_tracking.py index a09ffb67c..7cd3af568 100644 --- a/services/shared/data_gap_tracking.py +++ b/services/shared/data_gap_tracking.py @@ -35,7 +35,8 @@ GAP_KIND_CTE_VALIDATION_FAILED = "cte_validation_failed" UNASSIGNED_SUPPLIER_KEY = "unassigned" -# 21 CFR 1.1455(c)(1): records must reach FDA within 24 hours of a request. +# 21 CFR 1.1455(c)(1): records must reach FDA within 24 hours of a request, or +# within a reasonable time FDA agrees to. This threshold measures gap age only. AT_RISK_THRESHOLD = timedelta(hours=24) _MISSING_KDE_RE = re.compile(r"Missing required KDE '([^']+)'") @@ -250,18 +251,16 @@ def record_outcomes(db_session: Any, tenant_id: str, outcomes: Sequence[Validati except Exception as exc: # noqa: BLE001 -- gap logging is fail-open savepoint.rollback() logger.warning( - "data_gap_write_failed tenant=%s error_type=%s error=%s", + "data_gap_write_failed tenant=%s error_type=%s", tenant_id, type(exc).__name__, - exc, ) db_session.commit() except Exception as exc: # noqa: BLE001 -- gap logging must never block ingest logger.warning( - "data_gap_record_failed tenant=%s error_type=%s error=%s", + "data_gap_record_failed tenant=%s error_type=%s", tenant_id, type(exc).__name__, - exc, ) try: db_session.rollback() From 2bdca3f5e5861c73356c2dafe1364905b8f99725 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:47:20 -0700 Subject: [PATCH 03/10] feat(suppliers): flag gaps open at a records request response due time Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../components/gap-time-to-fix.test.tsx | 28 ++++++++- .../pages/suppliers-metrics.test.tsx | 2 +- .../pages/suppliers-removal.test.tsx | 2 +- .../dashboard/suppliers/gap-time-to-fix.tsx | 16 ++++-- frontend/src/types/api.ts | 6 +- .../admin/app/supplier_compliance_routes.py | 8 ++- services/shared/data_gap_tracking.py | 57 ++++++++++++++++--- .../shared/tests/test_data_gap_tracking.py | 47 +++++++++++---- 8 files changed, 136 insertions(+), 30 deletions(-) diff --git a/frontend/src/__tests__/components/gap-time-to-fix.test.tsx b/frontend/src/__tests__/components/gap-time-to-fix.test.tsx index 0c53b0d3c..3be6f22f3 100644 --- a/frontend/src/__tests__/components/gap-time-to-fix.test.tsx +++ b/frontend/src/__tests__/components/gap-time-to-fix.test.tsx @@ -12,6 +12,8 @@ const summary = (overrides: Partial): SupplierGapTimeToFix median_time_to_fix_seconds: null, oldest_open_gap_opened_at: null, at_risk: false, + at_risk_gap_count: 0, + past_internal_target: false, has_data: true, ...overrides, }); @@ -42,7 +44,7 @@ describe('GapTimeToFix', () => { expect(screen.queryByText(/No data/)).not.toBeInTheDocument(); }); - it('renders open count, median, oldest open gap, and the at risk badge', () => { + it('renders open count, median, oldest open gap, and the request due badge with its citation', () => { render( { median_time_to_fix_seconds: 5_400, oldest_open_gap_opened_at: new Date(NOW - 25 * HOUR_MS).toISOString(), at_risk: true, + at_risk_gap_count: 1, + past_internal_target: true, })} unavailable={false} now={NOW} @@ -58,7 +62,27 @@ describe('GapTimeToFix', () => { expect(screen.getByText('2 open gaps')).toBeInTheDocument(); expect(screen.getByText('Median time to fix: 1 h 30 min')).toBeInTheDocument(); expect(screen.getByText('Oldest open gap: 1 d 1 h')).toBeInTheDocument(); - expect(screen.getByText('At risk: gap open over 24 hours')).toBeInTheDocument(); + expect(screen.getByText('At risk: open when FDA records response is due')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /21 CFR 1\.1455\(c\)\(1\)/ })).toHaveAttribute( + 'href', + 'https://www.ecfr.gov/current/title-21/section-1.1455', + ); + expect(screen.getByText('Internal target: fixed within 24 hours of flag')).toBeInTheDocument(); + expect(screen.getByText('Past internal target')).toBeInTheDocument(); + expect(screen.queryByText(/over 24 hours/)).not.toBeInTheDocument(); + }); + + it('shows the internal target without a regulatory citation when not at risk', () => { + render( + , + ); + expect(screen.getByText('Past internal target')).toBeInTheDocument(); + expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /1\.1455/ })).not.toBeInTheDocument(); }); it('says No fixes yet when no gap has closed and hides the badge when not at risk', () => { diff --git a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx index 9e468b2b9..2eaf22e2a 100644 --- a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx @@ -22,7 +22,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceScore: mocks.score, getSupplierComplianceGaps: mocks.gaps, listSupplierTLCs: mocks.tlcs, - getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], at_risk_threshold_seconds: 86400, evaluated_at: '' }), + getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], internal_target_seconds: 86400, evaluated_at: '' }), }, })); diff --git a/frontend/src/__tests__/pages/suppliers-removal.test.tsx b/frontend/src/__tests__/pages/suppliers-removal.test.tsx index 54b1c423a..7f91a6133 100644 --- a/frontend/src/__tests__/pages/suppliers-removal.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-removal.test.tsx @@ -24,7 +24,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceScore: () => Promise.resolve(null), getSupplierComplianceGaps: () => Promise.resolve({ gaps: [] }), listSupplierTLCs: () => Promise.resolve({ items: [] }), - getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], at_risk_threshold_seconds: 86400, evaluated_at: '' }), + getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], internal_target_seconds: 86400, evaluated_at: '' }), checkFacilityRemoval: mocks.checkRemoval, deleteSupplierFacility: mocks.deleteFacility, }, diff --git a/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx b/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx index 5d65d6dac..6469ca36e 100644 --- a/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx +++ b/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx @@ -70,12 +70,20 @@ export function GapTimeToFix({ summary, unavailable, now }: GapTimeToFixProps) { Median time to fix: {median === null ? 'No fixes yet' : formatDuration(median)} {oldest !== null && Oldest open gap: {oldest}} - {summary.at_risk && ( - - At risk: gap open over 24 hours + Internal target: fixed within 24 hours of flag + {summary.past_internal_target && ( + + Past internal target )} - {citation} + {summary.at_risk && ( + + + At risk: open when FDA records response is due + + {citation} + + )} ); } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 52846cbf0..8535e29e7 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -496,13 +496,17 @@ export interface SupplierGapTimeToFix { open_gap_count: number; median_time_to_fix_seconds: number | null; oldest_open_gap_opened_at: string | null; + /** A gap on a lot named in an unanswered records request is open at its response_due_at. */ at_risk: boolean; + at_risk_gap_count: number; + /** Internal target only: an open gap is older than 24 hours. Not a regulatory deadline. */ + past_internal_target: boolean; has_data: boolean; } export interface SupplierGapTimeToFixResponse { suppliers: SupplierGapTimeToFix[]; - at_risk_threshold_seconds: number; + internal_target_seconds: number; evaluated_at: string; } diff --git a/services/admin/app/supplier_compliance_routes.py b/services/admin/app/supplier_compliance_routes.py index 7527994b6..c0ea74d4e 100644 --- a/services/admin/app/supplier_compliance_routes.py +++ b/services/admin/app/supplier_compliance_routes.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel -from shared.data_gap_tracking import AT_RISK_THRESHOLD, fetch_supplier_summaries +from shared.data_gap_tracking import INTERNAL_TARGET, fetch_supplier_summaries from sqlalchemy import select from sqlalchemy.orm import Session @@ -125,12 +125,14 @@ class SupplierGapTimeToFix(BaseModel): median_time_to_fix_seconds: float | None oldest_open_gap_opened_at: str | None at_risk: bool + at_risk_gap_count: int + past_internal_target: bool has_data: bool class SupplierGapTimeToFixResponse(BaseModel): suppliers: list[SupplierGapTimeToFix] - at_risk_threshold_seconds: int + internal_target_seconds: int evaluated_at: str @@ -161,7 +163,7 @@ async def get_gap_time_to_fix( summaries = fetch_supplier_summaries(db, str(tenant_id), facility_ids, now) return SupplierGapTimeToFixResponse( suppliers=[SupplierGapTimeToFix(supplier_key=key, **summary) for key, summary in summaries.items()], - at_risk_threshold_seconds=int(AT_RISK_THRESHOLD.total_seconds()), + internal_target_seconds=int(INTERNAL_TARGET.total_seconds()), evaluated_at=now.isoformat(), ) diff --git a/services/shared/data_gap_tracking.py b/services/shared/data_gap_tracking.py index 7cd3af568..fe585ab48 100644 --- a/services/shared/data_gap_tracking.py +++ b/services/shared/data_gap_tracking.py @@ -35,9 +35,12 @@ GAP_KIND_CTE_VALIDATION_FAILED = "cte_validation_failed" UNASSIGNED_SUPPLIER_KEY = "unassigned" -# 21 CFR 1.1455(c)(1): records must reach FDA within 24 hours of a request, or -# within a reasonable time FDA agrees to. This threshold measures gap age only. -AT_RISK_THRESHOLD = timedelta(hours=24) +# Internal target only, not a regulatory deadline: fix a gap within 24 hours of +# flagging it. Regulatory risk is computed against request_cases.response_due_at. +INTERNAL_TARGET = timedelta(hours=24) + +# Request cases that have not yet been answered. +CLOSED_REQUEST_STATUSES = ("submitted", "amended") _MISSING_KDE_RE = re.compile(r"Missing required KDE '([^']+)'") _HARVESTER_PHONE_ERROR = "Harvester business phone must be a non-empty string" @@ -137,26 +140,34 @@ def oldest_opened_at(opened_ats: Sequence[datetime]) -> Optional[datetime]: return min(opened_ats) if opened_ats else None -def is_at_risk( +def is_past_internal_target( open_opened_ats: Sequence[datetime], now: datetime, - threshold: timedelta = AT_RISK_THRESHOLD, + threshold: timedelta = INTERNAL_TARGET, ) -> bool: - """True when any open gap has been open strictly longer than ``threshold``.""" + """True when any open gap has been open strictly longer than the internal target.""" return any(now - opened > threshold for opened in open_opened_ats) +def is_open_at(opened_at: datetime, closed_at: datetime | None, due_at: datetime) -> bool: + """True when a gap is open at ``due_at`` (opened by then, not closed by then).""" + return opened_at <= due_at and (closed_at is None or closed_at > due_at) + + def summarize_supplier( open_opened_ats: Sequence[datetime], fix_durations_seconds: Sequence[float], now: datetime, + at_risk_gap_count: int = 0, ) -> dict[str, Any]: oldest = oldest_opened_at(open_opened_ats) return { "open_gap_count": len(open_opened_ats), "median_time_to_fix_seconds": median_seconds(fix_durations_seconds), "oldest_open_gap_opened_at": oldest.isoformat() if oldest else None, - "at_risk": is_at_risk(open_opened_ats, now), + "at_risk": at_risk_gap_count > 0, + "at_risk_gap_count": at_risk_gap_count, + "past_internal_target": is_past_internal_target(open_opened_ats, now), "has_data": bool(open_opened_ats) or bool(fix_durations_seconds), } @@ -280,6 +291,27 @@ def record_outcomes(db_session: Any, tenant_id: str, outcomes: Sequence[Validati ) +# A gap is at risk when its lot is listed on an unanswered request case and the +# gap is still open at that case's response_due_at (21 CFR 1.1455(c)(1)). The +# same predicate is in is_open_at; a closed gap counts only when it closed after +# the due time. +_AT_RISK_SQL = text( + """ + SELECT g.supplier_key, COUNT(DISTINCT g.id) AS at_risk_gaps + FROM public.data_gap_events g + JOIN fsma.request_cases r + ON r.tenant_id = g.tenant_id + AND g.lot_code = ANY(r.affected_lots) + WHERE g.tenant_id = CAST(:tid AS uuid) + AND g.supplier_key = ANY(:keys) + AND r.package_status <> ALL(:closed_statuses) + AND g.opened_at <= r.response_due_at + AND (g.closed_at IS NULL OR g.closed_at > r.response_due_at) + GROUP BY g.supplier_key + """ +) + + def fetch_supplier_summaries( db_session: Any, tenant_id: str, @@ -289,6 +321,7 @@ def fetch_supplier_summaries( """Per supplier time to fix summary for ``supplier_keys`` (every key is present).""" open_by_key: dict[str, list[datetime]] = {key: [] for key in supplier_keys} fixed_by_key: dict[str, list[float]] = {key: [] for key in supplier_keys} + at_risk_by_key: dict[str, int] = {key: 0 for key in supplier_keys} if supplier_keys: rows = db_session.execute(_SUMMARY_ROWS_SQL, {"tid": tenant_id, "keys": list(supplier_keys)}).fetchall() for supplier_key, opened_at, fix_seconds in rows: @@ -296,4 +329,12 @@ def fetch_supplier_summaries( open_by_key[supplier_key].append(opened_at) else: fixed_by_key[supplier_key].append(float(fix_seconds)) - return {key: summarize_supplier(open_by_key[key], fixed_by_key[key], now) for key in supplier_keys} + at_risk_rows = db_session.execute( + _AT_RISK_SQL, + {"tid": tenant_id, "keys": list(supplier_keys), "closed_statuses": list(CLOSED_REQUEST_STATUSES)}, + ).fetchall() + for supplier_key, count in at_risk_rows: + at_risk_by_key[supplier_key] = int(count) + return { + key: summarize_supplier(open_by_key[key], fixed_by_key[key], now, at_risk_by_key[key]) for key in supplier_keys + } diff --git a/services/shared/tests/test_data_gap_tracking.py b/services/shared/tests/test_data_gap_tracking.py index f4bdee5a2..4abc458d6 100644 --- a/services/shared/tests/test_data_gap_tracking.py +++ b/services/shared/tests/test_data_gap_tracking.py @@ -10,16 +10,17 @@ from datetime import datetime, timedelta, timezone from shared.data_gap_tracking import ( - AT_RISK_THRESHOLD, GAP_KIND_CTE_VALIDATION_FAILED, GAP_KIND_MISSING_KDE, + INTERNAL_TARGET, Gap, ValidationOutcome, decide_transition, gap_from_rule_reason, gap_identity, gaps_from_kde_errors, - is_at_risk, + is_open_at, + is_past_internal_target, is_receipt_hash, median_seconds, oldest_opened_at, @@ -54,20 +55,36 @@ def test_oldest_opened_at(): assert oldest_opened_at([NOW, NOW - timedelta(hours=1)]) == NOW - timedelta(hours=1) -def test_at_risk_threshold_is_24_hours(): - assert AT_RISK_THRESHOLD == timedelta(hours=24) +def test_internal_target_is_24_hours(): + assert INTERNAL_TARGET == timedelta(hours=24) -def test_gap_open_exactly_24_hours_is_not_at_risk(): - assert is_at_risk([NOW - timedelta(hours=24)], NOW) is False +def test_gap_open_exactly_24_hours_is_not_past_internal_target(): + assert is_past_internal_target([NOW - timedelta(hours=24)], NOW) is False -def test_gap_open_just_over_24_hours_is_at_risk(): - assert is_at_risk([NOW - timedelta(hours=24, microseconds=1)], NOW) is True +def test_gap_open_just_over_24_hours_is_past_internal_target(): + assert is_past_internal_target([NOW - timedelta(hours=24, microseconds=1)], NOW) is True -def test_no_open_gaps_is_not_at_risk(): - assert is_at_risk([], NOW) is False +def test_no_open_gaps_is_not_past_internal_target(): + assert is_past_internal_target([], NOW) is False + + +def test_gap_still_open_at_due_time_is_open_at(): + assert is_open_at(NOW - timedelta(hours=2), None, NOW) is True + + +def test_gap_closed_after_due_time_is_open_at(): + assert is_open_at(NOW - timedelta(hours=2), NOW + timedelta(seconds=1), NOW) is True + + +def test_gap_closed_exactly_at_due_time_is_not_open_at(): + assert is_open_at(NOW - timedelta(hours=2), NOW, NOW) is False + + +def test_gap_opened_after_due_time_is_not_open_at(): + assert is_open_at(NOW + timedelta(seconds=1), None, NOW) is False def test_summary_without_history_has_no_data(): @@ -76,6 +93,8 @@ def test_summary_without_history_has_no_data(): "median_time_to_fix_seconds": None, "oldest_open_gap_opened_at": None, "at_risk": False, + "at_risk_gap_count": 0, + "past_internal_target": False, "has_data": False, } @@ -92,7 +111,15 @@ def test_summary_with_open_gaps(): result = summarize_supplier([opened, NOW], [], NOW) assert result["open_gap_count"] == 2 assert result["oldest_open_gap_opened_at"] == opened.isoformat() + assert result["past_internal_target"] is True + assert result["at_risk"] is False + + +def test_summary_at_risk_comes_from_request_due_count_not_gap_age(): + result = summarize_supplier([NOW], [], NOW, at_risk_gap_count=2) assert result["at_risk"] is True + assert result["at_risk_gap_count"] == 2 + assert result["past_internal_target"] is False # --------------------------------------------------------------------------- From 867a62930bc47819f19626f9f832e83df4096bcd Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:38:10 -0700 Subject: [PATCH 04/10] feat(suppliers): add per supplier data quality view Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- docs/security/route-security-manifest.json | 30 +- .../components/supplier-data-quality.test.tsx | 194 +++++++++ .../pages/suppliers-metrics.test.tsx | 1 + .../pages/suppliers-removal.test.tsx | 1 + .../app/dashboard/suppliers/data-quality.tsx | 192 +++++++++ frontend/src/app/dashboard/suppliers/page.tsx | 24 ++ frontend/src/lib/api-client.ts | 29 ++ frontend/src/types/api.ts | 48 +++ .../admin/app/supplier_compliance_routes.py | 168 +++++++- .../test_supplier_data_quality_routes.py | 178 ++++++++ services/shared/supplier_data_quality.py | 401 ++++++++++++++++++ .../tests/test_supplier_data_quality.py | 242 +++++++++++ .../security/test_route_security_manifest.py | 9 +- 13 files changed, 1511 insertions(+), 6 deletions(-) create mode 100644 frontend/src/__tests__/components/supplier-data-quality.test.tsx create mode 100644 frontend/src/app/dashboard/suppliers/data-quality.tsx create mode 100644 services/admin/tests/test_supplier_data_quality_routes.py create mode 100644 services/shared/supplier_data_quality.py create mode 100644 services/shared/tests/test_supplier_data_quality.py diff --git a/docs/security/route-security-manifest.json b/docs/security/route-security-manifest.json index a4794c99f..00bbed8cc 100644 --- a/docs/security/route-security-manifest.json +++ b/docs/security/route-security-manifest.json @@ -2852,6 +2852,32 @@ "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", "test_adapter": "tenant_dual_session_csrf" }, + { + "operation": "admin:GET:/v1/supplier/data-quality", + "owner": "admin", + "method": "GET", + "path": "/v1/supplier/data-quality", + "classification": "tenant-scoped", + "authentication": "bearer-token-or-opaque-session-cookie", + "required_permissions": [ + "authenticated" + ], + "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", + "test_adapter": "tenant_dual_session_csrf" + }, + { + "operation": "admin:GET:/v1/supplier/data-quality/{facility_id}/records", + "owner": "admin", + "method": "GET", + "path": "/v1/supplier/data-quality/{facility_id}/records", + "classification": "tenant-scoped", + "authentication": "bearer-token-or-opaque-session-cookie", + "required_permissions": [ + "authenticated" + ], + "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", + "test_adapter": "tenant_dual_session_csrf" + }, { "operation": "admin:POST:/v1/supplier/demo/reset", "owner": "admin", @@ -4918,13 +4944,13 @@ } ], "summary": { - "enabled_operation_count": 255, + "enabled_operation_count": 257, "disabled_operation_count": 180, "disabled_unique_path_count": 163, "enabled_by_classification": { "global-sysadmin": 16, "public": 30, - "tenant-scoped": 202, + "tenant-scoped": 204, "workload-only": 7 } } diff --git a/frontend/src/__tests__/components/supplier-data-quality.test.tsx b/frontend/src/__tests__/components/supplier-data-quality.test.tsx new file mode 100644 index 000000000..adc3f0f7b --- /dev/null +++ b/frontend/src/__tests__/components/supplier-data-quality.test.tsx @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { SupplierDataQuality, SupplierQualityMetric, SupplierQualityRecordsResponse } from '@/types/api'; + +const mocks = vi.hoisted(() => ({ + getSupplierDataQualityRecords: vi.fn(), +})); + +vi.mock('@/lib/api-client', () => ({ + apiClient: { + getSupplierDataQualityRecords: (...args: unknown[]) => mocks.getSupplierDataQualityRecords(...args), + }, +})); + +import { DataQuality, DRILLDOWN_PAGE_SIZE } from '@/app/dashboard/suppliers/data-quality'; + +const LATE_LABEL = 'Our measure, based on the agreed sending time with this supplier; not a regulatory requirement'; + +const metric = (overrides: Partial): SupplierQualityMetric => ({ + key: 'missing_kde', + status: 'ok', + numerator: 0, + denominator: 0, + share: null, + reason: null, + ...overrides, +}); + +const summary = (metrics: SupplierQualityMetric[]): SupplierDataQuality => ({ supplier_key: 'fac-1', metrics }); + +const fourMetrics = summary([ + metric({ key: 'missing_kde', status: 'ok', numerator: 5, denominator: 40, share: 0.125 }), + metric({ key: 'unlinked_lot', status: 'not_enough_data', numerator: 2, denominator: 7, share: null }), + metric({ key: 'late_cte', status: 'unavailable', numerator: null, denominator: null, reason: 'No agreed sending time is recorded for this supplier, so late records cannot be measured.' }), + metric({ key: 'epcis_failed', status: 'unavailable', numerator: null, denominator: null, reason: 'No EPCIS file record.' }), +]); + +function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false) { + return render( + , + ); +} + +const page = (overrides: Partial): SupplierQualityRecordsResponse => ({ + supplier_key: 'fac-1', + metric: 'missing_kde', + window_days: 30, + total: 0, + limit: DRILLDOWN_PAGE_SIZE, + offset: 0, + items: [], + ...overrides, +}); + +describe('DataQuality', () => { + beforeEach(() => { + mocks.getSupplierDataQualityRecords.mockReset(); + }); + + it('shows count of denominator and a share for each metric, with no combined score', () => { + renderQuality(); + expect(screen.getByText('5 of 40 records (12.5%)')).toBeInTheDocument(); + expect(screen.queryByText(/score/i)).not.toBeInTheDocument(); + expect(screen.getByText('Data quality, records received in the last 30 days')).toBeInTheDocument(); + }); + + it('shows Not enough data below the minimum instead of a share', () => { + renderQuality(); + expect(screen.getByText(/2 of 7 lot codes/)).toBeInTheDocument(); + expect(screen.getByText('Not enough data (fewer than 20)')).toBeInTheDocument(); + expect(screen.queryByText(/28\.6%/)).not.toBeInTheDocument(); + }); + + it('shows unavailable metrics with their reason and the late CTE label', () => { + renderQuality(); + expect(screen.getByText(/Unavailable\. No agreed sending time is recorded/)).toBeInTheDocument(); + expect(screen.getByText(/Unavailable\. No EPCIS file record\./)).toBeInTheDocument(); + expect(screen.getByText(LATE_LABEL)).toBeInTheDocument(); + }); + + it('offers drill down only for computable metrics with flagged records', () => { + renderQuality(); + expect(screen.getAllByRole('button', { name: 'View records' })).toHaveLength(2); + }); + + it('hides drill down when nothing is flagged', () => { + renderQuality(summary([metric({ key: 'missing_kde', numerator: 0, denominator: 30, share: 0 })])); + expect(screen.queryByRole('button', { name: 'View records' })).not.toBeInTheDocument(); + }); + + it('loads the exact records for a metric and pages with Show more', async () => { + mocks.getSupplierDataQualityRecords + .mockResolvedValueOnce( + page({ + total: 2, + items: [ + { + record_id: 'rec-1', + lot_id: null, + lot_code: 'TLC-1', + cte_type: 'shipping', + event_time: null, + received_at: null, + missing_kdes: ['reference_document', 'quantity'], + }, + ], + }), + ) + .mockResolvedValueOnce( + page({ + total: 2, + offset: 1, + items: [ + { + record_id: 'rec-2', + lot_id: null, + lot_code: 'TLC-2', + cte_type: 'receiving', + event_time: null, + received_at: null, + missing_kdes: ['tlc_source_reference'], + }, + ], + }), + ); + renderQuality(); + + fireEvent.click(screen.getAllByRole('button', { name: 'View records' })[0]); + await waitFor(() => expect(screen.getByText('rec-1')).toBeInTheDocument()); + expect(screen.getByText('Missing: reference_document, quantity')).toBeInTheDocument(); + expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith('fac-1', 'missing_kde', { + windowDays: 30, + limit: DRILLDOWN_PAGE_SIZE, + offset: 0, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Show more (1 of 2)' })); + await waitFor(() => expect(screen.getByText('rec-2')).toBeInTheDocument()); + expect(mocks.getSupplierDataQualityRecords).toHaveBeenLastCalledWith('fac-1', 'missing_kde', { + windowDays: 30, + limit: DRILLDOWN_PAGE_SIZE, + offset: 1, + }); + expect(screen.queryByRole('button', { name: /Show more/ })).not.toBeInTheDocument(); + }); + + it('lists unlinked lot codes', async () => { + mocks.getSupplierDataQualityRecords.mockResolvedValueOnce( + page({ + metric: 'unlinked_lot', + total: 1, + items: [ + { + record_id: null, + lot_id: 'lot-9', + lot_code: 'TLC-9', + cte_type: null, + event_time: null, + received_at: null, + missing_kdes: [], + }, + ], + }), + ); + renderQuality(); + fireEvent.click(screen.getAllByRole('button', { name: 'View records' })[1]); + await waitFor(() => expect(screen.getByText('Lot TLC-9')).toBeInTheDocument()); + expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith('fac-1', 'unlinked_lot', expect.anything()); + }); + + it('says so when the records request fails', async () => { + mocks.getSupplierDataQualityRecords.mockRejectedValueOnce(new Error('boom')); + renderQuality(); + fireEvent.click(screen.getAllByRole('button', { name: 'View records' })[0]); + await waitFor(() => expect(screen.getByText('Records could not be loaded')).toBeInTheDocument()); + }); + + it('shows unavailable when the summary request failed', () => { + renderQuality(null, true); + expect(screen.getByText('Data quality unavailable')).toBeInTheDocument(); + }); + + it('shows No data for a facility missing from the response', () => { + renderQuality(null, false); + expect(screen.getByText('Data quality: No data')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx index 2eaf22e2a..877b474b3 100644 --- a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx @@ -23,6 +23,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceGaps: mocks.gaps, listSupplierTLCs: mocks.tlcs, getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], internal_target_seconds: 86400, evaluated_at: '' }), + getSupplierDataQuality: () => Promise.resolve({ suppliers: [], window_days: 30, min_records: 20, late_cte_label: '', evaluated_at: '' }), }, })); diff --git a/frontend/src/__tests__/pages/suppliers-removal.test.tsx b/frontend/src/__tests__/pages/suppliers-removal.test.tsx index 7f91a6133..ee0eae0d6 100644 --- a/frontend/src/__tests__/pages/suppliers-removal.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-removal.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceGaps: () => Promise.resolve({ gaps: [] }), listSupplierTLCs: () => Promise.resolve({ items: [] }), getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], internal_target_seconds: 86400, evaluated_at: '' }), + getSupplierDataQuality: () => Promise.resolve({ suppliers: [], window_days: 30, min_records: 20, late_cte_label: '', evaluated_at: '' }), checkFacilityRemoval: mocks.checkRemoval, deleteSupplierFacility: mocks.deleteFacility, }, diff --git a/frontend/src/app/dashboard/suppliers/data-quality.tsx b/frontend/src/app/dashboard/suppliers/data-quality.tsx new file mode 100644 index 000000000..79db05b5f --- /dev/null +++ b/frontend/src/app/dashboard/suppliers/data-quality.tsx @@ -0,0 +1,192 @@ +import { useState, type ReactNode } from 'react'; + +import { Button } from '@/components/ui/button'; +import { apiClient } from '@/lib/api-client'; +import type { + SupplierDataQuality, + SupplierQualityDrilldownMetric, + SupplierQualityMetric, + SupplierQualityMetricKey, + SupplierQualityRecordItem, +} from '@/types/api'; + +export const DRILLDOWN_PAGE_SIZE = 25; + +const METRIC_COPY: Record = { + missing_kde: { label: 'Records missing a KDE', unit: 'records' }, + unlinked_lot: { label: 'Lot codes not linked back through the chain', unit: 'lot codes' }, + late_cte: { label: 'Late CTE records', unit: 'records' }, + epcis_failed: { label: 'EPCIS files failing validation', unit: 'files' }, +}; + +function isDrilldown(key: SupplierQualityMetricKey): key is SupplierQualityDrilldownMetric { + return key === 'missing_kde' || key === 'unlinked_lot'; +} + +function formatShare(share: number): string { + return `${Math.round(share * 1000) / 10}%`; +} + +interface DrilldownState { + items: SupplierQualityRecordItem[]; + total: number; + loading: boolean; + failed: boolean; +} + +function DrilldownList({ metric, state }: { metric: SupplierQualityDrilldownMetric; state: DrilldownState }) { + if (state.items.length === 0) { + if (state.loading) return

    Loading records

    ; + if (state.failed) return

    Records could not be loaded

    ; + return

    No records

    ; + } + return ( +
      + {state.items.map((item) => + metric === 'missing_kde' ? ( +
    • + {item.record_id} + Lot {item.lot_code} + {item.cte_type && {item.cte_type}} + Missing: {item.missing_kdes.join(', ')} +
    • + ) : ( +
    • + Lot {item.lot_code} + {item.lot_id} +
    • + ), + )} +
    + ); +} + +function MetricRow({ + facilityId, + metric, + windowDays, + minRecords, + lateCteLabel, +}: { + facilityId: string; + metric: SupplierQualityMetric; + windowDays: number; + minRecords: number; + lateCteLabel: string; +}) { + const copy = METRIC_COPY[metric.key]; + const [open, setOpen] = useState(false); + const [drilldown, setDrilldown] = useState({ items: [], total: 0, loading: false, failed: false }); + + const load = async (key: SupplierQualityDrilldownMetric, offset: number) => { + setDrilldown((prev) => ({ ...prev, loading: true, failed: false })); + try { + const res = await apiClient.getSupplierDataQualityRecords(facilityId, key, { + windowDays, + limit: DRILLDOWN_PAGE_SIZE, + offset, + }); + setDrilldown((prev) => ({ + items: offset === 0 ? res.items : [...prev.items, ...res.items], + total: res.total, + loading: false, + failed: false, + })); + } catch { + setDrilldown((prev) => ({ ...prev, loading: false, failed: true })); + } + }; + + const toggle = () => { + if (!isDrilldown(metric.key)) return; + const next = !open; + setOpen(next); + if (next && drilldown.items.length === 0) void load(metric.key, 0); + }; + + let value: ReactNode; + if (metric.status === 'unavailable') { + value = Unavailable. {metric.reason}; + } else { + const counts = `${metric.numerator} of ${metric.denominator} ${copy.unit}`; + value = + metric.status === 'ok' && metric.share !== null ? ( + + {counts} ({formatShare(metric.share)}) + + ) : ( + + {counts}. Not enough data (fewer than {minRecords}) + + ); + } + + const canDrill = isDrilldown(metric.key) && metric.status !== 'unavailable' && (metric.numerator ?? 0) > 0; + + return ( +
  • +
    + {copy.label}: + {value} + {canDrill && ( + + )} +
    + {metric.key === 'late_cte' &&

    {lateCteLabel}

    } + {open && isDrilldown(metric.key) && ( +
    + + {drilldown.items.length < drilldown.total && ( + + )} +
    + )} +
  • + ); +} + +interface DataQualityProps { + facilityId: string; + summary: SupplierDataQuality | undefined; + /** True when the data quality request failed. */ + unavailable: boolean; + windowDays: number; + minRecords: number; + lateCteLabel: string; +} + +/** Four separate data quality measures for one facility. No combined score. */ +export function DataQuality({ facilityId, summary, unavailable, windowDays, minRecords, lateCteLabel }: DataQualityProps) { + if (unavailable) { + return

    Data quality unavailable

    ; + } + if (!summary) { + return

    Data quality: No data

    ; + } + return ( +
    +

    Data quality, records received in the last {windowDays} days

    +
      + {summary.metrics.map((metric) => ( + + ))} +
    +
    + ); +} diff --git a/frontend/src/app/dashboard/suppliers/page.tsx b/frontend/src/app/dashboard/suppliers/page.tsx index f204284c2..c2b394bee 100644 --- a/frontend/src/app/dashboard/suppliers/page.tsx +++ b/frontend/src/app/dashboard/suppliers/page.tsx @@ -49,8 +49,10 @@ import type { IntegrationProfile, FacilityRemovalCheck, SupplierGapTimeToFix, + SupplierDataQualityResponse, } from '@/types/api'; import { GapTimeToFix } from './gap-time-to-fix'; +import { DataQuality } from './data-quality'; import { NO_COMPLIANCE_DATA, countOrNull, @@ -144,6 +146,8 @@ export default function SupplierDashboardPage() { const [now, setNow] = useState(Date.now); const [gapTimeToFix, setGapTimeToFix] = useState>({}); const [gapTimeToFixUnavailable, setGapTimeToFixUnavailable] = useState(false); + const [dataQuality, setDataQuality] = useState(null); + const [dataQualityUnavailable, setDataQualityUnavailable] = useState(false); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); @@ -240,6 +244,16 @@ export default function SupplierDashboardPage() { setGapTimeToFixUnavailable(true); }); + apiClient.getSupplierDataQuality() + .then((res) => { + setDataQuality(res); + setDataQualityUnavailable(false); + }) + .catch(() => { + setDataQuality(null); + setDataQualityUnavailable(true); + }); + if (tenantId) { fetchWorkbenchReadinessSummary(tenantId) .then(setWorkbenchSummary) @@ -884,6 +898,16 @@ export default function SupplierDashboardPage() { unavailable={gapTimeToFixUnavailable} now={now} /> + {(dataQuality || dataQualityUnavailable) && ( + s.supplier_key === facility.id)} + unavailable={dataQualityUnavailable} + windowDays={dataQuality?.window_days ?? 30} + minRecords={dataQuality?.min_records ?? 20} + lateCteLabel={dataQuality?.late_cte_label ?? ''} + /> + )} {comp.gaps.length > 0 && (
      {comp.gaps.map((gap, index) => { diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index fd02334d6..b7a858815 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -29,6 +29,9 @@ import type { SupplierComplianceGapsResponse, SupplierComplianceScore, SupplierGapTimeToFixResponse, + SupplierDataQualityResponse, + SupplierQualityDrilldownMetric, + SupplierQualityRecordsResponse, SupplierBulkUploadCommitResponse, SupplierBulkUploadParseResponse, SupplierBulkUploadStatusResponse, @@ -877,6 +880,32 @@ class APIClient { return data; } + async getSupplierDataQuality(windowDays?: number): Promise { + const { data } = await this.adminClient.get('/v1/supplier/data-quality', { + params: windowDays ? { window_days: windowDays } : undefined, + }); + return data; + } + + async getSupplierDataQualityRecords( + facilityId: string, + metric: SupplierQualityDrilldownMetric, + options: { windowDays?: number; limit?: number; offset?: number } = {}, + ): Promise { + const { data } = await this.adminClient.get( + `/v1/supplier/data-quality/${encodeURIComponent(facilityId)}/records`, + { + params: { + metric, + window_days: options.windowDays, + limit: options.limit, + offset: options.offset, + }, + }, + ); + return data; + } + async getSupplierFDAExportPreview( facilityId?: string, limit: number = 25, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 8535e29e7..5339336bb 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -510,6 +510,54 @@ export interface SupplierGapTimeToFixResponse { evaluated_at: string; } +export type SupplierQualityMetricKey = 'missing_kde' | 'unlinked_lot' | 'late_cte' | 'epcis_failed'; +export type SupplierQualityDrilldownMetric = 'missing_kde' | 'unlinked_lot'; + +export interface SupplierQualityMetric { + key: SupplierQualityMetricKey; + /** not_enough_data: denominator is below min_records, so share is null. */ + status: 'ok' | 'not_enough_data' | 'unavailable'; + numerator: number | null; + denominator: number | null; + share: number | null; + /** Set when status is unavailable. */ + reason: string | null; +} + +export interface SupplierDataQuality { + /** Supplier facility id. */ + supplier_key: string; + metrics: SupplierQualityMetric[]; +} + +export interface SupplierDataQualityResponse { + suppliers: SupplierDataQuality[]; + window_days: number; + min_records: number; + late_cte_label: string; + evaluated_at: string; +} + +export interface SupplierQualityRecordItem { + record_id: string | null; + lot_id: string | null; + lot_code: string; + cte_type: string | null; + event_time: string | null; + received_at: string | null; + missing_kdes: string[]; +} + +export interface SupplierQualityRecordsResponse { + supplier_key: string; + metric: SupplierQualityDrilldownMetric; + window_days: number; + total: number; + limit: number; + offset: number; + items: SupplierQualityRecordItem[]; +} + export interface SupplierFDAExportRow { event_id: string; tlc_code: string; diff --git a/services/admin/app/supplier_compliance_routes.py b/services/admin/app/supplier_compliance_routes.py index c0ea74d4e..1559b9a34 100644 --- a/services/admin/app/supplier_compliance_routes.py +++ b/services/admin/app/supplier_compliance_routes.py @@ -9,12 +9,28 @@ import io import uuid as uuid_module from datetime import datetime, timezone -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel from shared.data_gap_tracking import INTERNAL_TARGET, fetch_supplier_summaries +from shared.supplier_data_quality import ( + DEFAULT_WINDOW_DAYS, + LATE_CTE_LABEL, + MAX_WINDOW_DAYS, + METRIC_EPCIS_FAILED, + METRIC_LATE_CTE, + METRIC_MISSING_KDE, + METRIC_UNLINKED_LOT, + MIN_RECORDS, + fetch_lot_rows, + fetch_record_rows, + fetch_supplier_quality, + flag_records, + unlinked_lots, + window_start as quality_window_start, +) from sqlalchemy import select from sqlalchemy.orm import Session @@ -37,6 +53,8 @@ router = APIRouter(prefix="/supplier", tags=["supplier-onboarding"]) +QUALITY_METRIC_ORDER = (METRIC_MISSING_KDE, METRIC_UNLINKED_LOT, METRIC_LATE_CTE, METRIC_EPCIS_FAILED) + @router.get("/compliance-score", response_model=SupplierComplianceScoreResponse) async def get_compliance_score( @@ -168,6 +186,154 @@ async def get_gap_time_to_fix( ) +class SupplierQualityMetric(BaseModel): + key: str + status: Literal["ok", "not_enough_data", "unavailable"] + numerator: int | None + denominator: int | None + share: float | None + reason: str | None + + +class SupplierDataQuality(BaseModel): + supplier_key: str + metrics: list[SupplierQualityMetric] + + +class SupplierDataQualityResponse(BaseModel): + suppliers: list[SupplierDataQuality] + window_days: int + min_records: int + late_cte_label: str + evaluated_at: str + + +class SupplierQualityRecordItem(BaseModel): + record_id: str | None = None + lot_id: str | None = None + lot_code: str + cte_type: str | None = None + event_time: str | None = None + received_at: str | None = None + missing_kdes: list[str] = [] + + +class SupplierQualityRecordsResponse(BaseModel): + supplier_key: str + metric: str + window_days: int + total: int + limit: int + offset: int + items: list[SupplierQualityRecordItem] + + +def _owned_facility_ids(db: Session, tenant_id: uuid_module.UUID, user_id: Any) -> list[str]: + return [ + str(facility_id) + for facility_id in db.scalars( + select(SupplierFacilityModel.id).where( + SupplierFacilityModel.tenant_id == tenant_id, + SupplierFacilityModel.supplier_user_id == user_id, + ) + ).all() + ] + + +def _iso_or_none(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +@router.get("/data-quality", response_model=SupplierDataQualityResponse) +async def get_supplier_data_quality( + window_days: int = Query(default=DEFAULT_WINDOW_DAYS, ge=1, le=MAX_WINDOW_DAYS), + current_user: UserModel = Depends(get_current_user), + db: Session = Depends(get_session), +) -> SupplierDataQualityResponse: + """Four separate data quality measures per facility over a rolling window. + + Scoped to the tenant and to facilities owned by the calling user. + """ + tenant_id = TenantContext.get_tenant_context(db) + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant context required") + + facility_ids = _owned_facility_ids(db, tenant_id, current_user.id) + now = datetime.now(timezone.utc) + quality = fetch_supplier_quality( + db, str(tenant_id), str(current_user.id), facility_ids, quality_window_start(now, window_days) + ) + return SupplierDataQualityResponse( + suppliers=[ + SupplierDataQuality( + supplier_key=key, + metrics=[SupplierQualityMetric(**metrics[name]) for name in QUALITY_METRIC_ORDER], + ) + for key, metrics in quality.items() + ], + window_days=window_days, + min_records=MIN_RECORDS, + late_cte_label=LATE_CTE_LABEL, + evaluated_at=now.isoformat(), + ) + + +@router.get("/data-quality/{facility_id}/records", response_model=SupplierQualityRecordsResponse) +async def get_supplier_data_quality_records( + facility_id: str, + metric: Literal["missing_kde", "unlinked_lot"], + window_days: int = Query(default=DEFAULT_WINDOW_DAYS, ge=1, le=MAX_WINDOW_DAYS), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + current_user: UserModel = Depends(get_current_user), + db: Session = Depends(get_session), +) -> SupplierQualityRecordsResponse: + """The exact records behind one facility's metric numerator, paginated.""" + tenant_id = TenantContext.get_tenant_context(db) + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant context required") + try: + scoped_facility_id = uuid_module.UUID(facility_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid facility id") from exc + _get_supplier_facility_or_404( + db, + tenant_id=tenant_id, + supplier_user_id=current_user.id, + facility_id=scoped_facility_id, + ) + + key = str(scoped_facility_id) + since = quality_window_start(datetime.now(timezone.utc), window_days) + if metric == METRIC_MISSING_KDE: + flagged = flag_records(fetch_record_rows(db, str(tenant_id), str(current_user.id), [key], since)) + items = [ + SupplierQualityRecordItem( + record_id=str(row["record_id"]), + lot_code=row["lot_code"], + cte_type=row["cte_type"], + event_time=_iso_or_none(row["event_time"]), + received_at=_iso_or_none(row["created_at"]), + missing_kdes=row["missing_kdes"], + ) + for row in flagged + ] + else: + items = [ + SupplierQualityRecordItem(lot_id=lot["lot_id"], lot_code=lot["lot_code"]) + for lot in unlinked_lots(fetch_lot_rows(db, str(tenant_id), str(current_user.id), [key], since)) + ] + return SupplierQualityRecordsResponse( + supplier_key=key, + metric=metric, + window_days=window_days, + total=len(items), + limit=limit, + offset=offset, + items=items[offset : offset + limit], + ) + + @router.get("/export/fda-records/preview", response_model=SupplierFDAExportPreviewResponse) async def preview_fda_records_export( facility_id: str | None = None, diff --git a/services/admin/tests/test_supplier_data_quality_routes.py b/services/admin/tests/test_supplier_data_quality_routes.py new file mode 100644 index 000000000..8b5341633 --- /dev/null +++ b/services/admin/tests/test_supplier_data_quality_routes.py @@ -0,0 +1,178 @@ +"""Supplier data quality routes: scoping and guards, no database. + +A fake session answers the facility ownership lookups; the SQL fetchers are +stubbed so the tests check what the routes pass and return, not SQL. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from uuid import UUID + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +TENANT = UUID("00000000-0000-0000-0000-000000000001") +OTHER_TENANT = UUID("00000000-0000-0000-0000-000000000009") +USER = UUID("00000000-0000-0000-0000-000000000002") +OTHER_USER = UUID("00000000-0000-0000-0000-000000000003") +OWN_FACILITY = UUID("00000000-0000-0000-0000-0000000000f1") +FOREIGN_FACILITY = UUID("00000000-0000-0000-0000-0000000000f2") +OTHER_TENANT_FACILITY = UUID("00000000-0000-0000-0000-0000000000f3") + + +class FakeSession: + def __init__(self) -> None: + self.facilities = { + OWN_FACILITY: SimpleNamespace(id=OWN_FACILITY, tenant_id=TENANT, supplier_user_id=USER), + FOREIGN_FACILITY: SimpleNamespace(id=FOREIGN_FACILITY, tenant_id=TENANT, supplier_user_id=OTHER_USER), + OTHER_TENANT_FACILITY: SimpleNamespace( + id=OTHER_TENANT_FACILITY, tenant_id=OTHER_TENANT, supplier_user_id=USER + ), + } + + def get(self, _model, key): + return self.facilities.get(key) + + def scalars(self, _stmt): + # The route filters by tenant and user in SQL; the fake returns what that + # filter yields for TENANT and USER. + return SimpleNamespace(all=lambda: [OWN_FACILITY]) + + +@pytest.fixture +def calls(): + return [] + + +def _client(monkeypatch, calls, tenant=TENANT): + import app.supplier_compliance_routes as routes + + def fake_records(_db, tenant_id, user_id, facility_ids, since): + calls.append(("records", tenant_id, user_id, list(facility_ids))) + return [ + { + "record_id": "r1", + "facility_id": str(OWN_FACILITY), + "cte_type": "shipping", + "kde_data": {}, + "event_time": None, + "created_at": None, + "lot_code": "TLC-1", + }, + { + "record_id": "r2", + "facility_id": str(OWN_FACILITY), + "cte_type": "shipping", + "kde_data": {}, + "event_time": None, + "created_at": None, + "lot_code": "TLC-2", + }, + ] + + def fake_lots(_db, tenant_id, user_id, facility_ids, since): + calls.append(("lots", tenant_id, user_id, list(facility_ids))) + return [ + { + "lot_id": "l1", + "lot_code": "TLC-1", + "facility_id": str(OWN_FACILITY), + "cte_type": "shipping", + "kde_data": {}, + "is_transformation_output": False, + } + ] + + monkeypatch.setattr(routes, "fetch_record_rows", fake_records) + monkeypatch.setattr(routes, "fetch_lot_rows", fake_lots) + monkeypatch.setattr("shared.supplier_data_quality.fetch_record_rows", fake_records) + monkeypatch.setattr("shared.supplier_data_quality.fetch_lot_rows", fake_lots) + monkeypatch.setattr(routes.TenantContext, "get_tenant_context", lambda _db: tenant) + + app = FastAPI() + app.include_router(routes.router, prefix="/v1") + session = FakeSession() + app.dependency_overrides[routes.get_session] = lambda: session + app.dependency_overrides[routes.get_current_user] = lambda: SimpleNamespace(id=USER) + return TestClient(app) + + +def test_summary_returns_four_separate_metrics_for_owned_facilities(monkeypatch, calls): + resp = _client(monkeypatch, calls).get("/v1/supplier/data-quality") + assert resp.status_code == 200 + body = resp.json() + assert body["window_days"] == 30 + assert body["min_records"] == 20 + assert body["late_cte_label"] == ( + "Our measure, based on the agreed sending time with this supplier; not a regulatory requirement" + ) + [supplier] = body["suppliers"] + assert supplier["supplier_key"] == str(OWN_FACILITY) + assert [m["key"] for m in supplier["metrics"]] == ["missing_kde", "unlinked_lot", "late_cte", "epcis_failed"] + missing = supplier["metrics"][0] + assert (missing["status"], missing["numerator"], missing["denominator"]) == ("not_enough_data", 2, 2) + assert supplier["metrics"][2]["status"] == "unavailable" + assert supplier["metrics"][3]["status"] == "unavailable" + assert all(call[1:3] == (str(TENANT), str(USER)) for call in calls) + assert all(call[3] == [str(OWN_FACILITY)] for call in calls) + + +@pytest.mark.parametrize("window", [0, 366, -1]) +def test_window_days_is_bounded(monkeypatch, calls, window): + resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality?window_days={window}") + assert resp.status_code == 422 + assert calls == [] + + +def test_summary_requires_tenant(monkeypatch, calls): + assert _client(monkeypatch, calls, tenant=None).get("/v1/supplier/data-quality").status_code == 400 + + +def test_drilldown_lists_missing_kde_records(monkeypatch, calls): + resp = _client(monkeypatch, calls).get( + f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=missing_kde&limit=1" + ) + assert resp.status_code == 200 + body = resp.json() + assert (body["total"], body["limit"], body["offset"]) == (2, 1, 0) + assert [item["record_id"] for item in body["items"]] == ["r1"] + assert "product_description" in body["items"][0]["missing_kdes"] + assert calls == [("records", str(TENANT), str(USER), [str(OWN_FACILITY)])] + + +def test_drilldown_offset_pages(monkeypatch, calls): + resp = _client(monkeypatch, calls).get( + f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=missing_kde&limit=1&offset=1" + ) + assert [item["record_id"] for item in resp.json()["items"]] == ["r2"] + + +def test_drilldown_lists_unlinked_lots(monkeypatch, calls): + resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=unlinked_lot") + assert resp.status_code == 200 + assert [item["lot_code"] for item in resp.json()["items"]] == ["TLC-1"] + + +@pytest.mark.parametrize("facility", [FOREIGN_FACILITY, OTHER_TENANT_FACILITY]) +def test_drilldown_refuses_facility_not_owned_in_tenant(monkeypatch, calls, facility): + resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality/{facility}/records?metric=missing_kde") + assert resp.status_code == 404 + assert calls == [] + + +def test_drilldown_rejects_unavailable_metrics(monkeypatch, calls): + for metric in ("late_cte", "epcis_failed", "anything"): + resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric={metric}") + assert resp.status_code == 422 + assert calls == [] + + +def test_drilldown_rejects_bad_facility_id_and_limit(monkeypatch, calls): + client = _client(monkeypatch, calls) + assert client.get("/v1/supplier/data-quality/not-a-uuid/records?metric=missing_kde").status_code == 400 + assert ( + client.get(f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=missing_kde&limit=201").status_code == 422 + ) + assert calls == [] diff --git a/services/shared/supplier_data_quality.py b/services/shared/supplier_data_quality.py new file mode 100644 index 000000000..50e05930c --- /dev/null +++ b/services/shared/supplier_data_quality.py @@ -0,0 +1,401 @@ +"""Per supplier data quality over a rolling window. + +A *supplier* is a supplier facility (``supplier_facilities.id``), the same +``supplier_key`` that ``public.data_gap_events`` uses. Four metrics are kept +separate on purpose; there is no combined score. + +1. ``missing_kde``: share of CTE records (``supplier_cte_events``) received in + the window that lack at least one required KDE. The required list is the + ingest webhook's ``REQUIRED_KDES_BY_CTE``, minus the KDEs a supplier record + carries as columns: the lot code (``lot_id``), the event date + (``event_time``) and the recording facility's own location + (``facility_id``). +2. ``unlinked_lot``: share of lot codes active in the window that cannot be + linked back through the chain. A lot is linked when it is the output of a + ``fsma.transformation_links`` row, when one of its records carries a lot + linkage KDE (the same fields the rules engine checks in + ``shared.rules.seeds``), or when it starts at this facility (harvesting or + first land based receiving). +3. ``late_cte``: unavailable. No agreed sending time is stored per supplier. +4. ``epcis_failed``: unavailable. EPCIS documents are not stored as files with + a validation result, and nothing ties an EPCIS upload to a facility. + +Read only. Every query filters on tenant, on the calling supplier user and on +that user's facility ids. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Mapping, Optional, Sequence + +from sqlalchemy import text + +MIN_RECORDS = 20 +DEFAULT_WINDOW_DAYS = 30 +MAX_WINDOW_DAYS = 365 + +METRIC_MISSING_KDE = "missing_kde" +METRIC_UNLINKED_LOT = "unlinked_lot" +METRIC_LATE_CTE = "late_cte" +METRIC_EPCIS_FAILED = "epcis_failed" +DRILLDOWN_METRICS = (METRIC_MISSING_KDE, METRIC_UNLINKED_LOT) + +LATE_CTE_LABEL = "Our measure, based on the agreed sending time with this supplier; not a regulatory requirement" +LATE_CTE_UNAVAILABLE_REASON = ( + "No agreed sending time is recorded for this supplier, so late records cannot be measured." +) +EPCIS_UNAVAILABLE_REASON = ( + "EPCIS uploads are not stored as files with a validation result linked to a facility, " + "so failing files cannot be counted." +) + +# Copy of services/ingestion/app/webhook_models.py REQUIRED_KDES_BY_CTE, keyed +# by supplier CTE name. The admin service cannot import the ingestion package +# (both are named ``app``); services/shared/tests/test_supplier_data_quality.py fails if +# the two tables drift. +WEBHOOK_REQUIRED_KDES: dict[str, tuple[str, ...]] = { + "harvesting": ( + "traceability_lot_code", + "product_description", + "quantity", + "unit_of_measure", + "harvest_date", + "location_name", + "reference_document", + ), + "cooling": ( + "traceability_lot_code", + "product_description", + "quantity", + "unit_of_measure", + "cooling_date", + "location_name", + "reference_document", + ), + "initial_packing": ( + "traceability_lot_code", + "product_description", + "quantity", + "unit_of_measure", + "packing_date", + "location_name", + "reference_document", + "harvester_business_name", + "harvester_business_phone", + ), + "first_receiver": ( + "traceability_lot_code", + "product_description", + "quantity", + "unit_of_measure", + "landing_date", + "receiving_location", + "reference_document", + ), + "shipping": ( + "traceability_lot_code", + "product_description", + "quantity", + "unit_of_measure", + "ship_date", + "ship_from_location", + "ship_to_location", + "reference_document", + "tlc_source_reference", + ), + "receiving": ( + "traceability_lot_code", + "product_description", + "quantity", + "unit_of_measure", + "receive_date", + "receiving_location", + "immediate_previous_source", + "reference_document", + "tlc_source_reference", + ), + "transforming": ( + "traceability_lot_code", + "product_description", + "quantity", + "unit_of_measure", + "transformation_date", + "location_name", + "reference_document", + ), +} + +# Supplied by supplier_cte_events columns, not kde_data: lot_id, event_time, +# and facility_id (the recording facility's own location). +COLUMN_BACKED_KDES = frozenset( + { + "traceability_lot_code", + "harvest_date", + "cooling_date", + "packing_date", + "landing_date", + "ship_date", + "receive_date", + "transformation_date", + "location_name", + "receiving_location", + "ship_from_location", + } +) + +# Lot linkage fields from shared.rules.seeds (receiving, shipping and +# transformation lot_linkage / kde_presence rules), plus the harvester the +# initial packer must record (21 CFR 1.1330(a)(7)). +LINKAGE_KDES = ( + "tlc_source_reference", + "tlc_source_gln", + "immediate_previous_source", + "input_lot_codes", + "input_traceability_lot_codes", + "input_tlcs", + "harvester_business_name", +) +# The chain starts at these CTEs; there is nothing upstream to link to. +ORIGIN_CTES = frozenset({"harvesting", "first_receiver"}) + +_CTE_ALIASES = { + "transformation": "transforming", + "first_land_based_receiving": "first_receiver", +} + + +# --------------------------------------------------------------------------- +# Pure functions +# --------------------------------------------------------------------------- + + +def normalize_cte(cte_type: Optional[str]) -> str: + value = (cte_type or "").strip().lower() + return _CTE_ALIASES.get(value, value) + + +def _present(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return value.strip() != "" + if isinstance(value, (list, tuple, dict, set)): + return len(value) > 0 + return True + + +def required_record_kdes(cte_type: Optional[str]) -> tuple[str, ...]: + """KDEs a supplier record must carry in ``kde_data`` for its CTE.""" + required = WEBHOOK_REQUIRED_KDES.get(normalize_cte(cte_type), ()) + return tuple(name for name in required if name not in COLUMN_BACKED_KDES) + + +def missing_kdes(cte_type: Optional[str], kde_data: Optional[Mapping[str, Any]]) -> list[str]: + data = kde_data if isinstance(kde_data, Mapping) else {} + return [name for name in required_record_kdes(cte_type) if not _present(data.get(name))] + + +def lot_is_linked( + records: Sequence[tuple[Optional[str], Optional[Mapping[str, Any]]]], + is_transformation_output: bool, +) -> bool: + """True when a lot links back: transformation output, linkage KDE, or origin CTE. + + ``records`` is every (cte_type, kde_data) recorded for the lot. + """ + if is_transformation_output: + return True + for cte_type, kde_data in records: + if normalize_cte(cte_type) in ORIGIN_CTES: + return True + data = kde_data if isinstance(kde_data, Mapping) else {} + if any(_present(data.get(name)) for name in LINKAGE_KDES): + return True + return False + + +def share_metric(key: str, numerator: int, denominator: int, min_records: int = MIN_RECORDS) -> dict[str, Any]: + """Numerator and denominator always; a share only from ``min_records`` up.""" + enough = denominator >= min_records + return { + "key": key, + "status": "ok" if enough else "not_enough_data", + "numerator": numerator, + "denominator": denominator, + "share": (numerator / denominator) if enough else None, + "reason": None, + } + + +def unavailable_metric(key: str, reason: str) -> dict[str, Any]: + return { + "key": key, + "status": "unavailable", + "numerator": None, + "denominator": None, + "share": None, + "reason": reason, + } + + +def window_start(now: datetime, window_days: int) -> datetime: + return now - timedelta(days=window_days) + + +def flag_records(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Records missing at least one required KDE, with the missing names.""" + flagged: list[dict[str, Any]] = [] + for row in rows: + missing = missing_kdes(row["cte_type"], row["kde_data"]) + if missing: + flagged.append({**row, "missing_kdes": missing}) + return flagged + + +def group_lots(rows: Sequence[Mapping[str, Any]]) -> dict[str, dict[str, Any]]: + """Collapse one row per (lot, record) into one entry per lot id.""" + lots: dict[str, dict[str, Any]] = {} + for row in rows: + lot_id = str(row["lot_id"]) + lot = lots.setdefault( + lot_id, + { + "lot_id": lot_id, + "lot_code": row["lot_code"], + "facility_id": str(row["facility_id"]), + "is_transformation_output": bool(row["is_transformation_output"]), + "records": [], + }, + ) + if row.get("cte_type") is not None: + lot["records"].append((row["cte_type"], row["kde_data"])) + return lots + + +def unlinked_lots(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + return [ + {"lot_id": lot["lot_id"], "lot_code": lot["lot_code"], "facility_id": lot["facility_id"]} + for lot in group_lots(rows).values() + if not lot_is_linked(lot["records"], lot["is_transformation_output"]) + ] + + +def summarize_facility( + record_rows: Sequence[Mapping[str, Any]], + lot_rows: Sequence[Mapping[str, Any]], +) -> dict[str, dict[str, Any]]: + """The four metrics for one facility, keyed by metric.""" + lot_count = len(group_lots(lot_rows)) + return { + METRIC_MISSING_KDE: share_metric(METRIC_MISSING_KDE, len(flag_records(record_rows)), len(record_rows)), + METRIC_UNLINKED_LOT: share_metric(METRIC_UNLINKED_LOT, len(unlinked_lots(lot_rows)), lot_count), + METRIC_LATE_CTE: unavailable_metric(METRIC_LATE_CTE, LATE_CTE_UNAVAILABLE_REASON), + METRIC_EPCIS_FAILED: unavailable_metric(METRIC_EPCIS_FAILED, EPCIS_UNAVAILABLE_REASON), + } + + +# --------------------------------------------------------------------------- +# Database (SELECT only) +# --------------------------------------------------------------------------- + +_RECORD_ROWS_SQL = text( + """ + SELECT e.id AS record_id, + e.facility_id, + e.cte_type, + e.kde_data, + e.event_time, + e.created_at, + l.tlc_code AS lot_code + FROM supplier_cte_events e + JOIN supplier_traceability_lots l + ON l.id = e.lot_id + AND l.tenant_id = e.tenant_id + WHERE e.tenant_id = CAST(:tid AS uuid) + AND e.supplier_user_id = CAST(:uid AS uuid) + AND e.facility_id = ANY(CAST(:fids AS uuid[])) + AND e.created_at >= :since + ORDER BY e.created_at DESC, e.id + """ +) + +# One row per (active lot, record of that lot). A lot is active when it has a +# record received in the window; linkage looks at all of the caller's records +# for it, any age. +_LOT_ROWS_SQL = text( + """ + WITH active AS ( + SELECT DISTINCT l.id, l.tlc_code, l.facility_id + FROM supplier_traceability_lots l + JOIN supplier_cte_events w + ON w.lot_id = l.id + AND w.tenant_id = l.tenant_id + WHERE l.tenant_id = CAST(:tid AS uuid) + AND l.supplier_user_id = CAST(:uid AS uuid) + AND l.facility_id = ANY(CAST(:fids AS uuid[])) + AND w.created_at >= :since + ) + SELECT a.id AS lot_id, + a.tlc_code AS lot_code, + a.facility_id, + e.cte_type, + e.kde_data, + EXISTS ( + SELECT 1 + FROM fsma.transformation_links t + WHERE t.tenant_id = CAST(:tid AS uuid) + AND t.output_tlc = a.tlc_code + ) AS is_transformation_output + FROM active a + JOIN supplier_cte_events e + ON e.lot_id = a.id + AND e.tenant_id = CAST(:tid AS uuid) + AND e.supplier_user_id = CAST(:uid AS uuid) + ORDER BY a.tlc_code, a.id + """ +) + + +def _params(tenant_id: str, supplier_user_id: str, facility_ids: Sequence[str], since: datetime) -> dict[str, Any]: + return {"tid": tenant_id, "uid": supplier_user_id, "fids": list(facility_ids), "since": since} + + +def fetch_record_rows( + db_session: Any, tenant_id: str, supplier_user_id: str, facility_ids: Sequence[str], since: datetime +) -> list[dict[str, Any]]: + if not facility_ids: + return [] + result = db_session.execute(_RECORD_ROWS_SQL, _params(tenant_id, supplier_user_id, facility_ids, since)) + return [dict(row) for row in result.mappings().all()] + + +def fetch_lot_rows( + db_session: Any, tenant_id: str, supplier_user_id: str, facility_ids: Sequence[str], since: datetime +) -> list[dict[str, Any]]: + if not facility_ids: + return [] + result = db_session.execute(_LOT_ROWS_SQL, _params(tenant_id, supplier_user_id, facility_ids, since)) + return [dict(row) for row in result.mappings().all()] + + +def _by_facility(rows: Sequence[Mapping[str, Any]], facility_ids: Sequence[str]) -> dict[str, list[Mapping[str, Any]]]: + grouped: dict[str, list[Mapping[str, Any]]] = {key: [] for key in facility_ids} + for row in rows: + grouped.setdefault(str(row["facility_id"]), []).append(row) + return grouped + + +def fetch_supplier_quality( + db_session: Any, + tenant_id: str, + supplier_user_id: str, + facility_ids: Sequence[str], + since: datetime, +) -> dict[str, dict[str, dict[str, Any]]]: + """Per facility metrics for ``facility_ids`` (every id is present).""" + records = _by_facility( + fetch_record_rows(db_session, tenant_id, supplier_user_id, facility_ids, since), facility_ids + ) + lots = _by_facility(fetch_lot_rows(db_session, tenant_id, supplier_user_id, facility_ids, since), facility_ids) + return {key: summarize_facility(records[key], lots[key]) for key in facility_ids} diff --git a/services/shared/tests/test_supplier_data_quality.py b/services/shared/tests/test_supplier_data_quality.py new file mode 100644 index 000000000..101dcb6c1 --- /dev/null +++ b/services/shared/tests/test_supplier_data_quality.py @@ -0,0 +1,242 @@ +"""Supplier data quality: pure logic only. + +No database. Row dicts stand in for the SELECT results. +""" + +from __future__ import annotations + +import importlib.util +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from shared.supplier_data_quality import ( + COLUMN_BACKED_KDES, + METRIC_EPCIS_FAILED, + METRIC_LATE_CTE, + METRIC_MISSING_KDE, + METRIC_UNLINKED_LOT, + MIN_RECORDS, + WEBHOOK_REQUIRED_KDES, + fetch_supplier_quality, + flag_records, + group_lots, + lot_is_linked, + missing_kdes, + required_record_kdes, + share_metric, + summarize_facility, + unavailable_metric, + unlinked_lots, + window_start, +) + +REPO_ROOT = Path(__file__).resolve().parents[3] +WEBHOOK_MODELS = REPO_ROOT / "services" / "ingestion" / "app" / "webhook_models.py" + +COMPLETE_SHIPPING = { + "product_description": "Baby Spinach", + "quantity": 10, + "unit_of_measure": "cases", + "ship_to_location": "DC 4", + "reference_document": "BOL-1", + "tlc_source_reference": "Farm A", +} + + +def _record(cte_type: str, kde_data: dict, lot_code: str = "LOT-1") -> dict: + return { + "record_id": uuid.uuid4(), + "facility_id": "fac-1", + "cte_type": cte_type, + "kde_data": kde_data, + "event_time": None, + "created_at": None, + "lot_code": lot_code, + } + + +def _lot_row(lot_id: str, cte_type: str | None, kde_data: dict | None, output: bool = False) -> dict: + return { + "lot_id": lot_id, + "lot_code": f"TLC-{lot_id}", + "facility_id": "fac-1", + "cte_type": cte_type, + "kde_data": kde_data, + "is_transformation_output": output, + } + + +# --- required KDEs ----------------------------------------------------------- + + +def test_required_kdes_match_ingest_webhook_table(): + """The copied table must equal REQUIRED_KDES_BY_CTE, CTE for CTE.""" + spec = importlib.util.spec_from_file_location("_ingestion_webhook_models", WEBHOOK_MODELS) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + aliases = {"transformation": "transforming", "first_land_based_receiving": "first_receiver"} + webhook = {aliases.get(cte.value, cte.value): tuple(kdes) for cte, kdes in module.REQUIRED_KDES_BY_CTE.items()} + assert webhook == WEBHOOK_REQUIRED_KDES + + +def test_column_backed_kdes_are_never_required_in_kde_data(): + for cte in WEBHOOK_REQUIRED_KDES: + assert not set(required_record_kdes(cte)) & COLUMN_BACKED_KDES + + +def test_receiving_keeps_source_kdes_and_drops_column_kdes(): + assert required_record_kdes("receiving") == ( + "product_description", + "quantity", + "unit_of_measure", + "immediate_previous_source", + "reference_document", + "tlc_source_reference", + ) + + +def test_cte_aliases_resolve(): + assert required_record_kdes("transformation") == required_record_kdes("transforming") + assert required_record_kdes(" First_Land_Based_Receiving ") == required_record_kdes("first_receiver") + + +def test_unknown_cte_requires_nothing(): + assert required_record_kdes("growing") == () + assert missing_kdes(None, {}) == [] + + +# --- missing KDE ------------------------------------------------------------- + + +def test_complete_shipping_record_has_no_missing_kdes(): + assert missing_kdes("shipping", COMPLETE_SHIPPING) == [] + + +@pytest.mark.parametrize("blank", [None, "", " ", [], {}]) +def test_blank_values_count_as_missing(blank): + data = {**COMPLETE_SHIPPING, "reference_document": blank} + assert missing_kdes("shipping", data) == ["reference_document"] + + +def test_zero_quantity_is_present(): + assert "quantity" not in missing_kdes("shipping", {**COMPLETE_SHIPPING, "quantity": 0}) + + +def test_non_mapping_kde_data_misses_everything(): + assert missing_kdes("cooling", None) == list(required_record_kdes("cooling")) + assert missing_kdes("cooling", "not a dict") == list(required_record_kdes("cooling")) + + +def test_flag_records_keeps_only_failing_rows_with_names(): + rows = [_record("shipping", COMPLETE_SHIPPING), _record("shipping", {"quantity": 1})] + flagged = flag_records(rows) + assert len(flagged) == 1 + assert flagged[0]["record_id"] == rows[1]["record_id"] + assert "product_description" in flagged[0]["missing_kdes"] + + +# --- lot linkage ------------------------------------------------------------- + + +def test_lot_linked_by_transformation_output(): + assert lot_is_linked([("shipping", {})], is_transformation_output=True) + + +@pytest.mark.parametrize( + "field", ["tlc_source_reference", "tlc_source_gln", "immediate_previous_source", "input_lot_codes"] +) +def test_lot_linked_by_linkage_kde(field): + assert lot_is_linked([("shipping", {}), ("receiving", {field: "X"})], False) + + +@pytest.mark.parametrize("cte", ["harvesting", "first_receiver", "first_land_based_receiving"]) +def test_origin_cte_links_lot(cte): + assert lot_is_linked([(cte, {})], False) + + +def test_lot_without_any_link_is_unlinked(): + assert not lot_is_linked([("shipping", {"tlc_source_reference": " "}), ("cooling", {})], False) + assert not lot_is_linked([], False) + + +def test_group_lots_collapses_records_per_lot(): + rows = [_lot_row("a", "shipping", {}), _lot_row("a", "receiving", {}), _lot_row("b", "cooling", {})] + lots = group_lots(rows) + assert set(lots) == {"a", "b"} + assert len(lots["a"]["records"]) == 2 + + +def test_unlinked_lots_returns_ids_and_codes(): + rows = [ + _lot_row("a", "shipping", {}), + _lot_row("b", "receiving", {"tlc_source_reference": "Farm"}), + _lot_row("c", "shipping", {}, output=True), + ] + assert unlinked_lots(rows) == [{"lot_id": "a", "lot_code": "TLC-a", "facility_id": "fac-1"}] + + +# --- share and thresholds ---------------------------------------------------- + + +def test_share_below_min_records_is_not_enough_data_but_keeps_counts(): + metric = share_metric(METRIC_MISSING_KDE, 3, MIN_RECORDS - 1) + assert metric["status"] == "not_enough_data" + assert metric["share"] is None + assert (metric["numerator"], metric["denominator"]) == (3, MIN_RECORDS - 1) + + +def test_share_at_min_records_is_computed(): + metric = share_metric(METRIC_MISSING_KDE, 5, MIN_RECORDS) + assert metric["status"] == "ok" + assert metric["share"] == 5 / MIN_RECORDS + + +def test_zero_denominator_is_not_enough_data(): + assert share_metric(METRIC_UNLINKED_LOT, 0, 0)["status"] == "not_enough_data" + + +def test_min_records_is_twenty(): + assert MIN_RECORDS == 20 + + +def test_unavailable_metric_has_reason_and_no_counts(): + metric = unavailable_metric(METRIC_LATE_CTE, "why") + assert metric == { + "key": METRIC_LATE_CTE, + "status": "unavailable", + "numerator": None, + "denominator": None, + "share": None, + "reason": "why", + } + + +def test_window_start(): + now = datetime(2026, 10, 24, tzinfo=timezone.utc) + assert window_start(now, 30) == now - timedelta(days=30) + + +# --- facility summary -------------------------------------------------------- + + +def test_summarize_facility_keeps_four_separate_metrics(): + records = [_record("shipping", COMPLETE_SHIPPING) for _ in range(15)] + [_record("shipping", {}) for _ in range(5)] + lots = [_lot_row(str(i), "shipping", {}) for i in range(3)] + summary = summarize_facility(records, lots) + + assert set(summary) == {METRIC_MISSING_KDE, METRIC_UNLINKED_LOT, METRIC_LATE_CTE, METRIC_EPCIS_FAILED} + assert summary[METRIC_MISSING_KDE]["share"] == 5 / 20 + assert summary[METRIC_UNLINKED_LOT]["status"] == "not_enough_data" + assert (summary[METRIC_UNLINKED_LOT]["numerator"], summary[METRIC_UNLINKED_LOT]["denominator"]) == (3, 3) + assert summary[METRIC_LATE_CTE]["status"] == "unavailable" + assert summary[METRIC_EPCIS_FAILED]["status"] == "unavailable" + + +def test_fetch_supplier_quality_without_facilities_runs_no_query(): + class NoQuery: + def execute(self, *args, **kwargs): # pragma: no cover (must not run) + raise AssertionError("no query expected") + + assert fetch_supplier_quality(NoQuery(), "t", "u", [], datetime.now(timezone.utc)) == {} diff --git a/tests/security/test_route_security_manifest.py b/tests/security/test_route_security_manifest.py index c7fea970a..8d734b306 100644 --- a/tests/security/test_route_security_manifest.py +++ b/tests/security/test_route_security_manifest.py @@ -70,7 +70,10 @@ def test_checked_in_manifest_matches_live_route_and_dependency_graph(manifest): # +1 global-sysadmin: admin:PATCH:/admin/organizations/{org_id} (rename). # +2 tenant-scoped (SEL-421, #3492): admin:GET:/v1/supplier/facilities/{facility_id}/removal-check # and admin:DELETE:/v1/supplier/facilities/{facility_id}, owner-only, tenant-filtered. - "enabled_operation_count": 254, + # +1 tenant-scoped (#3572): admin:GET:/v1/supplier/gap-time-to-fix. + # +2 tenant-scoped: admin:GET:/v1/supplier/data-quality and + # admin:GET:/v1/supplier/data-quality/{facility_id}/records, owner-only, tenant-filtered. + "enabled_operation_count": 257, # 178/161 includes graph:GET:/api/v1/provisions/by-request: it is added # to the graph v1_router AFTER router.include_router(v1_router), so the # eager route copies of fastapi < 0.137 (under which the previous @@ -94,7 +97,7 @@ def test_checked_in_manifest_matches_live_route_and_dependency_graph(manifest): "enabled_by_classification": { "global-sysadmin": 16, "public": 30, - "tenant-scoped": 201, + "tenant-scoped": 204, "workload-only": 7, }, } @@ -187,7 +190,7 @@ def test_dual_session_routes_record_cookie_tenancy_and_csrf_adapter(manifest): dual = [item for item in manifest["enabled_operations"] if item["test_adapter"] == "tenant_dual_session_csrf"] mutations = [item for item in dual if item["method"] in {"POST", "PUT", "PATCH", "DELETE"}] - assert len(dual) == 51 + assert len(dual) == 54 assert len(mutations) == 23 assert all(item["authentication"] == "bearer-token-or-opaque-session-cookie" for item in dual) assert all(item["storage_boundary"] == "admin-postgres:jwt-tenant-or-session-membership-tenant" for item in dual) From f0dd136c58b157a9a6d5bf2be3a5b3d621ebccfa Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:42:48 -0700 Subject: [PATCH 05/10] test(suppliers): generate ids at runtime in data quality tests Replace hand written UUIDs, record ids, lot codes and facility ids with uuid4 and crypto.randomUUID values, and assert on values derived from the generated inputs. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../components/supplier-data-quality.test.tsx | 54 +++++++----- .../test_supplier_data_quality_routes.py | 87 ++++++++++--------- .../tests/test_supplier_data_quality.py | 35 +++++--- 3 files changed, 98 insertions(+), 78 deletions(-) diff --git a/frontend/src/__tests__/components/supplier-data-quality.test.tsx b/frontend/src/__tests__/components/supplier-data-quality.test.tsx index adc3f0f7b..4b55be4c0 100644 --- a/frontend/src/__tests__/components/supplier-data-quality.test.tsx +++ b/frontend/src/__tests__/components/supplier-data-quality.test.tsx @@ -26,11 +26,19 @@ const metric = (overrides: Partial): SupplierQualityMetri ...overrides, }); -const summary = (metrics: SupplierQualityMetric[]): SupplierDataQuality => ({ supplier_key: 'fac-1', metrics }); +const FACILITY_ID = crypto.randomUUID(); +const MIN_RECORDS = 20; +// Counts are derived from MIN_RECORDS so one metric clears the minimum and one does not. +const MISSING = { numerator: 1 + (Date.now() % 5), denominator: MIN_RECORDS * 2 }; +const UNLINKED = { numerator: 2, denominator: MIN_RECORDS - 1 }; +const missingShare = MISSING.numerator / MISSING.denominator; +const formatShare = (share: number) => `${Math.round(share * 1000) / 10}%`; + +const summary = (metrics: SupplierQualityMetric[]): SupplierDataQuality => ({ supplier_key: FACILITY_ID, metrics }); const fourMetrics = summary([ - metric({ key: 'missing_kde', status: 'ok', numerator: 5, denominator: 40, share: 0.125 }), - metric({ key: 'unlinked_lot', status: 'not_enough_data', numerator: 2, denominator: 7, share: null }), + metric({ key: 'missing_kde', status: 'ok', ...MISSING, share: missingShare }), + metric({ key: 'unlinked_lot', status: 'not_enough_data', ...UNLINKED, share: null }), metric({ key: 'late_cte', status: 'unavailable', numerator: null, denominator: null, reason: 'No agreed sending time is recorded for this supplier, so late records cannot be measured.' }), metric({ key: 'epcis_failed', status: 'unavailable', numerator: null, denominator: null, reason: 'No EPCIS file record.' }), ]); @@ -38,18 +46,18 @@ const fourMetrics = summary([ function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false) { return render( , ); } const page = (overrides: Partial): SupplierQualityRecordsResponse => ({ - supplier_key: 'fac-1', + supplier_key: FACILITY_ID, metric: 'missing_kde', window_days: 30, total: 0, @@ -66,16 +74,16 @@ describe('DataQuality', () => { it('shows count of denominator and a share for each metric, with no combined score', () => { renderQuality(); - expect(screen.getByText('5 of 40 records (12.5%)')).toBeInTheDocument(); + expect(screen.getByText(`${MISSING.numerator} of ${MISSING.denominator} records (${formatShare(missingShare)})`)).toBeInTheDocument(); expect(screen.queryByText(/score/i)).not.toBeInTheDocument(); expect(screen.getByText('Data quality, records received in the last 30 days')).toBeInTheDocument(); }); it('shows Not enough data below the minimum instead of a share', () => { renderQuality(); - expect(screen.getByText(/2 of 7 lot codes/)).toBeInTheDocument(); - expect(screen.getByText('Not enough data (fewer than 20)')).toBeInTheDocument(); - expect(screen.queryByText(/28\.6%/)).not.toBeInTheDocument(); + expect(screen.getByText(new RegExp(`${UNLINKED.numerator} of ${UNLINKED.denominator} lot codes`))).toBeInTheDocument(); + expect(screen.getByText(`Not enough data (fewer than ${MIN_RECORDS})`)).toBeInTheDocument(); + expect(screen.queryByText(formatShare(UNLINKED.numerator / UNLINKED.denominator))).not.toBeInTheDocument(); }); it('shows unavailable metrics with their reason and the late CTE label', () => { @@ -96,15 +104,16 @@ describe('DataQuality', () => { }); it('loads the exact records for a metric and pages with Show more', async () => { + const [first, second] = [crypto.randomUUID(), crypto.randomUUID()]; mocks.getSupplierDataQualityRecords .mockResolvedValueOnce( page({ total: 2, items: [ { - record_id: 'rec-1', + record_id: first, lot_id: null, - lot_code: 'TLC-1', + lot_code: crypto.randomUUID(), cte_type: 'shipping', event_time: null, received_at: null, @@ -119,9 +128,9 @@ describe('DataQuality', () => { offset: 1, items: [ { - record_id: 'rec-2', + record_id: second, lot_id: null, - lot_code: 'TLC-2', + lot_code: crypto.randomUUID(), cte_type: 'receiving', event_time: null, received_at: null, @@ -133,17 +142,17 @@ describe('DataQuality', () => { renderQuality(); fireEvent.click(screen.getAllByRole('button', { name: 'View records' })[0]); - await waitFor(() => expect(screen.getByText('rec-1')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText(first)).toBeInTheDocument()); expect(screen.getByText('Missing: reference_document, quantity')).toBeInTheDocument(); - expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith('fac-1', 'missing_kde', { + expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith(FACILITY_ID, 'missing_kde', { windowDays: 30, limit: DRILLDOWN_PAGE_SIZE, offset: 0, }); fireEvent.click(screen.getByRole('button', { name: 'Show more (1 of 2)' })); - await waitFor(() => expect(screen.getByText('rec-2')).toBeInTheDocument()); - expect(mocks.getSupplierDataQualityRecords).toHaveBeenLastCalledWith('fac-1', 'missing_kde', { + await waitFor(() => expect(screen.getByText(second)).toBeInTheDocument()); + expect(mocks.getSupplierDataQualityRecords).toHaveBeenLastCalledWith(FACILITY_ID, 'missing_kde', { windowDays: 30, limit: DRILLDOWN_PAGE_SIZE, offset: 1, @@ -152,6 +161,7 @@ describe('DataQuality', () => { }); it('lists unlinked lot codes', async () => { + const lotCode = crypto.randomUUID(); mocks.getSupplierDataQualityRecords.mockResolvedValueOnce( page({ metric: 'unlinked_lot', @@ -159,8 +169,8 @@ describe('DataQuality', () => { items: [ { record_id: null, - lot_id: 'lot-9', - lot_code: 'TLC-9', + lot_id: crypto.randomUUID(), + lot_code: lotCode, cte_type: null, event_time: null, received_at: null, @@ -171,8 +181,8 @@ describe('DataQuality', () => { ); renderQuality(); fireEvent.click(screen.getAllByRole('button', { name: 'View records' })[1]); - await waitFor(() => expect(screen.getByText('Lot TLC-9')).toBeInTheDocument()); - expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith('fac-1', 'unlinked_lot', expect.anything()); + await waitFor(() => expect(screen.getByText(`Lot ${lotCode}`)).toBeInTheDocument()); + expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith(FACILITY_ID, 'unlinked_lot', expect.anything()); }); it('says so when the records request fails', async () => { diff --git a/services/admin/tests/test_supplier_data_quality_routes.py b/services/admin/tests/test_supplier_data_quality_routes.py index 8b5341633..b37412f9f 100644 --- a/services/admin/tests/test_supplier_data_quality_routes.py +++ b/services/admin/tests/test_supplier_data_quality_routes.py @@ -7,19 +7,44 @@ from __future__ import annotations from types import SimpleNamespace -from uuid import UUID +from uuid import uuid4 import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -TENANT = UUID("00000000-0000-0000-0000-000000000001") -OTHER_TENANT = UUID("00000000-0000-0000-0000-000000000009") -USER = UUID("00000000-0000-0000-0000-000000000002") -OTHER_USER = UUID("00000000-0000-0000-0000-000000000003") -OWN_FACILITY = UUID("00000000-0000-0000-0000-0000000000f1") -FOREIGN_FACILITY = UUID("00000000-0000-0000-0000-0000000000f2") -OTHER_TENANT_FACILITY = UUID("00000000-0000-0000-0000-0000000000f3") +TENANT = uuid4() +OTHER_TENANT = uuid4() +USER = uuid4() +OTHER_USER = uuid4() +OWN_FACILITY = uuid4() +FOREIGN_FACILITY = uuid4() +OTHER_TENANT_FACILITY = uuid4() + +# Two shipping records with no KDEs on the owned facility, and one lot with no +# upstream link. Ids and lot codes are generated per run. +RECORD_ROWS = [ + { + "record_id": str(uuid4()), + "facility_id": str(OWN_FACILITY), + "cte_type": "shipping", + "kde_data": {}, + "event_time": None, + "created_at": None, + "lot_code": str(uuid4()), + } + for _ in range(2) +] +LOT_ROWS = [ + { + "lot_id": str(uuid4()), + "lot_code": str(uuid4()), + "facility_id": str(OWN_FACILITY), + "cte_type": "shipping", + "kde_data": {}, + "is_transformation_output": False, + } +] class FakeSession: @@ -51,39 +76,11 @@ def _client(monkeypatch, calls, tenant=TENANT): def fake_records(_db, tenant_id, user_id, facility_ids, since): calls.append(("records", tenant_id, user_id, list(facility_ids))) - return [ - { - "record_id": "r1", - "facility_id": str(OWN_FACILITY), - "cte_type": "shipping", - "kde_data": {}, - "event_time": None, - "created_at": None, - "lot_code": "TLC-1", - }, - { - "record_id": "r2", - "facility_id": str(OWN_FACILITY), - "cte_type": "shipping", - "kde_data": {}, - "event_time": None, - "created_at": None, - "lot_code": "TLC-2", - }, - ] + return [dict(row) for row in RECORD_ROWS] def fake_lots(_db, tenant_id, user_id, facility_ids, since): calls.append(("lots", tenant_id, user_id, list(facility_ids))) - return [ - { - "lot_id": "l1", - "lot_code": "TLC-1", - "facility_id": str(OWN_FACILITY), - "cte_type": "shipping", - "kde_data": {}, - "is_transformation_output": False, - } - ] + return [dict(row) for row in LOT_ROWS] monkeypatch.setattr(routes, "fetch_record_rows", fake_records) monkeypatch.setattr(routes, "fetch_lot_rows", fake_lots) @@ -112,7 +109,11 @@ def test_summary_returns_four_separate_metrics_for_owned_facilities(monkeypatch, assert supplier["supplier_key"] == str(OWN_FACILITY) assert [m["key"] for m in supplier["metrics"]] == ["missing_kde", "unlinked_lot", "late_cte", "epcis_failed"] missing = supplier["metrics"][0] - assert (missing["status"], missing["numerator"], missing["denominator"]) == ("not_enough_data", 2, 2) + assert (missing["status"], missing["numerator"], missing["denominator"]) == ( + "not_enough_data", + len(RECORD_ROWS), + len(RECORD_ROWS), + ) assert supplier["metrics"][2]["status"] == "unavailable" assert supplier["metrics"][3]["status"] == "unavailable" assert all(call[1:3] == (str(TENANT), str(USER)) for call in calls) @@ -136,8 +137,8 @@ def test_drilldown_lists_missing_kde_records(monkeypatch, calls): ) assert resp.status_code == 200 body = resp.json() - assert (body["total"], body["limit"], body["offset"]) == (2, 1, 0) - assert [item["record_id"] for item in body["items"]] == ["r1"] + assert (body["total"], body["limit"], body["offset"]) == (len(RECORD_ROWS), 1, 0) + assert [item["record_id"] for item in body["items"]] == [RECORD_ROWS[0]["record_id"]] assert "product_description" in body["items"][0]["missing_kdes"] assert calls == [("records", str(TENANT), str(USER), [str(OWN_FACILITY)])] @@ -146,13 +147,13 @@ def test_drilldown_offset_pages(monkeypatch, calls): resp = _client(monkeypatch, calls).get( f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=missing_kde&limit=1&offset=1" ) - assert [item["record_id"] for item in resp.json()["items"]] == ["r2"] + assert [item["record_id"] for item in resp.json()["items"]] == [RECORD_ROWS[1]["record_id"]] def test_drilldown_lists_unlinked_lots(monkeypatch, calls): resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=unlinked_lot") assert resp.status_code == 200 - assert [item["lot_code"] for item in resp.json()["items"]] == ["TLC-1"] + assert [item["lot_code"] for item in resp.json()["items"]] == [LOT_ROWS[0]["lot_code"]] @pytest.mark.parametrize("facility", [FOREIGN_FACILITY, OTHER_TENANT_FACILITY]) diff --git a/services/shared/tests/test_supplier_data_quality.py b/services/shared/tests/test_supplier_data_quality.py index 101dcb6c1..69949c8c5 100644 --- a/services/shared/tests/test_supplier_data_quality.py +++ b/services/shared/tests/test_supplier_data_quality.py @@ -41,27 +41,32 @@ "unit_of_measure": "cases", "ship_to_location": "DC 4", "reference_document": "BOL-1", - "tlc_source_reference": "Farm A", + "tlc_source_reference": str(uuid.uuid4()), } -def _record(cte_type: str, kde_data: dict, lot_code: str = "LOT-1") -> dict: +FACILITY_ID = str(uuid.uuid4()) +# One generated lot code per generated lot id. +LOT_CODES: dict[str, str] = {} + + +def _record(cte_type: str, kde_data: dict, lot_code: str | None = None) -> dict: return { "record_id": uuid.uuid4(), - "facility_id": "fac-1", + "facility_id": FACILITY_ID, "cte_type": cte_type, "kde_data": kde_data, "event_time": None, "created_at": None, - "lot_code": lot_code, + "lot_code": lot_code if lot_code is not None else str(uuid.uuid4()), } def _lot_row(lot_id: str, cte_type: str | None, kde_data: dict | None, output: bool = False) -> dict: return { "lot_id": lot_id, - "lot_code": f"TLC-{lot_id}", - "facility_id": "fac-1", + "lot_code": LOT_CODES.setdefault(lot_id, str(uuid.uuid4())), + "facility_id": FACILITY_ID, "cte_type": cte_type, "kde_data": kde_data, "is_transformation_output": output, @@ -162,19 +167,23 @@ def test_lot_without_any_link_is_unlinked(): def test_group_lots_collapses_records_per_lot(): - rows = [_lot_row("a", "shipping", {}), _lot_row("a", "receiving", {}), _lot_row("b", "cooling", {})] + first, second = str(uuid.uuid4()), str(uuid.uuid4()) + rows = [_lot_row(first, "shipping", {}), _lot_row(first, "receiving", {}), _lot_row(second, "cooling", {})] lots = group_lots(rows) - assert set(lots) == {"a", "b"} - assert len(lots["a"]["records"]) == 2 + assert set(lots) == {first, second} + assert len(lots[first]["records"]) == sum(1 for row in rows if row["lot_id"] == first) def test_unlinked_lots_returns_ids_and_codes(): rows = [ - _lot_row("a", "shipping", {}), - _lot_row("b", "receiving", {"tlc_source_reference": "Farm"}), - _lot_row("c", "shipping", {}, output=True), + _lot_row(str(uuid.uuid4()), "shipping", {}), + _lot_row(str(uuid.uuid4()), "receiving", {"tlc_source_reference": str(uuid.uuid4())}), + _lot_row(str(uuid.uuid4()), "shipping", {}, output=True), + ] + unlinked = rows[0] + assert unlinked_lots(rows) == [ + {"lot_id": unlinked["lot_id"], "lot_code": unlinked["lot_code"], "facility_id": unlinked["facility_id"]} ] - assert unlinked_lots(rows) == [{"lot_id": "a", "lot_code": "TLC-a", "facility_id": "fac-1"}] # --- share and thresholds ---------------------------------------------------- From 4332a828a59a67b864c198ef94d7d1f8fdd6ad51 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:45:55 -0700 Subject: [PATCH 06/10] test(suppliers): derive data quality test counts from generated inputs Counts now come from lengths of generated id arrays or from MIN_RECORDS, DEFAULT_WINDOW_DAYS and MAX_WINDOW_DAYS. KDE values, reasons, tenant and user ids are generated. Removes the Date.now based numerator. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../components/supplier-data-quality.test.tsx | 48 ++++++++++------- .../test_supplier_data_quality_routes.py | 15 +++--- .../tests/test_supplier_data_quality.py | 52 ++++++++++++------- 3 files changed, 68 insertions(+), 47 deletions(-) diff --git a/frontend/src/__tests__/components/supplier-data-quality.test.tsx b/frontend/src/__tests__/components/supplier-data-quality.test.tsx index 4b55be4c0..27947d94f 100644 --- a/frontend/src/__tests__/components/supplier-data-quality.test.tsx +++ b/frontend/src/__tests__/components/supplier-data-quality.test.tsx @@ -28,9 +28,18 @@ const metric = (overrides: Partial): SupplierQualityMetri const FACILITY_ID = crypto.randomUUID(); const MIN_RECORDS = 20; -// Counts are derived from MIN_RECORDS so one metric clears the minimum and one does not. -const MISSING = { numerator: 1 + (Date.now() % 5), denominator: MIN_RECORDS * 2 }; -const UNLINKED = { numerator: 2, denominator: MIN_RECORDS - 1 }; +const WINDOW_DAYS = 30; +const uuids = (length: number) => Array.from({ length }, () => crypto.randomUUID()); +// Generated inputs: records clear MIN_RECORDS, lots fall one short of it. +// Every other record and lot is flagged, so counts come from these arrays. +const RECORD_IDS = uuids(MIN_RECORDS * 2); +const FLAGGED_RECORD_IDS = RECORD_IDS.filter((_, index) => index % 2 === 0); +const LOT_IDS = uuids(MIN_RECORDS - 1); +const UNLINKED_LOT_IDS = LOT_IDS.filter((_, index) => index % 2 === 0); +const MISSING = { numerator: FLAGGED_RECORD_IDS.length, denominator: RECORD_IDS.length }; +const UNLINKED = { numerator: UNLINKED_LOT_IDS.length, denominator: LOT_IDS.length }; +const LATE_REASON = crypto.randomUUID(); +const EPCIS_REASON = crypto.randomUUID(); const missingShare = MISSING.numerator / MISSING.denominator; const formatShare = (share: number) => `${Math.round(share * 1000) / 10}%`; @@ -39,8 +48,8 @@ const summary = (metrics: SupplierQualityMetric[]): SupplierDataQuality => ({ su const fourMetrics = summary([ metric({ key: 'missing_kde', status: 'ok', ...MISSING, share: missingShare }), metric({ key: 'unlinked_lot', status: 'not_enough_data', ...UNLINKED, share: null }), - metric({ key: 'late_cte', status: 'unavailable', numerator: null, denominator: null, reason: 'No agreed sending time is recorded for this supplier, so late records cannot be measured.' }), - metric({ key: 'epcis_failed', status: 'unavailable', numerator: null, denominator: null, reason: 'No EPCIS file record.' }), + metric({ key: 'late_cte', status: 'unavailable', numerator: null, denominator: null, reason: LATE_REASON }), + metric({ key: 'epcis_failed', status: 'unavailable', numerator: null, denominator: null, reason: EPCIS_REASON }), ]); function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false) { @@ -49,7 +58,7 @@ function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailab facilityId={FACILITY_ID} summary={data ?? undefined} unavailable={unavailable} - windowDays={30} + windowDays={WINDOW_DAYS} minRecords={MIN_RECORDS} lateCteLabel={LATE_LABEL} />, @@ -59,7 +68,7 @@ function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailab const page = (overrides: Partial): SupplierQualityRecordsResponse => ({ supplier_key: FACILITY_ID, metric: 'missing_kde', - window_days: 30, + window_days: WINDOW_DAYS, total: 0, limit: DRILLDOWN_PAGE_SIZE, offset: 0, @@ -76,7 +85,7 @@ describe('DataQuality', () => { renderQuality(); expect(screen.getByText(`${MISSING.numerator} of ${MISSING.denominator} records (${formatShare(missingShare)})`)).toBeInTheDocument(); expect(screen.queryByText(/score/i)).not.toBeInTheDocument(); - expect(screen.getByText('Data quality, records received in the last 30 days')).toBeInTheDocument(); + expect(screen.getByText(`Data quality, records received in the last ${WINDOW_DAYS} days`)).toBeInTheDocument(); }); it('shows Not enough data below the minimum instead of a share', () => { @@ -88,8 +97,8 @@ describe('DataQuality', () => { it('shows unavailable metrics with their reason and the late CTE label', () => { renderQuality(); - expect(screen.getByText(/Unavailable\. No agreed sending time is recorded/)).toBeInTheDocument(); - expect(screen.getByText(/Unavailable\. No EPCIS file record\./)).toBeInTheDocument(); + expect(screen.getByText(`Unavailable. ${LATE_REASON}`)).toBeInTheDocument(); + expect(screen.getByText(`Unavailable. ${EPCIS_REASON}`)).toBeInTheDocument(); expect(screen.getByText(LATE_LABEL)).toBeInTheDocument(); }); @@ -99,16 +108,17 @@ describe('DataQuality', () => { }); it('hides drill down when nothing is flagged', () => { - renderQuality(summary([metric({ key: 'missing_kde', numerator: 0, denominator: 30, share: 0 })])); + renderQuality(summary([metric({ key: 'missing_kde', numerator: 0, denominator: RECORD_IDS.length, share: 0 })])); expect(screen.queryByRole('button', { name: 'View records' })).not.toBeInTheDocument(); }); it('loads the exact records for a metric and pages with Show more', async () => { - const [first, second] = [crypto.randomUUID(), crypto.randomUUID()]; + const [first, second] = uuids(2); + const total = [first, second].length; mocks.getSupplierDataQualityRecords .mockResolvedValueOnce( page({ - total: 2, + total, items: [ { record_id: first, @@ -124,7 +134,7 @@ describe('DataQuality', () => { ) .mockResolvedValueOnce( page({ - total: 2, + total, offset: 1, items: [ { @@ -145,15 +155,15 @@ describe('DataQuality', () => { await waitFor(() => expect(screen.getByText(first)).toBeInTheDocument()); expect(screen.getByText('Missing: reference_document, quantity')).toBeInTheDocument(); expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith(FACILITY_ID, 'missing_kde', { - windowDays: 30, + windowDays: WINDOW_DAYS, limit: DRILLDOWN_PAGE_SIZE, offset: 0, }); - fireEvent.click(screen.getByRole('button', { name: 'Show more (1 of 2)' })); + fireEvent.click(screen.getByRole('button', { name: `Show more (1 of ${total})` })); await waitFor(() => expect(screen.getByText(second)).toBeInTheDocument()); expect(mocks.getSupplierDataQualityRecords).toHaveBeenLastCalledWith(FACILITY_ID, 'missing_kde', { - windowDays: 30, + windowDays: WINDOW_DAYS, limit: DRILLDOWN_PAGE_SIZE, offset: 1, }); @@ -165,7 +175,7 @@ describe('DataQuality', () => { mocks.getSupplierDataQualityRecords.mockResolvedValueOnce( page({ metric: 'unlinked_lot', - total: 1, + total: [lotCode].length, items: [ { record_id: null, @@ -186,7 +196,7 @@ describe('DataQuality', () => { }); it('says so when the records request fails', async () => { - mocks.getSupplierDataQualityRecords.mockRejectedValueOnce(new Error('boom')); + mocks.getSupplierDataQualityRecords.mockRejectedValueOnce(new Error(crypto.randomUUID())); renderQuality(); fireEvent.click(screen.getAllByRole('button', { name: 'View records' })[0]); await waitFor(() => expect(screen.getByText('Records could not be loaded')).toBeInTheDocument()); diff --git a/services/admin/tests/test_supplier_data_quality_routes.py b/services/admin/tests/test_supplier_data_quality_routes.py index b37412f9f..7eca1bd5b 100644 --- a/services/admin/tests/test_supplier_data_quality_routes.py +++ b/services/admin/tests/test_supplier_data_quality_routes.py @@ -12,6 +12,7 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from shared.supplier_data_quality import DEFAULT_WINDOW_DAYS, LATE_CTE_LABEL, MAX_WINDOW_DAYS, MIN_RECORDS TENANT = uuid4() OTHER_TENANT = uuid4() @@ -21,7 +22,7 @@ FOREIGN_FACILITY = uuid4() OTHER_TENANT_FACILITY = uuid4() -# Two shipping records with no KDEs on the owned facility, and one lot with no +# Shipping records with no KDEs on the owned facility, and one lot with no # upstream link. Ids and lot codes are generated per run. RECORD_ROWS = [ { @@ -33,7 +34,7 @@ "created_at": None, "lot_code": str(uuid4()), } - for _ in range(2) + for _ in range(MIN_RECORDS // 10) ] LOT_ROWS = [ { @@ -100,11 +101,9 @@ def test_summary_returns_four_separate_metrics_for_owned_facilities(monkeypatch, resp = _client(monkeypatch, calls).get("/v1/supplier/data-quality") assert resp.status_code == 200 body = resp.json() - assert body["window_days"] == 30 - assert body["min_records"] == 20 - assert body["late_cte_label"] == ( - "Our measure, based on the agreed sending time with this supplier; not a regulatory requirement" - ) + assert body["window_days"] == DEFAULT_WINDOW_DAYS + assert body["min_records"] == MIN_RECORDS + assert body["late_cte_label"] == LATE_CTE_LABEL [supplier] = body["suppliers"] assert supplier["supplier_key"] == str(OWN_FACILITY) assert [m["key"] for m in supplier["metrics"]] == ["missing_kde", "unlinked_lot", "late_cte", "epcis_failed"] @@ -120,7 +119,7 @@ def test_summary_returns_four_separate_metrics_for_owned_facilities(monkeypatch, assert all(call[3] == [str(OWN_FACILITY)] for call in calls) -@pytest.mark.parametrize("window", [0, 366, -1]) +@pytest.mark.parametrize("window", [0, MAX_WINDOW_DAYS + 1, -1]) def test_window_days_is_bounded(monkeypatch, calls, window): resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality?window_days={window}") assert resp.status_code == 422 diff --git a/services/shared/tests/test_supplier_data_quality.py b/services/shared/tests/test_supplier_data_quality.py index 69949c8c5..4d5c2e5d3 100644 --- a/services/shared/tests/test_supplier_data_quality.py +++ b/services/shared/tests/test_supplier_data_quality.py @@ -13,6 +13,7 @@ import pytest from shared.supplier_data_quality import ( COLUMN_BACKED_KDES, + DEFAULT_WINDOW_DAYS, METRIC_EPCIS_FAILED, METRIC_LATE_CTE, METRIC_MISSING_KDE, @@ -36,11 +37,12 @@ WEBHOOK_MODELS = REPO_ROOT / "services" / "ingestion" / "app" / "webhook_models.py" COMPLETE_SHIPPING = { - "product_description": "Baby Spinach", - "quantity": 10, - "unit_of_measure": "cases", - "ship_to_location": "DC 4", - "reference_document": "BOL-1", + "product_description": str(uuid.uuid4()), + # Any positive count; derived rather than picked. + "quantity": MIN_RECORDS, + "unit_of_measure": str(uuid.uuid4()), + "ship_to_location": str(uuid.uuid4()), + "reference_document": str(uuid.uuid4()), "tlc_source_reference": str(uuid.uuid4()), } @@ -135,7 +137,7 @@ def test_non_mapping_kde_data_misses_everything(): def test_flag_records_keeps_only_failing_rows_with_names(): - rows = [_record("shipping", COMPLETE_SHIPPING), _record("shipping", {"quantity": 1})] + rows = [_record("shipping", COMPLETE_SHIPPING), _record("shipping", {"quantity": COMPLETE_SHIPPING["quantity"]})] flagged = flag_records(rows) assert len(flagged) == 1 assert flagged[0]["record_id"] == rows[1]["record_id"] @@ -153,7 +155,7 @@ def test_lot_linked_by_transformation_output(): "field", ["tlc_source_reference", "tlc_source_gln", "immediate_previous_source", "input_lot_codes"] ) def test_lot_linked_by_linkage_kde(field): - assert lot_is_linked([("shipping", {}), ("receiving", {field: "X"})], False) + assert lot_is_linked([("shipping", {}), ("receiving", {field: str(uuid.uuid4())})], False) @pytest.mark.parametrize("cte", ["harvesting", "first_receiver", "first_land_based_receiving"]) @@ -190,16 +192,20 @@ def test_unlinked_lots_returns_ids_and_codes(): def test_share_below_min_records_is_not_enough_data_but_keeps_counts(): - metric = share_metric(METRIC_MISSING_KDE, 3, MIN_RECORDS - 1) + records = [_record("shipping", {}) for _ in range(MIN_RECORDS - 1)] + flagged = records[::2] + metric = share_metric(METRIC_MISSING_KDE, len(flagged), len(records)) assert metric["status"] == "not_enough_data" assert metric["share"] is None - assert (metric["numerator"], metric["denominator"]) == (3, MIN_RECORDS - 1) + assert (metric["numerator"], metric["denominator"]) == (len(flagged), len(records)) def test_share_at_min_records_is_computed(): - metric = share_metric(METRIC_MISSING_KDE, 5, MIN_RECORDS) + records = [_record("shipping", {}) for _ in range(MIN_RECORDS)] + flagged = records[::2] + metric = share_metric(METRIC_MISSING_KDE, len(flagged), len(records)) assert metric["status"] == "ok" - assert metric["share"] == 5 / MIN_RECORDS + assert metric["share"] == len(flagged) / len(records) def test_zero_denominator_is_not_enough_data(): @@ -211,34 +217,40 @@ def test_min_records_is_twenty(): def test_unavailable_metric_has_reason_and_no_counts(): - metric = unavailable_metric(METRIC_LATE_CTE, "why") + reason = str(uuid.uuid4()) + metric = unavailable_metric(METRIC_LATE_CTE, reason) assert metric == { "key": METRIC_LATE_CTE, "status": "unavailable", "numerator": None, "denominator": None, "share": None, - "reason": "why", + "reason": reason, } def test_window_start(): - now = datetime(2026, 10, 24, tzinfo=timezone.utc) - assert window_start(now, 30) == now - timedelta(days=30) + now = datetime.now(timezone.utc) + assert window_start(now, DEFAULT_WINDOW_DAYS) == now - timedelta(days=DEFAULT_WINDOW_DAYS) # --- facility summary -------------------------------------------------------- def test_summarize_facility_keeps_four_separate_metrics(): - records = [_record("shipping", COMPLETE_SHIPPING) for _ in range(15)] + [_record("shipping", {}) for _ in range(5)] - lots = [_lot_row(str(i), "shipping", {}) for i in range(3)] + complete = [_record("shipping", COMPLETE_SHIPPING) for _ in range(MIN_RECORDS // 2)] + incomplete = [_record("shipping", {}) for _ in range(MIN_RECORDS - len(complete))] + records = complete + incomplete + lots = [_lot_row(str(uuid.uuid4()), "shipping", {}) for _ in range(MIN_RECORDS // 2)] summary = summarize_facility(records, lots) assert set(summary) == {METRIC_MISSING_KDE, METRIC_UNLINKED_LOT, METRIC_LATE_CTE, METRIC_EPCIS_FAILED} - assert summary[METRIC_MISSING_KDE]["share"] == 5 / 20 + assert summary[METRIC_MISSING_KDE]["share"] == len(incomplete) / len(records) assert summary[METRIC_UNLINKED_LOT]["status"] == "not_enough_data" - assert (summary[METRIC_UNLINKED_LOT]["numerator"], summary[METRIC_UNLINKED_LOT]["denominator"]) == (3, 3) + assert (summary[METRIC_UNLINKED_LOT]["numerator"], summary[METRIC_UNLINKED_LOT]["denominator"]) == ( + len(lots), + len(lots), + ) assert summary[METRIC_LATE_CTE]["status"] == "unavailable" assert summary[METRIC_EPCIS_FAILED]["status"] == "unavailable" @@ -248,4 +260,4 @@ class NoQuery: def execute(self, *args, **kwargs): # pragma: no cover (must not run) raise AssertionError("no query expected") - assert fetch_supplier_quality(NoQuery(), "t", "u", [], datetime.now(timezone.utc)) == {} + assert fetch_supplier_quality(NoQuery(), str(uuid.uuid4()), str(uuid.uuid4()), [], datetime.now(timezone.utc)) == {} From 61bb7292f98ba01c7efa05e5b8108ed3c1b0557d Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:51:14 -0700 Subject: [PATCH 07/10] feat(suppliers): agreed sending hours and live late CTE metric (#3575) * feat(suppliers): agreed sending hours per facility and live late CTE metric Adds supplier_facilities.agreed_sending_hours (nullable, positive) with Alembic and Supabase migrations and downgrades, a tenant scoped PUT route and dashboard control, and computes the late CTE metric against the agreed sending time. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> * test(security): count the agreed sending hours PUT as a dual session CSRF mutation Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> * fix(copy): real-data follow-up to #3471 (v3) (#3544) * fix(dashboard): drop fabricated demo data from recall report page Remove the hardcoded demo investigation stats and affected-lot rows (invented suppliers, lot codes, consumer counts). Demo mode now renders the same No data yet empty state with a link to import records. * feat(copy): label every EPCIS intake and export mention as (Preview) EPCIS is preview on both paths, so feature-name mentions across app and marketing copy now carry the (Preview) marker. The integrations eyebrow now reads CSV upload, JSON events API, EPCIS (Preview). The FDA sortable export stays unlabeled. Stored keys and option values are unchanged. * fix(copy): keep typed EPCIS format value unlabeled in customer-readiness The format field is a string union used for logic; the (Preview) marker stays on the display name only. * feat(pricing): show honest unavailable state when billing is unreachable Drop the hardcoded tier prices and the silent fallback. Prices now come only from the billing service; when it cannot be reached the page renders a Pricing is temporarily unavailable state with signup and contact links instead of invented figures. Hardcoded dollar amounts in the pricing copy and metadata are removed as well. EPCIS 2.0 export carries the (Preview) label; the FDA sortable export stays unlabeled. * fix(app): remove sample and sandbox banners and the simulated ingest walkthrough Dashboard shell and DemoBanner now say No data yet with a link to connect real data on the Ingest page. The ingest page no longer fakes a job for the first example URL; it submits for real. The Sample: Daily FDA package archive entry is removed from the archive jobs list. Test assertions updated for the EPCIS (Preview) status labels. * fix(data-import): remove synthetic sample dataset simulation Delete SAMPLE_EVENTS, the handleLoadSample flow with its state and types, and the Load Sample Dataset / Run Simulation card. A regression test asserts the control is not rendered. Real CSV upload and the API guide are unchanged. * test(copy): assert csv-sftp display text never renders SFTP Display labels were already clean (CSV Import); this locks the invariant for the csv-sftp registry entry name, customer copy, and the csv_sftp_import delivery-mode label. Stored keys and ids are unchanged. * fix(receiving): use a comma in generated Receiving Dock location names New receiving submissions name the location 'Receiving Dock, ' instead of using a dash. No existing records are touched. * fix(inflow-lab): hide Inflow Lab links from dashboard, docs, ingest, integrations and product * fix(tools): remove Inflow Lab simulator, walkthrough sample payloads and recall drill demo data Drops tools/inflow-lab components, lib/api-samples ingest walkthrough JSON, synthetic tools/ask and tools/export copy, and invented proof/recall-drill numbers. Recall report shows the #3536 no_data empty state. * ci: trigger required gates Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --------- Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(ci): Engine Review Pass picks up real PASS markers (#3577) * fix(ci): Engine Review Pass reads issue comment markers and allows pi, deepseek, devops Real PASS markers on #3553, #3547, #3530 and #3527 were ignored: pi was not an allowed engine, issue comment markers were never read, and devops/ branches failed as an invalid author engine. Also rerun on PR issue comments. Signed-off-by: Christopher Sellers <1@christophersellers.com> * ci(engine-review-pass): drop devops, add hermes, require exact full sha Signed-off-by: Christopher Sellers <1@christophersellers.com> --------- Signed-off-by: Christopher Sellers <1@christophersellers.com> * feat(copy): SEL-452 copy gate with capabilities registry and baseline ratchet (#3549) * feat(copy): SEL-452 copy gate with four-status capabilities registry and baseline ratchet Signed-off-by: Christopher Sellers <1@christophersellers.com> * ci(copy): copy-gate path filters include copy-gate.yml Signed-off-by: Christopher Sellers <1@christophersellers.com> * fix(copy): gate against main's baseline so a PR cannot loosen the ratchet Signed-off-by: Christopher Sellers <1@christophersellers.com> * fix(copy-gate): reject unparseable registry, fail on baseline lookup errors, diff pushes against event.before * chore(copy-lint): refresh baseline after main removed sandbox copy (SEL-452) Signed-off-by: Christopher Sellers <1@christophersellers.com> * chore(copy-gate): refresh copy baseline after merging main (counts only dropped) * fix(schema): include public.data_gap_events in expected schema snapshot Regenerate docs/schema/expected_schema.json from a fresh alembic upgrade head so the RLS isolation suite schema parity check stops reporting EXTRA TABLE for the v135 data gap migration. Co-authored-by: Cursor --------- Signed-off-by: Christopher Sellers <1@christophersellers.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Cursor * chore(schema): snapshot supplier_facilities.agreed_sending_hours (v136) Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> * fix(copy): label EPCIS as Preview in supplier quality and ingest, drop walkthrough dash Refresh copy-lint baseline.json so it matches current counts. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --------- Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Signed-off-by: Christopher Sellers <1@christophersellers.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Cursor --- .github/workflows/copy-gate.yml | 58 + .github/workflows/engine-review-pass.yml | 51 +- ...1dba_v136_supplier_agreed_sending_hours.py | 43 + docs/schema/expected_schema.json | 53 + docs/security/route-security-manifest.json | 17 +- frontend/package.json | 1 + .../public/samples/sample_recall_drill.csv | 11 - .../__tests__/components/demo-banner.test.tsx | 8 +- .../components/evidence-chain.test.tsx | 167 - .../components/supplier-data-quality.test.tsx | 109 +- .../src/__tests__/lib/inflow-error.test.ts | 14 - .../lib/inflow-lab-exception-grouping.test.ts | 152 - .../pages/dashboard-navigation.test.tsx | 10 +- .../src/__tests__/pages/data-import.test.tsx | 6 + .../__tests__/pages/docs-contract.test.tsx | 42 +- .../pages/experimental-gating.test.tsx | 24 +- .../src/__tests__/pages/inflow-lab.test.tsx | 761 ----- .../src/__tests__/pages/ingest-epcis.test.tsx | 6 +- .../__tests__/pages/pre-revenue-copy.test.ts | 9 +- .../pages/proof-recall-drill.test.tsx | 49 - .../__tests__/pages/recall-report.test.tsx | 84 +- frontend/src/app/buyer-programs/page.tsx | 2 +- frontend/src/app/compliance/page.tsx | 2 +- .../src/app/dashboard/compliance/page.tsx | 30 +- .../src/app/dashboard/export-jobs/page.tsx | 12 +- .../src/app/dashboard/inflow-lab/page.tsx | 22 +- .../src/app/dashboard/integrations/page.tsx | 134 +- frontend/src/app/dashboard/layout.tsx | 6 +- frontend/src/app/dashboard/page.tsx | 20 +- .../src/app/dashboard/recall-drills/page.tsx | 2 +- .../src/app/dashboard/recall-report/page.tsx | 198 +- frontend/src/app/dashboard/receiving/page.tsx | 2 +- .../app/dashboard/suppliers/data-quality.tsx | 143 +- frontend/src/app/dashboard/suppliers/page.tsx | 25 +- frontend/src/app/developers/page.tsx | 4 +- .../src/app/docs/api/ApiReferenceClient.tsx | 28 +- frontend/src/app/docs/changelog/page.tsx | 2 +- .../connectors/inflow-lab/opengraph-image.tsx | 10 - .../app/docs/connectors/inflow-lab/page.tsx | 175 +- frontend/src/app/docs/fsma-204/page.tsx | 4 +- frontend/src/app/docs/page.tsx | 3 +- frontend/src/app/docs/quickstart/page.tsx | 12 +- frontend/src/app/docs/walkthrough/page.tsx | 47 +- frontend/src/app/fsma/integration/page.tsx | 2 +- frontend/src/app/ingest/page.tsx | 45 +- frontend/src/app/ingest/sandbox-handoff.ts | 4 +- frontend/src/app/integrations/page.tsx | 90 +- frontend/src/app/pricing/page.tsx | 99 +- frontend/src/app/product/page.tsx | 10 - frontend/src/app/proof/page.tsx | 10 +- frontend/src/app/proof/recall-drill/page.tsx | 515 --- frontend/src/app/sitemap.ts | 6 +- frontend/src/app/tools/ask/page.tsx | 204 +- .../components/DataImportClient.tsx | 151 - frontend/src/app/tools/export/page.tsx | 122 +- .../inflow-lab-dashboard-preview/page.tsx | 23 +- .../components/DashboardInflowLab.tsx | 2311 -------------- .../inflow-lab/components/EvidenceChain.tsx | 322 -- .../inflow-lab/components/InflowLabClient.tsx | 2774 ----------------- .../components/InflowLabTutorial.tsx | 224 -- .../inflow-lab/lib/exception-grouping.ts | 121 - .../app/tools/inflow-lab/opengraph-image.tsx | 10 - frontend/src/app/tools/inflow-lab/page.tsx | 28 +- frontend/src/app/trust/architecture/page.tsx | 4 +- frontend/src/app/trust/page.tsx | 6 +- frontend/src/app/trust/retention/page.tsx | 4 +- frontend/src/app/walkthrough/page.tsx | 2 +- frontend/src/app/why-regengine/page.tsx | 12 +- .../components/control-plane/demo-banner.tsx | 8 +- .../src/components/dashboard/breadcrumb.tsx | 1 - .../dashboard/metrics-overview-widget.tsx | 4 +- .../components/layout/marketing-footer.tsx | 6 +- .../components/pricing/PricingPageClient.tsx | 6 +- frontend/src/components/ui/tutorial.tsx | 4 +- frontend/src/lib/api-client.ts | 12 + .../lib/api-samples/ingest-inflow-lab.json | 22 - .../src/lib/api-samples/ingest-receiving.json | 21 - .../src/lib/api-samples/ingest-samples.ts | 73 +- .../ingest-walkthrough-receiving.json | 22 - .../ingest-walkthrough-shipping.json | 22 - .../ingest-walkthrough-transformation.json | 28 - frontend/src/lib/capabilities.ts | 12 +- frontend/src/lib/customer-readiness.test.ts | 8 + frontend/src/lib/customer-readiness.ts | 18 +- frontend/src/lib/doc-pages.ts | 1 - frontend/src/lib/inflow-error.ts | 6 - .../src/lib/marketing-analytics-routes.ts | 3 +- frontend/src/types/api.ts | 17 +- frontend/tests/visual/routes.json | 2 +- scripts/copy-lint/baseline.json | 192 ++ scripts/copy-lint/check-copy.mjs | 84 +- scripts/copy-lint/check-copy.test.mjs | 137 +- scripts/workflow_inventory.txt | 1 + services/admin/app/sqlalchemy_models.py | 6 + .../admin/app/supplier_compliance_routes.py | 65 +- .../test_supplier_data_quality_routes.py | 165 +- services/shared/supplier_data_quality.py | 76 +- .../tests/test_supplier_data_quality.py | 100 +- ...24080000_supplier_agreed_sending_hours.sql | 22 + .../security/test_route_security_manifest.py | 10 +- 100 files changed, 1704 insertions(+), 9107 deletions(-) create mode 100644 .github/workflows/copy-gate.yml create mode 100644 alembic/versions/20261024_b005ddbf1dba_v136_supplier_agreed_sending_hours.py delete mode 100644 frontend/public/samples/sample_recall_drill.csv delete mode 100644 frontend/src/__tests__/components/evidence-chain.test.tsx delete mode 100644 frontend/src/__tests__/lib/inflow-error.test.ts delete mode 100644 frontend/src/__tests__/lib/inflow-lab-exception-grouping.test.ts delete mode 100644 frontend/src/__tests__/pages/inflow-lab.test.tsx delete mode 100644 frontend/src/__tests__/pages/proof-recall-drill.test.tsx delete mode 100644 frontend/src/app/docs/connectors/inflow-lab/opengraph-image.tsx delete mode 100644 frontend/src/app/proof/recall-drill/page.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/components/DashboardInflowLab.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/components/EvidenceChain.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/components/InflowLabTutorial.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/lib/exception-grouping.ts delete mode 100644 frontend/src/app/tools/inflow-lab/opengraph-image.tsx delete mode 100644 frontend/src/lib/api-samples/ingest-inflow-lab.json delete mode 100644 frontend/src/lib/api-samples/ingest-receiving.json delete mode 100644 frontend/src/lib/api-samples/ingest-walkthrough-receiving.json delete mode 100644 frontend/src/lib/api-samples/ingest-walkthrough-shipping.json delete mode 100644 frontend/src/lib/api-samples/ingest-walkthrough-transformation.json delete mode 100644 frontend/src/lib/inflow-error.ts create mode 100644 scripts/copy-lint/baseline.json create mode 100644 supabase/migrations/20261024080000_supplier_agreed_sending_hours.sql diff --git a/.github/workflows/copy-gate.yml b/.github/workflows/copy-gate.yml new file mode 100644 index 000000000..ceaeaec75 --- /dev/null +++ b/.github/workflows/copy-gate.yml @@ -0,0 +1,58 @@ +name: Copy gate + +on: + pull_request: + paths: + - 'frontend/**' + - 'scripts/copy-lint/**' + - '.github/workflows/copy-gate.yml' + - 'scripts/copy-lint/baseline.json' + push: + branches: [main] + paths: + - 'frontend/**' + - 'scripts/copy-lint/**' + - '.github/workflows/copy-gate.yml' + - 'scripts/copy-lint/baseline.json' + +permissions: + contents: read + +jobs: + gate: + name: Copy gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: '20' + - run: node --test scripts/copy-lint/check-copy.test.mjs + - name: Read base copy baseline + env: + BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + base_ref="origin/${BASE_REF}" + else + if [[ "$BEFORE_SHA" =~ ^0{40}$ ]]; then + exit 0 + fi + base_ref="$BEFORE_SHA" + fi + base_commit=$(git rev-parse --verify "${base_ref}^{commit}") || exit 1 + baseline_path="scripts/copy-lint/baseline.json" + if git cat-file -e "${base_commit}:${baseline_path}" 2>/dev/null; then + git show "${base_commit}:${baseline_path}" > "$RUNNER_TEMP/base-baseline.json" || exit 1 + else + baseline_entry=$(git ls-tree -r --name-only "$base_commit" -- "$baseline_path") || exit 1 + if [ -n "$baseline_entry" ]; then + echo "Unable to read base copy baseline" >&2 + exit 1 + fi + fi + - run: node scripts/copy-lint/check-copy.mjs . --gate --github --base-baseline "$RUNNER_TEMP/base-baseline.json" diff --git a/.github/workflows/engine-review-pass.yml b/.github/workflows/engine-review-pass.yml index 87ddb7d42..e84025c1f 100644 --- a/.github/workflows/engine-review-pass.yml +++ b/.github/workflows/engine-review-pass.yml @@ -7,15 +7,18 @@ on: types: [opened, synchronize, reopened] pull_request_review: types: [submitted, dismissed] + issue_comment: + types: [created, edited] # Review bursts must not cancel in-progress runs. concurrency: - group: engine-review-pass-${{ github.event.pull_request.number }} + group: engine-review-pass-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: false permissions: statuses: write pull-requests: read + issues: read contents: read jobs: @@ -30,7 +33,20 @@ jobs: script: | const owner = context.repo.owner; const repo = context.repo.repo; - const pr = context.payload.pull_request; + let pr = context.payload.pull_request; + + if (!pr) { + if (!context.payload.issue?.pull_request) { + core.info('Issue comment is not on a pull request. Skipping.'); + return; + } + const prResponse = await github.rest.pulls.get({ + owner, + repo, + pull_number: context.payload.issue.number, + }); + pr = prResponse.data; + } if (!pr) { core.setFailed('Could not determine pull request from event payload.'); @@ -50,6 +66,9 @@ jobs: 'grok', 'muse', 'kimi', + 'pi', + 'deepseek', + 'hermes', 'visual-designer', 'product-designer', ]); @@ -107,7 +126,29 @@ jobs: core.info(`Total reviews retrieved: ${reviews.length}`); - const eligibleReviews = reviews.filter((r) => { + core.info(`Fetching comments for pull request #${prNumber}...`); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + + core.info(`Total issue comments retrieved: ${comments.length}`); + + const normalizedComments = comments.map((c) => ({ + id: c.id, + body: c.body, + submitted_at: c.created_at, + author_association: c.author_association, + user: c.user, + state: 'COMMENTED', + })); + + const allReviews = reviews.concat(normalizedComments); + + const eligibleReviews = allReviews.filter((r) => { if (r.state === 'DISMISSED' || !r.submitted_at || !r.body) { return false; } @@ -131,7 +172,7 @@ jobs: }); // The marker must be alone on its own line. - const markerRegex = /^REVIEW: (PASS|BLOCKED|FAIL) ([A-Za-z0-9._-]+) ([0-9a-f]{7,40})\s*$/; + const markerRegex = /^REVIEW: (PASS|BLOCKED|FAIL) ([A-Za-z0-9._-]+) ([0-9a-f]{40})\s*$/; const headShaLower = headSha.toLowerCase(); const authorEngineLower = authorEngine.toLowerCase(); @@ -154,7 +195,7 @@ jobs: continue; } - if (!headShaLower.startsWith(markerSha.toLowerCase())) { + if (markerSha.toLowerCase() !== headShaLower) { core.info(`Ignoring marker for non-matching SHA ${markerSha} on head ${headSha}`); continue; } diff --git a/alembic/versions/20261024_b005ddbf1dba_v136_supplier_agreed_sending_hours.py b/alembic/versions/20261024_b005ddbf1dba_v136_supplier_agreed_sending_hours.py new file mode 100644 index 000000000..7e48ae019 --- /dev/null +++ b/alembic/versions/20261024_b005ddbf1dba_v136_supplier_agreed_sending_hours.py @@ -0,0 +1,43 @@ +"""Agreed sending time per supplier facility. + +Mirrors supabase/migrations/20261024080000_supplier_agreed_sending_hours.sql +exactly: adds public.supplier_facilities.agreed_sending_hours, integer NULL, +CHECK (agreed_sending_hours > 0). No backfill. The supplier data quality view +counts a CTE record as late when it is received more than this many hours +after its event time. + +Additive and idempotent (ADD COLUMN IF NOT EXISTS with an inline CHECK that is +only created together with the column), so the plain SQL file can run ahead of +it. Downgrade drops only what this revision created. + +Revision ID: b005ddbf1dba +Revises: 41f96588d87c +Create Date: 2026-10-24 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "b005ddbf1dba" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "41f96588d87c" # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ +alter table public.supplier_facilities + add column if not exists agreed_sending_hours integer null + constraint supplier_facilities_agreed_sending_hours_check + check (agreed_sending_hours > 0); +""" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE public.supplier_facilities DROP CONSTRAINT IF EXISTS supplier_facilities_agreed_sending_hours_check" + ) + op.execute("ALTER TABLE public.supplier_facilities DROP COLUMN IF EXISTS agreed_sending_hours") diff --git a/docs/schema/expected_schema.json b/docs/schema/expected_schema.json index 97739280e..95c52ff48 100644 --- a/docs/schema/expected_schema.json +++ b/docs/schema/expected_schema.json @@ -5525,6 +5525,55 @@ } ] }, + "public.data_gap_events": { + "columns": { + "closed_at": { + "nullable": true, + "type": "timestamp" + }, + "closing_record_hash": { + "nullable": true, + "type": "text" + }, + "closing_record_id": { + "nullable": true, + "type": "uuid" + }, + "cte_type": { + "nullable": false, + "type": "text" + }, + "gap_key": { + "nullable": false, + "type": "text" + }, + "gap_kind": { + "nullable": false, + "type": "text" + }, + "id": { + "nullable": false, + "type": "uuid" + }, + "lot_code": { + "nullable": false, + "type": "text" + }, + "opened_at": { + "nullable": false, + "type": "timestamp" + }, + "supplier_key": { + "nullable": false, + "type": "text" + }, + "tenant_id": { + "nullable": false, + "type": "uuid" + } + }, + "foreign_keys": [] + }, "public.food_traceability_list": { "columns": { "applicable_ctes": { @@ -6758,6 +6807,10 @@ }, "public.supplier_facilities": { "columns": { + "agreed_sending_hours": { + "nullable": true, + "type": "integer" + }, "city": { "nullable": false, "type": "text" diff --git a/docs/security/route-security-manifest.json b/docs/security/route-security-manifest.json index 00bbed8cc..fb55757a3 100644 --- a/docs/security/route-security-manifest.json +++ b/docs/security/route-security-manifest.json @@ -2956,6 +2956,19 @@ "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", "test_adapter": "tenant_dual_session_csrf" }, + { + "operation": "admin:PUT:/v1/supplier/facilities/{facility_id}/agreed-sending-hours", + "owner": "admin", + "method": "PUT", + "path": "/v1/supplier/facilities/{facility_id}/agreed-sending-hours", + "classification": "tenant-scoped", + "authentication": "bearer-token-or-opaque-session-cookie", + "required_permissions": [ + "authenticated" + ], + "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", + "test_adapter": "tenant_dual_session_csrf" + }, { "operation": "admin:POST:/v1/supplier/facilities/{facility_id}/cte-events", "owner": "admin", @@ -4944,13 +4957,13 @@ } ], "summary": { - "enabled_operation_count": 257, + "enabled_operation_count": 258, "disabled_operation_count": 180, "disabled_unique_path_count": 163, "enabled_by_classification": { "global-sysadmin": 16, "public": 30, - "tenant-scoped": 204, + "tenant-scoped": 205, "workload-only": 7 } } diff --git a/frontend/package.json b/frontend/package.json index 1089ebec2..272542906 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "src/lib/capabilities.ts" ] }, + "//knip": "src/lib/capabilities.ts is read by scripts/copy-lint/check-copy.mjs (the SEL-452 copy gate registry), not imported by frontend code.", "//": "Pinning policy (#569): next, eslint-config-next, @sentry/nextjs, and @supabase/supabase-js are pinned to exact versions (no ^ or ~ prefix) because breaking changes in these packages have caused production incidents. Bump them deliberately after testing. All other deps use ^ for minor/patch auto-updates.", "dependencies": { "@radix-ui/react-checkbox": "^1.4.1", diff --git a/frontend/public/samples/sample_recall_drill.csv b/frontend/public/samples/sample_recall_drill.csv deleted file mode 100644 index daa15cd42..000000000 --- a/frontend/public/samples/sample_recall_drill.csv +++ /dev/null @@ -1,11 +0,0 @@ -Traceability Lot Code (TLC),Product Description,Quantity,Unit of Measure,Event Type,Event Date,Event Time,Facility Name,Facility GLN,Facility Address,Confidence Score -SV-20240115-001,Romaine Lettuce,500,cases,SHIPPING,2024-01-15,14:00:00,Sunny Valley Farms,1234567890128,"123 Farm Road, Salinas CA",0.98 -SV-20240115-001,Romaine Lettuce,500,cases,RECEIVING,2024-01-16,08:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.97 -FC-20240116-A,Chopped Romaine,250,cases,TRANSFORMATION,2024-01-16,10:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.95 -FC-20240116-B,Romaine Hearts,250,cases,TRANSFORMATION,2024-01-16,11:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.95 -FC-20240116-A,Chopped Romaine,100,cases,SHIPPING,2024-01-17,06:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.96 -FC-20240116-A,Chopped Romaine,100,cases,RECEIVING,2024-01-17,10:00:00,Metro Foods,3456789012340,"789 Distribution Dr, Oakland CA",0.94 -FC-20240116-B,Romaine Hearts,150,cases,SHIPPING,2024-01-17,07:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.96 -FC-20240116-B,Romaine Hearts,150,cases,RECEIVING,2024-01-17,11:00:00,Valley Wholesale,4567890123456,"321 Wholesale Ave, Stockton CA",0.93 -FC-20240116-A,Chopped Romaine,50,cases,SHIPPING,2024-01-18,05:00:00,Metro Foods,3456789012340,"789 Distribution Dr, Oakland CA",0.95 -FC-20240116-A,Chopped Romaine,50,cases,RECEIVING,2024-01-18,08:00:00,SuperMart #42,5678901234562,"100 Retail St, San Jose CA",0.92 diff --git a/frontend/src/__tests__/components/demo-banner.test.tsx b/frontend/src/__tests__/components/demo-banner.test.tsx index 91ca22ed7..410c79984 100644 --- a/frontend/src/__tests__/components/demo-banner.test.tsx +++ b/frontend/src/__tests__/components/demo-banner.test.tsx @@ -9,13 +9,13 @@ import { DemoBanner } from '@/components/control-plane/demo-banner'; describe('DemoBanner Component', () => { it('renders when visible=true', () => { render(); - expect(screen.getByText('Sample Data')).toBeInTheDocument(); + expect(screen.getByText('No data yet')).toBeInTheDocument(); }); - it('shows "Sample Data" text and description', () => { + it('shows the empty-state text and a next-step link', () => { render(); - expect(screen.getByText('Sample Data')).toBeInTheDocument(); - expect(screen.getByText(/backend unavailable/i)).toBeInTheDocument(); + expect(screen.getByText('No data yet')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /connect real data/i })).toHaveAttribute('href', '/ingest'); }); it('does not render when visible=false', () => { diff --git a/frontend/src/__tests__/components/evidence-chain.test.tsx b/frontend/src/__tests__/components/evidence-chain.test.tsx deleted file mode 100644 index e677d6995..000000000 --- a/frontend/src/__tests__/components/evidence-chain.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { - EvidenceChain, - type EvidenceChainNode, - type EvidenceChainProps, -} from '@/app/tools/inflow-lab/components/EvidenceChain'; - -const CTES = ['Harvesting', 'Cooling', 'Initial packing', 'Shipping', 'DC receiving']; - -/** Mirrors `toLineage`: five slots, present events complete, the rest missing. */ -function lineage(completeCount: number): EvidenceChainNode[] { - return CTES.map((cte, index) => ({ - cte, - location: index < completeCount ? `Facility ${index + 1}` : 'Not captured', - time: index < completeCount ? `May 7, 2026, 10:0${index} AM` : 'Missing CTE', - state: index < completeCount ? 'complete' : 'missing', - })); -} - -function renderChain(overrides: Partial = {}) { - const props: EvidenceChainProps = { - nodes: lineage(3), - hasData: true, - isBlocked: true, - isBusy: false, - lot: { code: 'lot-code', product: 'product-name', supplier: 'supplier-name', cases: 1 }, - missingEvidence: 'Shipping, DC receiving', - patternLabel: 'Missing handoff evidence', - fixPath: 'Map the missing handoff fields once, then re-run the lot group.', - lotsAffected: 2, - rowsRead: 11, - lotCount: 3, - runKey: 1, - onRun: vi.fn(), - onOpenEditor: vi.fn(), - onFocusDefect: null, - ...overrides, - }; - return { props, ...render() }; -} - -describe('EvidenceChain', () => { - describe('with no data yet', () => { - // `toLineage([])` returns five *missing* nodes. The chain must read that - // as "not traced yet", never as five defects — the panel is not allowed - // to invent a failure before an evaluation has run. - const noData = { nodes: lineage(0), hasData: false, isBlocked: false, lot: null }; - - it('holds the export gate open rather than reporting a block', () => { - renderChain(noData); - expect(screen.getByText('Awaiting trace')).toBeInTheDocument(); - expect(screen.queryByText('Blocked')).not.toBeInTheDocument(); - }); - - it('does not invent a defect from an untraced chain', () => { - renderChain(noData); - expect(screen.queryByText(/records? never arrived/)).not.toBeInTheDocument(); - expect(screen.queryByText('Blocked now')).not.toBeInTheDocument(); - expect(screen.queryByText('no record received')).not.toBeInTheDocument(); - // Every CTE is announced as untraced, not as missing evidence. - CTES.forEach((cte) => { - expect(screen.getByText(cte).textContent).toContain('not yet traced'); - }); - }); - - it('counts nothing as proven', () => { - renderChain(noData); - expect(screen.getByText('0/5')).toBeInTheDocument(); - }); - }); - - describe('with a blocked lot', () => { - it('names how many records are missing and what they were', () => { - renderChain(); - expect(screen.getByText('2 records never arrived.')).toBeInTheDocument(); - expect(screen.getByText('Blocked')).toBeInTheDocument(); - expect(screen.getByText('Shipping, DC receiving')).toBeInTheDocument(); - expect(screen.getByText('Missing handoff evidence')).toBeInTheDocument(); - }); - - it('singularises the heading for a single missing record', () => { - renderChain({ nodes: lineage(4), missingEvidence: 'DC receiving' }); - expect(screen.getByText('One record never arrived.')).toBeInTheDocument(); - }); - - it('reports proven links and the rows behind them', () => { - renderChain(); - expect(screen.getByText('3/5')).toBeInTheDocument(); - expect(screen.getByText('11')).toBeInTheDocument(); - }); - - it('marks the missing CTEs for assistive tech, not by colour alone', () => { - renderChain(); - expect(screen.getByText('Shipping').textContent).toContain('missing'); - expect(screen.getByText('Harvesting').textContent).toContain('proven'); - }); - - it('reports a shared pattern across lots', () => { - renderChain({ lotsAffected: 2 }); - expect(screen.getByText('2 lots, same pattern')).toBeInTheDocument(); - }); - - it('does not overstate the blast radius for a lone lot', () => { - renderChain({ lotsAffected: 1 }); - expect(screen.getByText('This lot only')).toBeInTheDocument(); - }); - - it('offers the defect jump only when there is a group to jump to', async () => { - const onFocusDefect = vi.fn(); - const { unmount } = renderChain({ onFocusDefect }); - await userEvent.click(screen.getByRole('button', { name: 'Focus this defect' })); - expect(onFocusDefect).toHaveBeenCalledOnce(); - unmount(); - - renderChain({ onFocusDefect: null }); - expect(screen.queryByRole('button', { name: 'Focus this defect' })).not.toBeInTheDocument(); - }); - }); - - describe('with a clean lot', () => { - const clean = { nodes: lineage(5), isBlocked: false }; - - it('clears the export gate and says so without hedging', () => { - renderChain(clean); - expect(screen.getByText('Preview-ready')).toBeInTheDocument(); - expect(screen.getByText('The chain closes end to end.')).toBeInTheDocument(); - expect(screen.getByText('5/5')).toBeInTheDocument(); - }); - - it('reports no defect when there is none', () => { - renderChain(clean); - expect(screen.queryByText(/never arrived/)).not.toBeInTheDocument(); - expect(screen.queryByText('Blocked now')).not.toBeInTheDocument(); - }); - }); - - describe('controls', () => { - it('runs the trace and opens the data editor, with no sample on offer', async () => { - const onRun = vi.fn(); - const onOpenEditor = vi.fn(); - renderChain({ onRun, onOpenEditor }); - - await userEvent.click(screen.getByRole('button', { name: /Run trace again/ })); - expect(onRun).toHaveBeenCalledOnce(); - - await userEvent.click(screen.getByRole('button', { name: 'Open data editor' })); - expect(onOpenEditor).toHaveBeenCalledOnce(); - expect(screen.queryByRole('button', { name: /sample/i })).not.toBeInTheDocument(); - }); - - it('invites a first run before any data exists', () => { - renderChain({ nodes: lineage(0), hasData: false, isBlocked: false, lot: null }); - expect(screen.getByRole('button', { name: /Run trace$/ })).toBeInTheDocument(); - expect(screen.getByText('Awaiting supplier rows')).toBeInTheDocument(); - expect(screen.getByText('No data yet')).toBeInTheDocument(); - }); - - it('locks the run control while a trace is in flight', () => { - renderChain({ isBusy: true }); - const chain = screen.getByTestId('inflow-evidence-chain'); - expect(within(chain).getByRole('button', { name: /Tracing/ })).toBeDisabled(); - }); - }); -}); diff --git a/frontend/src/__tests__/components/supplier-data-quality.test.tsx b/frontend/src/__tests__/components/supplier-data-quality.test.tsx index 27947d94f..8ea93ef32 100644 --- a/frontend/src/__tests__/components/supplier-data-quality.test.tsx +++ b/frontend/src/__tests__/components/supplier-data-quality.test.tsx @@ -4,11 +4,13 @@ import type { SupplierDataQuality, SupplierQualityMetric, SupplierQualityRecords const mocks = vi.hoisted(() => ({ getSupplierDataQualityRecords: vi.fn(), + setFacilityAgreedSendingHours: vi.fn(), })); vi.mock('@/lib/api-client', () => ({ apiClient: { getSupplierDataQualityRecords: (...args: unknown[]) => mocks.getSupplierDataQualityRecords(...args), + setFacilityAgreedSendingHours: (...args: unknown[]) => mocks.setFacilityAgreedSendingHours(...args), }, })); @@ -38,21 +40,35 @@ const LOT_IDS = uuids(MIN_RECORDS - 1); const UNLINKED_LOT_IDS = LOT_IDS.filter((_, index) => index % 2 === 0); const MISSING = { numerator: FLAGGED_RECORD_IDS.length, denominator: RECORD_IDS.length }; const UNLINKED = { numerator: UNLINKED_LOT_IDS.length, denominator: LOT_IDS.length }; +const LATE_RECORD_IDS = uuids(MIN_RECORDS); +const LATE_FLAGGED_IDS = LATE_RECORD_IDS.filter((_, index) => index % 2 === 0); +const LATE = { numerator: LATE_FLAGGED_IDS.length, denominator: LATE_RECORD_IDS.length }; +// Any positive whole number of hours; derived rather than picked. +const AGREED_HOURS = MIN_RECORDS; const LATE_REASON = crypto.randomUUID(); const EPCIS_REASON = crypto.randomUUID(); const missingShare = MISSING.numerator / MISSING.denominator; const formatShare = (share: number) => `${Math.round(share * 1000) / 10}%`; -const summary = (metrics: SupplierQualityMetric[]): SupplierDataQuality => ({ supplier_key: FACILITY_ID, metrics }); +const summary = (metrics: SupplierQualityMetric[], agreedSendingHours: number | null = null): SupplierDataQuality => ({ + supplier_key: FACILITY_ID, + agreed_sending_hours: agreedSendingHours, + metrics, +}); const fourMetrics = summary([ metric({ key: 'missing_kde', status: 'ok', ...MISSING, share: missingShare }), metric({ key: 'unlinked_lot', status: 'not_enough_data', ...UNLINKED, share: null }), - metric({ key: 'late_cte', status: 'unavailable', numerator: null, denominator: null, reason: LATE_REASON }), + metric({ key: 'late_cte', status: 'not_enough_data', numerator: null, denominator: null, reason: LATE_REASON }), metric({ key: 'epcis_failed', status: 'unavailable', numerator: null, denominator: null, reason: EPCIS_REASON }), ]); -function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false) { +const lateMeasured = summary( + [metric({ key: 'late_cte', status: 'ok', ...LATE, share: LATE.numerator / LATE.denominator })], + AGREED_HOURS, +); + +function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false, onSendingHoursSaved?: () => void) { return render( , ); } @@ -79,6 +96,7 @@ const page = (overrides: Partial): SupplierQuali describe('DataQuality', () => { beforeEach(() => { mocks.getSupplierDataQualityRecords.mockReset(); + mocks.setFacilityAgreedSendingHours.mockReset(); }); it('shows count of denominator and a share for each metric, with no combined score', () => { @@ -97,7 +115,7 @@ describe('DataQuality', () => { it('shows unavailable metrics with their reason and the late CTE label', () => { renderQuality(); - expect(screen.getByText(`Unavailable. ${LATE_REASON}`)).toBeInTheDocument(); + expect(screen.getByText(`Not enough data. ${LATE_REASON}`)).toBeInTheDocument(); expect(screen.getByText(`Unavailable. ${EPCIS_REASON}`)).toBeInTheDocument(); expect(screen.getByText(LATE_LABEL)).toBeInTheDocument(); }); @@ -211,4 +229,87 @@ describe('DataQuality', () => { renderQuality(null, false); expect(screen.getByText('Data quality: No data')).toBeInTheDocument(); }); + + it('shows the late share against the agreed sending time and offers its records', async () => { + const [lateId] = uuids(1); + mocks.getSupplierDataQualityRecords.mockResolvedValueOnce( + page({ + metric: 'late_cte', + total: [lateId].length, + items: [ + { + record_id: lateId, + lot_id: null, + lot_code: crypto.randomUUID(), + cte_type: 'shipping', + event_time: null, + received_at: null, + missing_kdes: [], + }, + ], + }), + ); + renderQuality(lateMeasured); + expect( + screen.getByText(`${LATE.numerator} of ${LATE.denominator} records (${formatShare(LATE.numerator / LATE.denominator)})`), + ).toBeInTheDocument(); + expect(screen.getByText(`Agreed sending time: ${AGREED_HOURS} hours after the event`)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'View records' })); + await waitFor(() => expect(screen.getByText(lateId)).toBeInTheDocument()); + expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith(FACILITY_ID, 'late_cte', expect.anything()); + }); + + it('offers no late drill down without an agreed sending time', () => { + renderQuality(summary([fourMetrics.metrics[2]])); + expect(screen.getByText('Agreed sending time: Not set')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'View records' })).not.toBeInTheDocument(); + }); + + it('saves a new agreed sending time and reloads the measures', async () => { + const onSaved = vi.fn(); + mocks.setFacilityAgreedSendingHours.mockResolvedValueOnce({ + facility_id: FACILITY_ID, + agreed_sending_hours: AGREED_HOURS, + }); + renderQuality(summary([fourMetrics.metrics[2]]), false, onSaved); + + fireEvent.click(screen.getByRole('button', { name: 'Set' })); + fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: String(AGREED_HOURS) } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(mocks.setFacilityAgreedSendingHours).toHaveBeenCalledWith(FACILITY_ID, AGREED_HOURS); + }); + + it.each(['0', `-${AGREED_HOURS}`, `${AGREED_HOURS}.5`, ''])('refuses %j as an agreed sending time', (draft) => { + renderQuality(summary([fourMetrics.metrics[2]])); + fireEvent.click(screen.getByRole('button', { name: 'Set' })); + fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: draft } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole number of hours above zero'); + expect(mocks.setFacilityAgreedSendingHours).not.toHaveBeenCalled(); + }); + + it('clears the agreed sending time with null', async () => { + const onSaved = vi.fn(); + mocks.setFacilityAgreedSendingHours.mockResolvedValueOnce({ facility_id: FACILITY_ID, agreed_sending_hours: null }); + renderQuality(lateMeasured, false, onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + expect(screen.getByLabelText('Hours after the event')).toHaveValue(AGREED_HOURS); + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(mocks.setFacilityAgreedSendingHours).toHaveBeenCalledWith(FACILITY_ID, null); + }); + + it('says so when saving the agreed sending time fails', async () => { + const onSaved = vi.fn(); + mocks.setFacilityAgreedSendingHours.mockRejectedValueOnce(new Error(crypto.randomUUID())); + renderQuality(summary([fourMetrics.metrics[2]]), false, onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Set' })); + fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: String(AGREED_HOURS) } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('The agreed sending time could not be saved')); + expect(onSaved).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/__tests__/lib/inflow-error.test.ts b/frontend/src/__tests__/lib/inflow-error.test.ts deleted file mode 100644 index da5e926c1..000000000 --- a/frontend/src/__tests__/lib/inflow-error.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { inflowErrorMessage } from '@/lib/inflow-error'; - -describe('Inflow connection errors', () => { - it('explains an empty disabled-route 404 without claiming the backend is down', () => { - expect(inflowErrorMessage(404, {})).toContain('Core backend health does not include the simulator'); - }); - it('preserves a specific upstream error', () => { - expect(inflowErrorMessage(404, { error: 'Fixture missing' })).toBe('Fixture missing'); - }); - it('reports other status failures', () => { - expect(inflowErrorMessage(502, {})).toBe('Inflow Lab request failed: 502'); - }); -}); diff --git a/frontend/src/__tests__/lib/inflow-lab-exception-grouping.test.ts b/frontend/src/__tests__/lib/inflow-lab-exception-grouping.test.ts deleted file mode 100644 index 785fb46d4..000000000 --- a/frontend/src/__tests__/lib/inflow-lab-exception-grouping.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { - classifyExceptionLot, - groupExceptionsByPattern, - summarizeSourceSuppliers, - type LotForGrouping, -} from "@/app/tools/inflow-lab/lib/exception-grouping"; - -function lot(id: string, missingCtes: string[], opts: Partial = {}): LotForGrouping { - return { - lotCode: id, - product: opts.product ?? "Romaine", - missingCtes, - readiness: opts.readiness ?? { - state: "warning", - deliveryLabel: "posted", - exportReady: false, - }, - events: opts.events ?? 4, - cases: opts.cases ?? 4, - supplier: opts.supplier, - }; -} - -function buildHandoffLots(count: number): LotForGrouping[] { - return Array.from({ length: count }, (_, i) => { - const missing = i % 3 === 0 ? ["Shipping"] : i % 3 === 1 ? ["Initial packing"] : ["DC receiving"]; - return lot(`TLC-HANDOFF-${i.toString().padStart(3, "0")}`, missing, { product: "Romaine" }); - }); -} - -function buildSourceLots(count: number, supplierSplit: { harvestSupplier: string; coolingSupplier: string }) { - return Array.from({ length: count }, (_, i) => { - const role = i % 5; - if (role === 0 || role === 1 || role === 2) { - return lot(`TLC-SOURCE-H-${i}`, ["Harvesting"], { supplier: supplierSplit.harvestSupplier }); - } - if (role === 3) { - return lot(`TLC-SOURCE-C-${i}`, ["Cooling"], { supplier: supplierSplit.coolingSupplier }); - } - return lot(`TLC-SOURCE-B-${i}`, ["Harvesting", "Cooling"], { supplier: supplierSplit.harvestSupplier }); - }); -} - -function buildMixedLots(count: number) { - return Array.from({ length: count }, (_, i) => { - const combos = [ - ["Harvesting", "Shipping"], - ["Cooling", "DC receiving"], - ["Initial packing", "Harvesting"], - ["Shipping", "Cooling"], - ]; - return lot(`TLC-MIXED-${i}`, combos[i % combos.length]); - }); -} - -describe("classifyExceptionLot", () => { - it("classifies a lot with only handoff CTEs missing as handoff", () => { - expect(classifyExceptionLot(lot("a", ["Shipping"]))).toBe("handoff"); - expect(classifyExceptionLot(lot("a", ["Initial packing", "DC receiving"]))).toBe("handoff"); - }); - - it("classifies a lot with only source CTEs missing as source", () => { - expect(classifyExceptionLot(lot("a", ["Harvesting"]))).toBe("source"); - expect(classifyExceptionLot(lot("a", ["Harvesting", "Cooling"]))).toBe("source"); - }); - - it("classifies a lot with both handoff and source CTEs missing as mixed", () => { - expect(classifyExceptionLot(lot("a", ["Harvesting", "Shipping"]))).toBe("mixed"); - expect(classifyExceptionLot(lot("a", ["Cooling", "DC receiving"]))).toBe("mixed"); - }); - - it("classifies a lot with no missing CTEs but otherwise blocked as mixed", () => { - expect(classifyExceptionLot(lot("a", []))).toBe("mixed"); - }); -}); - -describe("groupExceptionsByPattern", () => { - it("returns three groups in canonical order: handoff, source, mixed", () => { - const groups = groupExceptionsByPattern([]); - expect(groups.map((g) => g.id)).toEqual(["handoff", "source", "mixed"]); - }); - - it("partitions a 35-lot fixture into the right counts (18 / 10 / 7)", () => { - const fixture: LotForGrouping[] = [ - ...buildHandoffLots(18), - ...buildSourceLots(10, { harvestSupplier: "Supplier B", coolingSupplier: "Supplier C" }), - ...buildMixedLots(7), - ]; - expect(fixture).toHaveLength(35); - - const groups = groupExceptionsByPattern(fixture); - - const byId = Object.fromEntries(groups.map((g) => [g.id, g])); - expect(byId.handoff.lots).toHaveLength(18); - expect(byId.source.lots).toHaveLength(10); - expect(byId.mixed.lots).toHaveLength(7); - }); - - it("flags handoff and source groups as bulk-actionable but mixed as not", () => { - const fixture: LotForGrouping[] = [ - ...buildHandoffLots(2), - ...buildSourceLots(2, { harvestSupplier: "Supplier B", coolingSupplier: "Supplier C" }), - ...buildMixedLots(2), - ]; - - const groups = groupExceptionsByPattern(fixture); - const byId = Object.fromEntries(groups.map((g) => [g.id, g])); - - expect(byId.handoff.bulkActionable).toBe(true); - expect(byId.source.bulkActionable).toBe(true); - expect(byId.mixed.bulkActionable).toBe(false); - }); - - it("preserves lot identity within each group (no reordering across groups)", () => { - const handoffLot = lot("HO-1", ["Shipping"]); - const sourceLot = lot("SR-1", ["Harvesting"]); - const mixedLot = lot("MX-1", ["Harvesting", "Shipping"]); - - const groups = groupExceptionsByPattern([mixedLot, sourceLot, handoffLot]); - const byId = Object.fromEntries(groups.map((g) => [g.id, g])); - - expect(byId.handoff.lots[0].lotCode).toBe("HO-1"); - expect(byId.source.lots[0].lotCode).toBe("SR-1"); - expect(byId.mixed.lots[0].lotCode).toBe("MX-1"); - }); -}); - -describe("summarizeSourceSuppliers", () => { - it("breaks source lots down by supplier and missing-KDE field with overlap", () => { - const sourceLots: LotForGrouping[] = [ - ...Array.from({ length: 4 }, (_, i) => - lot(`B-${i}`, ["Harvesting"], { supplier: "Supplier B" }) - ), - ...Array.from({ length: 2 }, (_, i) => - lot(`C-${i}`, ["Cooling"], { supplier: "Supplier C" }) - ), - ...Array.from({ length: 4 }, (_, i) => - lot(`BC-${i}`, ["Harvesting", "Cooling"], { supplier: "Supplier B" }) - ), - ]; - - const summary = summarizeSourceSuppliers(sourceLots); - const harvestB = summary.harvestSuppliers.find((s) => s.supplier === "Supplier B"); - const coolingC = summary.coolingSuppliers.find((s) => s.supplier === "Supplier C"); - - expect(harvestB?.lotCount).toBe(8); - expect(coolingC?.lotCount).toBe(2); - expect(summary.overlapCount).toBe(4); - }); -}); diff --git a/frontend/src/__tests__/pages/dashboard-navigation.test.tsx b/frontend/src/__tests__/pages/dashboard-navigation.test.tsx index d7a450d9f..f31990594 100644 --- a/frontend/src/__tests__/pages/dashboard-navigation.test.tsx +++ b/frontend/src/__tests__/pages/dashboard-navigation.test.tsx @@ -67,20 +67,14 @@ describe('Dashboard navigation discoverability', () => { expect(screen.queryByText('All Systems Operational')).not.toBeInTheDocument(); }); - it('exposes Inflow Lab as a top-level active dashboard link', async () => { + it('does not surface Inflow Lab in the dashboard navigation', async () => { render(
      Inflow content
      , ); - const inflowLink = screen - .getAllByText('Inflow Lab') - .map((node) => node.closest('a')) - .find((link) => link?.getAttribute('aria-current') === 'page'); - expect(inflowLink).not.toBeNull(); - expect(inflowLink).toHaveAttribute('href', '/dashboard/inflow-lab'); - expect(inflowLink).toHaveAttribute('aria-current', 'page'); + expect(screen.queryByText('Inflow Lab')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /Intake/i })).toBeInTheDocument(); expect(screen.getAllByText('Document Ingestion')[0].closest('a')).toHaveAttribute('href', '/ingest'); }); diff --git a/frontend/src/__tests__/pages/data-import.test.tsx b/frontend/src/__tests__/pages/data-import.test.tsx index 282f46f90..29cfa6759 100644 --- a/frontend/src/__tests__/pages/data-import.test.tsx +++ b/frontend/src/__tests__/pages/data-import.test.tsx @@ -50,6 +50,12 @@ function csvResult(accepted: number, rejected: number) { describe('Data Import Hub CSV results', () => { beforeEach(() => vi.clearAllMocks()); + it('does not render the sample dataset simulation control', () => { + render(); + expect(screen.queryByText('Load Sample Dataset')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /run simulation/i })).not.toBeInTheDocument(); + }); + it.each([ [2, 0, 'CSV processed'], [1, 1, 'CSV partially accepted'], diff --git a/frontend/src/__tests__/pages/docs-contract.test.tsx b/frontend/src/__tests__/pages/docs-contract.test.tsx index 812b5dcdd..8500feffa 100644 --- a/frontend/src/__tests__/pages/docs-contract.test.tsx +++ b/frontend/src/__tests__/pages/docs-contract.test.tsx @@ -6,7 +6,6 @@ import AlphaRedirect from '@/app/alpha/page'; import DevelopersPage from '@/app/developers/page'; import DocsHomePage from '@/app/docs/page'; import AuthenticationDocsPage from '@/app/docs/authentication/page'; -import InflowLabConnectorPage from '@/app/docs/connectors/inflow-lab/page'; import ErrorCodesPage from '@/app/docs/errors/page'; import FSMA204GuidePage from '@/app/docs/fsma-204/page'; import QuickstartPage from '@/app/docs/quickstart/page'; @@ -15,7 +14,6 @@ import SDKsDocsPage, { metadata as sdksMetadata } from '@/app/docs/sdks/page'; import WalkthroughPage from '@/app/docs/walkthrough/page'; import WebhooksDocsPage, { metadata as webhooksMetadata } from '@/app/docs/webhooks/page'; import { ApiReferenceClient } from '@/app/docs/api/ApiReferenceClient'; -import ingestReceiving from '@/lib/api-samples/ingest-receiving.json'; import requiredKdes from '@/lib/api-samples/required-kdes.json'; import { INGEST_CURL_SAMPLE, @@ -63,14 +61,6 @@ describe('docs API key prefix is unified across surfaces (issue #2309)', () => { } }); - it('inflow-lab connector doc uses only rge_live_', () => { - render(); - const pageText = document.body.textContent ?? ''; - expect(pageText).toContain('rge_live_'); - for (const bad of FORBIDDEN_PREFIXES) { - expect(pageText).not.toContain(bad); - } - }); }); /** @@ -88,7 +78,6 @@ const DEVELOPER_PAGES: [string, () => ReactElement][] = [ ['developers', () => ], ['FSMA 204 guide', () => ], ['walkthrough', () => ], - ['Inflow Lab connector', () => ], ]; /** Pages that show a full ingest call. Each must render it from src/lib/api-samples, never carry its own payload. */ @@ -96,7 +85,6 @@ const INGEST_CALL_PAGES: [string, () => ReactElement][] = [ ['quickstart', () => ], ['FSMA 204 guide', () => ], ['walkthrough', () => ], - ['Inflow Lab connector', () => ], ]; /** The API reference hides endpoint detail until a card is opened. */ @@ -148,11 +136,12 @@ describe('developer docs stay true against the backend', () => { expect(pageText).toContain('"timestamp": "$NOW"'); // The unsigned samples sent their payload with an inline -d '{...}'. expect(pageText).not.toMatch(/-d '\{/); - // A created lot is permanent: every lot code in a sample carries the TEST- prefix. + // Docs carry no invented records: every lot code shown is a `<...>` field + // description to fill in, never a fabricated lot (a posted lot is permanent). const lots = Array.from(pageText.matchAll(/"traceability_lot_code":\s*"([^"]+)"/g)).map((m) => m[1]); - expect(lots.length).toBeGreaterThan(0); for (const lot of lots) { - expect(lot.startsWith('TEST-'), lot).toBe(true); + expect(lot.startsWith('<'), lot).toBe(true); + expect(lot.startsWith('TEST-'), lot).toBe(false); } }); @@ -243,27 +232,22 @@ describe('developer docs stay true against the backend', () => { }); describe('shared ingest sample', () => { - it('keeps placeholders in the JSON and a TEST- lot code (writes are permanent and billed)', () => { - const raw = JSON.stringify(ingestReceiving); - - expect(raw).not.toMatch(/\d{4}-\d{2}-\d{2}/); - expect(raw).toContain('{{EVENT_TIMESTAMP}}'); - expect(raw).toContain('{{EVENT_DATE}}'); - for (const event of ingestReceiving.events) { - expect(event.traceability_lot_code.startsWith('TEST-')).toBe(true); + it('carries no invented records: no TEST- lots, no fabricated suppliers, no fixed dates', () => { + for (const sample of [INGEST_CURL_SAMPLE, INGEST_PYTHON_SAMPLE, INGEST_JS_SAMPLE]) { + expect(sample).not.toContain('TEST-'); + expect(sample).not.toContain('{{'); + expect(sample).not.toMatch(/"timestamp":\s*"\d{4}-\d{2}-\d{2}/); + expect(sample).not.toMatch(/"\w+_date":\s*"\d{4}-\d{2}-\d{2}"/); + // Every value in the body skeleton is a `<...>` field description. + expect(sample).toContain('"<'); } }); - it('renders every language from the same payload with a clock-derived timestamp', () => { - const lot = ingestReceiving.events[0].traceability_lot_code; - + it('renders every language from the same skeleton with a clock-derived timestamp', () => { for (const sample of [INGEST_CURL_SAMPLE, INGEST_PYTHON_SAMPLE, INGEST_JS_SAMPLE]) { - expect(sample).toContain(lot); - expect(sample).not.toContain('{{'); expect(sample).toContain('X-Webhook-Signature'); expect(sample).toContain('Idempotency-Key'); expect(sample).not.toMatch(/X-Tenant-ID/); - expect(sample).not.toMatch(/"timestamp":\s*"\d{4}-\d{2}-\d{2}/); } expect(INGEST_CURL_SAMPLE).toContain('"timestamp": "$NOW"'); diff --git a/frontend/src/__tests__/pages/experimental-gating.test.tsx b/frontend/src/__tests__/pages/experimental-gating.test.tsx index 16ea91e3b..9d8bf72b6 100644 --- a/frontend/src/__tests__/pages/experimental-gating.test.tsx +++ b/frontend/src/__tests__/pages/experimental-gating.test.tsx @@ -8,11 +8,14 @@ import DashboardScanPage from '@/app/dashboard/scan/page'; import FSMAFieldCapturePage from '@/app/fsma/field-capture/page'; import { ToolsLandingClient } from '@/app/tools/components/ToolsLandingClient'; import { useAuth } from '@/lib/auth-context'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname, useRouter, notFound } from 'next/navigation'; vi.mock('next/navigation', () => ({ usePathname: vi.fn(), useRouter: vi.fn(), + notFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND'); + }), })); vi.mock('@/lib/auth-context', () => ({ @@ -27,10 +30,6 @@ vi.mock('@/components/dashboard/error-boundary', () => ({ DashboardErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}, })); -vi.mock('@/app/tools/inflow-lab/components/DashboardInflowLab', () => ({ - DashboardInflowLab: () =>
      , -})); - vi.mock('@/components/mobile/FieldCaptureClient', () => ({ FieldCaptureClient: () =>
      , })); @@ -65,12 +64,6 @@ describe('experimental feature gating (flag off)', () => { expect(screen.getByText(/Audit review is not enabled/i)).toBeInTheDocument(); }); - it('renders a feature-not-enabled state instead of the dashboard Inflow Lab', () => { - render(); - expect(screen.getByTestId('feature-not-enabled')).toBeInTheDocument(); - expect(screen.queryByTestId('inflow-lab-dashboard')).not.toBeInTheDocument(); - }); - it('renders a feature-not-enabled state instead of the field capture scanner', () => { render(); expect(screen.getByTestId('feature-not-enabled')).toBeInTheDocument(); @@ -128,15 +121,14 @@ describe('experimental feature gating (flag on)', () => {
      content
      , ); - expect(screen.getAllByText('Inflow Lab').length).toBeGreaterThan(0); + expect(screen.queryByText('Inflow Lab')).not.toBeInTheDocument(); expect(screen.getAllByText('FSMA 204 Dashboard').length).toBeGreaterThan(0); expect(screen.getAllByText('Field Capture').length).toBeGreaterThan(0); }); - it('renders the real dashboard Inflow Lab page', () => { - render(); - expect(screen.getByTestId('inflow-lab-dashboard')).toBeInTheDocument(); - expect(screen.queryByTestId('feature-not-enabled')).not.toBeInTheDocument(); + it('returns 404 for the retired dashboard Inflow Lab route even when the flag is on', () => { + expect(() => DashboardInflowLabPage()).toThrow('NEXT_NOT_FOUND'); + expect(notFound).toHaveBeenCalled(); }); it('renders the real field capture scanner', () => { diff --git a/frontend/src/__tests__/pages/inflow-lab.test.tsx b/frontend/src/__tests__/pages/inflow-lab.test.tsx deleted file mode 100644 index c04f28428..000000000 --- a/frontend/src/__tests__/pages/inflow-lab.test.tsx +++ /dev/null @@ -1,761 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -vi.mock('@/lib/auth-context', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useAuth: vi.fn().mockReturnValue({ - isAuthenticated: false, - isHydrated: true, - tenantId: null, - }), - }; -}); - -vi.mock('@/lib/tenant-context', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useTenant: vi.fn().mockReturnValue({ tenantId: null }), - }; -}); - -vi.mock('@/lib/fetch-with-csrf', () => ({ - fetchWithCsrf: vi.fn((input: RequestInfo | URL, init?: RequestInit) => - (globalThis as unknown as { fetch: typeof fetch }).fetch(input, init), - ), -})); - -import DashboardInflowLabPage from '@/app/dashboard/inflow-lab/page'; -import { AuthProvider } from '@/lib/auth-context'; -import { TenantProvider } from '@/lib/tenant-context'; -import { useAuth } from '@/lib/auth-context'; -import { useTenant } from '@/lib/tenant-context'; - -const completeLot = '00614141000012-20260426-000001'; -const partialLot = '00614141000012-20260426-000003'; - -function inflowRecord(sequence_no: number, lotCode: string, cteType: string) { - return { - sequence_no, - destination_mode: 'mock', - delivery_status: 'posted', - delivery_attempts: 1, - event: { - cte_type: cteType, - traceability_lot_code: lotCode, - product_description: lotCode === partialLot ? 'Green Leaf Lettuce' : 'Spring Mix', - location_name: `${cteType} station`, - timestamp: '2026-04-26T18:12:00.000Z', - }, - }; -} - -const completeLotRecords = [ - inflowRecord(5, completeLot, 'harvesting'), - inflowRecord(4, completeLot, 'cooling'), - inflowRecord(3, completeLot, 'initial_packing'), - inflowRecord(2, completeLot, 'shipping'), - inflowRecord(1, completeLot, 'receiving'), -]; - -const serviceRecords = [ - ...completeLotRecords, - inflowRecord(7, partialLot, 'harvesting'), - inflowRecord(6, partialLot, 'cooling'), -]; - -const sandboxEvaluation = { - total_events: 1, - compliant_events: 1, - non_compliant_events: 0, - total_kde_errors: 0, - total_rule_failures: 0, - submission_blocked: false, - blocking_reasons: [], - events: [ - { - event_index: 0, - cte_type: 'shipping', - traceability_lot_code: 'TLC-FEED-001', - product_description: 'Romaine Lettuce', - kde_errors: [], - rules_failed: 0, - rules_warned: 0, - compliant: true, - all_results: [], - }, - ], -}; - -const savedWorkbenchRun = { - run_id: 'run-123456789abc', - tenant_id: 'tenant-123', - source: 'inflow-lab-data-feeder', - csv: 'cte_type,traceability_lot_code\nshipping,TLC-FEED-001', - result: sandboxEvaluation, - readiness: { - score: 94, - label: 'export ready after authenticated commit', - components: [], - }, - fix_queue: [], - commit_gate: { - mode: 'staging', - allowed: true, - export_eligible: false, - reasons: ['Ready to request authenticated production commit.'], - next_state: 'production_evidence', - }, - saved_at: '2026-05-06T18:00:00.000Z', - input_hash: 'abc123', - result_hash: 'def456', - commit_decision_id: 'run-123456789abc:staging', -}; - -const savedWorkbenchSummary = { - tenant_id: 'tenant-123', - score: 94, - label: 'export ready after authenticated commit', - run_id: savedWorkbenchRun.run_id, - saved_at: savedWorkbenchRun.saved_at, - unresolved_fix_count: 0, - export_eligible: false, - source: savedWorkbenchRun.source, -}; - -function jsonResponse(payload: unknown, init?: ResponseInit) { - return Promise.resolve( - new Response(JSON.stringify(payload), { - status: 200, - headers: { 'content-type': 'application/json' }, - ...init, - }), - ); -} - -// Stands in for rows the user pastes: header plus one opaque row, no record values. -const USER_CSV = 'cte_type,traceability_lot_code\nreceiving,user-lot'; - -async function enterUserCsv(user: ReturnType) { - await user.click(await screen.findByRole('button', { name: 'Open data editor' })); - const textarea = await screen.findByRole('textbox', { name: 'Inbound CSV data' }); - fireEvent.change(textarea, { target: { value: USER_CSV } }); -} - -function renderDashboardInflowLabPage() { - return render( - - - - - , - ); -} - -describe('Dashboard Inflow Lab', () => { - const mockFetch = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - window.HTMLElement.prototype.scrollIntoView = vi.fn(); - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: false, - isHydrated: true, - tenantId: null, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ - tenantId: null, - setTenantId: vi.fn(), - isSystemTenant: false, - } as unknown as ReturnType); - const localStore: Record = {}; - const sessionStore: Record = {}; - vi.stubGlobal('localStorage', { - getItem: vi.fn((key: string) => localStore[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - localStore[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete localStore[key]; - }), - clear: vi.fn(() => { - Object.keys(localStore).forEach((key) => delete localStore[key]); - }), - length: 0, - key: vi.fn(() => null), - }); - vi.stubGlobal('sessionStorage', { - getItem: vi.fn((key: string) => sessionStore[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - sessionStore[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete sessionStore[key]; - }), - clear: vi.fn(() => { - Object.keys(sessionStore).forEach((key) => delete sessionStore[key]); - }), - length: 0, - key: vi.fn(() => null), - }); - mockFetch.mockImplementation((input: RequestInfo | URL) => { - const url = String(input); - if (url.includes('/api/ingestion/api/v1/inflow-workbench/readiness/summary')) { - return jsonResponse({ - tenant_id: 'tenant-123', - unresolved_fix_count: 0, - export_eligible: false, - source: 'none', - }); - } - if (url.includes(`/api/ingestion/api/v1/inflow-workbench/runs/${savedWorkbenchRun.run_id}`)) { - return jsonResponse(savedWorkbenchRun); - } - if (url.endsWith('/api/ingestion/api/v1/sandbox/evaluate')) { - return jsonResponse(sandboxEvaluation); - } - if (url.endsWith('/api/ingestion/api/v1/inflow-workbench/readiness/preview')) { - return jsonResponse({ - score: 94, - label: 'export ready after authenticated commit', - components: [], - }); - } - if (url.endsWith('/api/ingestion/api/v1/inflow-workbench/runs')) { - return jsonResponse(savedWorkbenchRun); - } - if (url.endsWith('/api/healthz')) { - return jsonResponse({ ok: true, build: { version: 'test-build' } }); - } - if (url.endsWith('/api/simulate/status')) { - return jsonResponse({ - running: false, - stats: { - total_records: serviceRecords.length, - unique_lots: 2, - delivery: { posted: serviceRecords.length, failed: 0, generated: 0, attempts: serviceRecords.length }, - }, - }); - } - if (url.includes('/api/events')) { - return jsonResponse({ events: serviceRecords }); - } - if (url.includes(`/api/lineage/${encodeURIComponent(completeLot)}`)) { - return jsonResponse({ records: completeLotRecords }); - } - if (url.includes(`/api/lineage/${encodeURIComponent(partialLot)}`)) { - return jsonResponse({ - records: serviceRecords.filter( - (record) => record.event.traceability_lot_code === partialLot, - ), - }); - } - return jsonResponse({}); - }); - vi.stubGlobal('fetch', mockFetch); - }); - - it('explains an empty release-disabled simulator 404', async () => { - mockFetch.mockResolvedValue(new Response(null, { status: 404 })); - renderDashboardInflowLabPage(); - expect(await screen.findByText(/Inflow Lab service connection:.*unavailable/)).toHaveTextContent('Core backend health does not include the simulator'); - }); - - it('renders the Inflow Lab header with one Sandbox chip and a single boundary banner', async () => { - renderDashboardInflowLabPage(); - - expect(screen.getByRole('heading', { name: 'Inflow Lab' })).toBeInTheDocument(); - - const sandboxChips = screen.getAllByText('Sandbox'); - expect(sandboxChips).toHaveLength(1); - - const banner = screen.getByRole('status'); - expect(banner).toHaveTextContent('Sandbox preview only.'); - expect(banner).toHaveTextContent( - /Production FDA-oriented exports stay sealed and review-ready\. Nothing here writes to them\./, - ); - - expect(screen.queryByText('Mock environment')).not.toBeInTheDocument(); - expect(screen.queryByText('Boundary active')).not.toBeInTheDocument(); - expect(screen.queryByText('Sandbox diagnosis')).not.toBeInTheDocument(); - }); - - it('shows the business activity as a traced chain without exposing internal scoring details', async () => { - renderDashboardInflowLabPage(); - - const chain = await screen.findByTestId('inflow-evidence-chain'); - // The handoff is shown as evidence rather than described in prose: every - // CTE from harvest to receive, ending in the consequence. - // Matched loosely because each label carries an sr-only state suffix, - // and the mock's location names ("cooling station") repeat the CTE word. - for (const cte of ['Harvesting', 'Cooling', 'Initial packing', 'Shipping', 'DC receiving']) { - expect(within(chain).getAllByText(cte, { exact: false }).length).toBeGreaterThan(0); - } - expect(within(chain).getByText('Export gate')).toBeInTheDocument(); - - // The scoring recipe stays internal — the chain reports what is missing - // and what to do about it, never how readiness is computed. - expect( - within(chain).queryByText(/weighting|coefficient|scoring model|formula/i), - ).not.toBeInTheDocument(); - }); - - it('reports one lead defect and one fix path', async () => { - renderDashboardInflowLabPage(); - - const chain = await screen.findByTestId('inflow-evidence-chain'); - expect(await within(chain).findByText(/records? never arrived\./)).toBeInTheDocument(); - expect(within(chain).getByText('Missing')).toBeInTheDocument(); - expect(within(chain).getByText('Pattern')).toBeInTheDocument(); - expect(within(chain).getByText('Lots affected')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Focus this defect' })).toBeInTheDocument(); - }); - - it('uses the proof-lead action to select the blocked lot and open its story in the rail', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await user.click(await screen.findByRole('button', { name: 'Focus this defect' })); - - expect(await screen.findByText(/This proof lead is blocked on/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Show matching queue group' })).toBeInTheDocument(); - expect(document.querySelector('[data-exception-group="handoff"]')).toHaveClass('bg-rose-100/70'); - expect(document.querySelector('[data-selected-lot-panel]')).toHaveAttribute('data-proof-lead-highlight', 'true'); - }); - - it('renders the 5-step funnel with a single Run sandbox test CTA in the active step', async () => { - renderDashboardInflowLabPage(); - - const funnel = await screen.findByTestId('inflow-funnel'); - expect(within(funnel).getByText('Loaded')).toBeInTheDocument(); - expect(within(funnel).getByText('Validated')).toBeInTheDocument(); - expect(within(funnel).getByText('Passing')).toBeInTheDocument(); - expect(within(funnel).getByText('Preview-ready')).toBeInTheDocument(); - expect(within(funnel).getByText('Production import')).toBeInTheDocument(); - - const sandboxButtons = screen.getAllByRole('button', { name: /Run sandbox test/i }); - expect(sandboxButtons).toHaveLength(1); - - const activeStep = funnel.querySelector('[data-funnel-step="passing"]'); - expect(activeStep).not.toBeNull(); - expect(activeStep!).toContainElement(sandboxButtons[0]); - }); - - it('renders the readiness panel with five stacked-bar factors', async () => { - renderDashboardInflowLabPage(); - - await waitFor(() => { - expect(screen.getByText('KDE completeness')).toBeInTheDocument(); - }); - expect(screen.getByText('CTE lifecycle')).toBeInTheDocument(); - expect(screen.getByText('Delivery quality')).toBeInTheDocument(); - expect(screen.getByText('Sandbox pass rate')).toBeInTheDocument(); - expect(screen.getByText('Connection health')).toBeInTheDocument(); - expect(screen.getByText('not run')).toBeInTheDocument(); - }); - - it('renders exceptions as failure-pattern groups, hiding empty groups on the all filter', async () => { - renderDashboardInflowLabPage(); - - // The fixture has one handoff-style partial lot; the handoff group must - // render. Anchor on the rendered group node directly instead of a - // parent
      , because the card structure around the Exceptions - // heading is layout-driven and has changed over time. - const handoffNode = await waitFor(() => { - const node = document.querySelector('[data-exception-group="handoff"]'); - if (!node) throw new Error('handoff group not rendered'); - return node as HTMLElement; - }); - expect(within(handoffNode as HTMLElement).getByText('Missing handoff evidence')).toBeInTheDocument(); - expect(screen.queryByText('Missing source evidence')).not.toBeInTheDocument(); - expect(screen.queryByText('Mixed / multi-cause')).not.toBeInTheDocument(); - }); - - it('shows an empty-filter state when the active filter selects a group with 0 lots', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await waitFor(() => { - expect(screen.getByText('Exceptions')).toBeInTheDocument(); - }); - // Click the 'mixed' filter chip; the fixture has no mixed lots so the - // section must surface a clear empty-filter state with a Clear filter - // affordance. - await user.click(screen.getByRole('button', { name: 'mixed' })); - const empty = await waitFor(() => { - const node = document.querySelector('[data-empty-filter-state]'); - if (!node) throw new Error('empty-filter state not rendered'); - return node as HTMLElement; - }); - expect(empty).toHaveTextContent(/No lots match the/); - expect(within(empty).getByRole('button', { name: 'Clear filter' })).toBeInTheDocument(); - }); - - it('handoff group has a Fix all bulk CTA, mixed group never does', async () => { - renderDashboardInflowLabPage(); - const handoff = await waitFor(() => { - const node = document.querySelector('[data-exception-group="handoff"]'); - if (!node) throw new Error('handoff group not rendered'); - return node as HTMLElement; - }); - // "Fix all" is a navigation into the fix queue, so it renders as a - // link (role=link) rather than an inert button. - const fixAll = within(handoff).getByRole('link', { name: /^Fix all /i }); - expect(fixAll).toHaveAttribute('href', '/exceptions'); - - // The mixed group, even when populated, must never carry a Fix all - // bulk CTA per the audit (per-lot triage only). Filter to mixed and - // confirm the empty-filter card shows up rather than a Fix all. - // (When the fixture produces mixed lots in a future test, the - // assertion is the same: data-exception-group="mixed" never contains - // a "Fix all" button.) - const mixed = document.querySelector('[data-exception-group="mixed"]'); - if (mixed) { - expect(within(mixed as HTMLElement).queryByText(/^Fix all /)).not.toBeInTheDocument(); - } - }); - - it('keeps the selected lot detail in a sticky right rail with Lifecycle and KDE tiles', async () => { - renderDashboardInflowLabPage(); - - // ``Selected lot`` is the section heading that renders even when no - // lot is loaded yet. Wait for ``Lifecycle`` instead — it only appears - // once selectedLotData is non-null (i.e. a lot has been picked from - // the fetched events). Use findByText for built-in timeout. - expect(await screen.findByText('Lifecycle')).toBeInTheDocument(); - expect(screen.getByText('Selected lot')).toBeInTheDocument(); - expect(screen.getByText('Lot KDE summary')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Fix this lot' })).toBeInTheDocument(); - expect(screen.getByText(/Proof lead lot .* is dragging down/i)).toBeInTheDocument(); - }); - - it('triggers a sandbox evaluation when Run sandbox test is pressed in the funnel', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - expect(screen.getByText(/Paste CSV text or upload a CSV file before evaluating\./i)).toBeInTheDocument(); - }); - - it('offers no sample data loader, only Upload CSV for the user\'s own rows', () => { - renderDashboardInflowLabPage(); - - expect(screen.queryByRole('button', { name: /sample/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /example/i })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Upload CSV' })).toBeInTheDocument(); - }); - - it('does not evaluate anything when Run trace is pressed before the user adds rows', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await user.click(await screen.findByRole('button', { name: /Run trace/ })); - - expect(screen.getByText(/Paste CSV text or upload a CSV file before evaluating\./i)).toBeInTheDocument(); - const textarea = await screen.findByRole('textbox', { name: 'Inbound CSV data' }); - expect((textarea as HTMLTextAreaElement).value).toBe(''); - expect( - mockFetch.mock.calls.some(([url]) => String(url) === '/api/ingestion/api/v1/sandbox/evaluate'), - ).toBe(false); - }); - - it('runs the trace on the user\'s own rows without saving until requested', async () => { - const user = userEvent.setup(); - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: true, - isHydrated: true, - tenantId: 'tenant-123', - user: { - id: 'writer-1', - email: 'writer@example.com', - is_sysadmin: false, - status: 'active', - role_name: 'member', - permissions: ['inflow.write'], - }, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ tenantId: 'tenant-123' } as ReturnType); - - renderDashboardInflowLabPage(); - - await enterUserCsv(user); - await user.click(screen.getByRole('button', { name: /Run trace/ })); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/sandbox/evaluate', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ csv: USER_CSV }), - }), - ); - }); - expect(await screen.findByText(/Sandbox evaluator processed 1 event/i)).toBeInTheDocument(); - expect( - mockFetch.mock.calls.filter( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/runs', - ), - ).toHaveLength(0); - - await user.click(screen.getByRole('button', { name: 'Save test run' })); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/inflow-workbench/runs', - expect.objectContaining({ method: 'POST' }), - ); - }); - }); - - it('keeps public guided proof runs local without calling authenticated readiness preview', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await enterUserCsv(user); - await user.click(screen.getByRole('button', { name: /Run trace/ })); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/sandbox/evaluate', - expect.objectContaining({ method: 'POST' }), - ); - }); - expect( - mockFetch.mock.calls.some( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/readiness/preview', - ), - ).toBe(false); - expect( - mockFetch.mock.calls.some( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/runs', - ), - ).toBe(false); - }); - - it('loads the latest saved workbench run when the dashboard opens with an authenticated tenant session', async () => { - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: true, - isHydrated: true, - tenantId: 'tenant-123', - user: { - id: 'reader-1', - email: 'reader@example.com', - is_sysadmin: false, - status: 'active', - role_name: 'member', - permissions: ['inflow.read'], - }, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ tenantId: 'tenant-123' } as ReturnType); - mockFetch.mockImplementation((input: RequestInfo | URL) => { - const url = String(input); - if (url.includes('/api/ingestion/api/v1/inflow-workbench/readiness/summary')) { - return jsonResponse(savedWorkbenchSummary); - } - if (url.includes(`/api/ingestion/api/v1/inflow-workbench/runs/${savedWorkbenchRun.run_id}`)) { - return jsonResponse(savedWorkbenchRun); - } - if (url.endsWith('/api/healthz')) { - return jsonResponse({ ok: true, build: { version: 'test-build' } }); - } - if (url.endsWith('/api/simulate/status')) { - return jsonResponse({ - running: false, - stats: { - total_records: serviceRecords.length, - unique_lots: 2, - delivery: { posted: serviceRecords.length, failed: 0, generated: 0, attempts: serviceRecords.length }, - }, - }); - } - if (url.includes('/api/events')) { - return jsonResponse({ events: serviceRecords }); - } - if (url.includes(`/api/lineage/${encodeURIComponent(completeLot)}`)) { - return jsonResponse({ records: completeLotRecords }); - } - if (url.includes(`/api/lineage/${encodeURIComponent(partialLot)}`)) { - return jsonResponse({ - records: serviceRecords.filter( - (record) => record.event.traceability_lot_code === partialLot, - ), - }); - } - return jsonResponse({}); - }); - - renderDashboardInflowLabPage(); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/inflow-workbench/readiness/summary?tenant_id=tenant-123', - expect.objectContaining({ method: 'GET' }), - ); - }); - expect( - await screen.findByText(/Latest saved workbench run/i), - ).toBeInTheDocument(); - expect(screen.getByText('run-123456789abc')).toBeInTheDocument(); - expect(screen.getByText(/0 open fixes remain in workbench\./i)).toBeInTheDocument(); - expect(screen.queryByText(/Last test run · never/i)).not.toBeInTheDocument(); - }); - - it('keeps sandbox evaluation non-persistent until Save test run is pressed', async () => { - const user = userEvent.setup(); - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: true, - isHydrated: true, - tenantId: 'tenant-123', - user: { - id: 'writer-1', - email: 'writer@example.com', - is_sysadmin: false, - status: 'active', - role_name: 'member', - permissions: ['inflow.write'], - }, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ tenantId: 'tenant-123' } as ReturnType); - - renderDashboardInflowLabPage(); - - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - const textarea = await screen.findByRole('textbox', { name: 'Inbound CSV data' }); - await user.type(textarea, 'cte_type,traceability_lot_code\nshipping,TLC-FEED-001'); - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - - await waitFor(() => { - expect(screen.getByText(/Sandbox evaluator processed 1 event/i)).toBeInTheDocument(); - }); - expect( - mockFetch.mock.calls.filter( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/runs', - ), - ).toHaveLength(0); - expect(screen.getByRole('button', { name: 'Save test run' })).toBeInTheDocument(); - expect( - screen.getByText(/Sandbox evaluation is stateless until you explicitly save a test run\./i), - ).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Save test run' })); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/inflow-workbench/runs', - expect.objectContaining({ method: 'POST' }), - ); - }); - expect(await screen.findByText(/Saved to Inflow Workbench as/i)).toBeInTheDocument(); - expect(screen.getByText('run-123456789abc')).toBeInTheDocument(); - expect(screen.getByText(/0 open fixes remain for this saved test run\./i)).toBeInTheDocument(); - expect(screen.queryByText(/Last test run · never/i)).not.toBeInTheDocument(); - }); - - it('disables saving when the session lacks inflow.write even if the role name looks privileged', async () => { - const user = userEvent.setup(); - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: true, - isHydrated: true, - tenantId: 'tenant-123', - user: { - id: 'owner-readonly-1', - email: 'readonly-owner@example.com', - is_sysadmin: false, - status: 'active', - role_name: 'owner', - permissions: ['inflow.read'], - }, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ tenantId: 'tenant-123' } as ReturnType); - - renderDashboardInflowLabPage(); - - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - const textarea = await screen.findByRole('textbox', { name: 'Inbound CSV data' }); - await user.type(textarea, 'cte_type,traceability_lot_code\nshipping,TLC-FEED-001'); - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - - const saveButton = await screen.findByRole('button', { name: 'Save unavailable' }); - expect(saveButton).toBeDisabled(); - - expect( - await screen.findByText( - /You can evaluate sandbox data here, but only users with write access can save an Inflow Workbench test run\./i, - ), - ).toBeInTheDocument(); - expect( - mockFetch.mock.calls.filter( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/runs', - ), - ).toHaveLength(0); - expect(screen.queryByText(/Saved to Inflow Workbench as/i)).not.toBeInTheDocument(); - }); - - it('opens fix queue navigation from the section-level button', async () => { - renderDashboardInflowLabPage(); - const link = await screen.findByRole('link', { name: 'Open fix queue' }); - // Canonical exceptions route is top-level /exceptions (mirrors the - // dashboard sidebar entry); /dashboard/exceptions does not exist. - expect(link).toHaveAttribute('href', '/exceptions'); - }); - - it('shows a clickable tutorial banner that opens the step-by-step walkthrough', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - const banner = await screen.findByTestId('inflow-tutorial-banner'); - expect(banner).toHaveTextContent('New to Inflow Lab? Learn to operate it step by step'); - - await user.click( - within(banner).getByRole('button', { name: /Learn to operate it step by step/i }), - ); - - expect(await screen.findByText('Step 1 of 7')).toBeInTheDocument(); - expect(screen.getByText('What Inflow Lab is')).toBeInTheDocument(); - }); - - it('walks forward through the tutorial steps', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - const banner = await screen.findByTestId('inflow-tutorial-banner'); - await user.click( - within(banner).getByRole('button', { name: /Learn to operate it step by step/i }), - ); - await screen.findByText('Step 1 of 7'); - - await user.click(screen.getByRole('button', { name: /Next/i })); - expect(await screen.findByText('Step 2 of 7')).toBeInTheDocument(); - expect(screen.getByText('Bring supplier data in')).toBeInTheDocument(); - }); - - it('dismisses the tutorial banner without disabling the How to use entry point', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - const banner = await screen.findByTestId('inflow-tutorial-banner'); - await user.click(within(banner).getByRole('button', { name: 'Dismiss tutorial banner' })); - - await waitFor(() => { - expect(screen.queryByTestId('inflow-tutorial-banner')).not.toBeInTheDocument(); - }); - - // The walkthrough must stay reachable from the header after dismissal. - await user.click(screen.getByRole('button', { name: /How to use/i })); - expect(await screen.findByText('Step 1 of 7')).toBeInTheDocument(); - }); - - it('exposes filter chips for all/handoff/source/mixed', async () => { - renderDashboardInflowLabPage(); - await waitFor(() => { - expect(screen.getByText('Filter')).toBeInTheDocument(); - }); - expect(screen.getByRole('button', { name: 'all' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'handoff' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'source' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'mixed' })).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/__tests__/pages/ingest-epcis.test.tsx b/frontend/src/__tests__/pages/ingest-epcis.test.tsx index 04750adbb..ccdacf7aa 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -136,7 +136,7 @@ describe('EPCIS 2.0 import on /ingest', () => { it('offers an EPCIS 2.0 tab with a JSON/XML chooser and no sample file link', async () => { await openEpcisTab(); - expect(screen.getByText('CSV uploads skip bad rows. EPCIS files are all or nothing: if any event fails, nothing is saved.')).toBeInTheDocument(); + expect(screen.getByText('CSV uploads skip bad rows. EPCIS (Preview) files are all or nothing: if any event fails, nothing is saved.')).toBeInTheDocument(); expect(document.getElementById('epcis-file-upload')).toHaveAttribute( 'accept', '.json,application/json,.xml,application/xml,text/xml', @@ -284,7 +284,7 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(mocks.validateEpcis).not.toHaveBeenCalled(); expect(mocks.ingestFile).not.toHaveBeenCalled(); - expect(screen.queryByText('EPCIS processed')).not.toBeInTheDocument(); + expect(screen.queryByText('EPCIS (Preview) processed')).not.toBeInTheDocument(); }); it('shows backend failures with the same error pattern as the CSV tab', async () => { @@ -296,7 +296,7 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); expect(screen.getByText('Batch ingest failed')).toBeInTheDocument(); - expect(screen.queryByText('EPCIS processed')).not.toBeInTheDocument(); + expect(screen.queryByText('EPCIS (Preview) processed')).not.toBeInTheDocument(); expect(mocks.refresh).not.toHaveBeenCalled(); }); diff --git a/frontend/src/__tests__/pages/pre-revenue-copy.test.ts b/frontend/src/__tests__/pages/pre-revenue-copy.test.ts index 8d6a5b488..52a2ae3a7 100644 --- a/frontend/src/__tests__/pages/pre-revenue-copy.test.ts +++ b/frontend/src/__tests__/pages/pre-revenue-copy.test.ts @@ -48,14 +48,15 @@ describe('pre-revenue public copy', () => { } }); - it('keeps the published $425/mo price on pricing surfaces with the Founding rate wording', () => { + it('shows no hardcoded prices on pricing surfaces', () => { const client = read('src/components/pricing/PricingPageClient.tsx'); - expect(client).toContain('RegEngine's Founding rate starts at $425/mo (billed annually).'); - expect(client).toContain('The Founding rate starts at $425/mo, billed annually.'); + expect(client).not.toContain('$425'); expect(client).toContain('Founding rate\n'); const page = read('src/app/pricing/page.tsx'); - expect(page).toContain('FSMA 204 traceability readiness from $425/mo.'); + expect(page).not.toContain('$425'); + expect(page).not.toMatch(/gaMonthly: \d/); + expect(page).toContain('Pricing is temporarily unavailable'); expect(page).toContain('What does the Founding rate include?'); }); diff --git a/frontend/src/__tests__/pages/proof-recall-drill.test.tsx b/frontend/src/__tests__/pages/proof-recall-drill.test.tsx deleted file mode 100644 index 2b0f65497..000000000 --- a/frontend/src/__tests__/pages/proof-recall-drill.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import RecallDrillPage from '@/app/proof/recall-drill/page'; - -describe('public proof recall drill page', () => { - beforeEach(() => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - steps: [ - { - name: 'Identifying from API', - duration_ms: 100, - status: 'ok', - detail: 'Endpoint loaded the frozen public dataset.', - }, - ], - time_to_trace_seconds: 0.1, - output_csv_url: '/samples/sample_recall_drill.csv', - lot_code: 'SV-20240115-001', - fda_24h_mandate_seconds: 86400, - timestamp: '2026-05-19T00:00:00Z', - }), - }), - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('starts the public backend drill endpoint and renders returned steps', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /start drill/i })); - - expect(fetch).toHaveBeenCalledWith('/api/ingestion/api/v1/proof/recall-drill', { - method: 'POST', - }); - expect(await screen.findByText(/Identifying from API/)).toBeInTheDocument(); - expect( - screen.getByText(/Endpoint loaded the frozen public dataset/i), - ).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/__tests__/pages/recall-report.test.tsx b/frontend/src/__tests__/pages/recall-report.test.tsx index f7db17565..0afa3c454 100644 --- a/frontend/src/__tests__/pages/recall-report.test.tsx +++ b/frontend/src/__tests__/pages/recall-report.test.tsx @@ -2,9 +2,11 @@ * Recall Report Page Tests * * Tests for the recall investigation / readiness report: - * - Renders heading and demo scenario - * - Renders dimension scores - * - Falls back to demo data when API is unavailable + * - Renders heading + * - Shows the "No data yet" empty state for the backend PR #3536 no-data + * contract (status "no_data", score 0, grade "N/A", demo_mode false, + * demo_disclaimer null) + * - Never renders fabricated data or a demo disclaimer * - Auth guard (skips fetch without credentials) */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -22,30 +24,20 @@ function createWrapper() { ); } -// ── Mock demo report matching backend shape ── +// ── No-data report matching the backend PR #3536 contract ── -const MOCK_DEMO_REPORT = { +const MOCK_NO_DATA_REPORT = { tenant_id: 'tenant-123', generated_at: new Date().toISOString(), - overall_score: 72, - overall_grade: 'C', - overall_status: 'needs_improvement', - time_to_respond_estimate: '4.2 hours', - dimensions: [ - { id: 'trace_speed', name: 'Trace Speed', score: 85, grade: 'B', status: 'good', findings: ['Fast lookup'], recommendations: ['Add caching'] }, - { id: 'data_completeness', name: 'Data Completeness', score: 68, grade: 'C', status: 'needs_improvement', findings: ['Missing KDEs'], recommendations: ['Onboard suppliers'] }, - { id: 'chain_integrity', name: 'Chain Integrity', score: 90, grade: 'A', status: 'excellent', findings: ['Hash chain valid'], recommendations: [] }, - { id: 'supplier_coverage', name: 'Supplier Coverage', score: 55, grade: 'D', status: 'at_risk', findings: ['3 of 8 onboarded'], recommendations: ['Prioritize top suppliers'] }, - { id: 'export_readiness', name: 'Export Readiness', score: 78, grade: 'C', status: 'needs_improvement', findings: ['CSV ready'], recommendations: ['Add EPCIS'] }, - { id: 'team_readiness', name: 'Team Readiness', score: 60, grade: 'C', status: 'needs_improvement', findings: ['No drills run'], recommendations: ['Schedule monthly drill'] }, - ], - executive_summary: 'Your facility has basic recall capabilities but gaps remain.', - action_items: [ - { priority: 'HIGH', action: 'Onboard remaining suppliers', impact: 'Coverage +40%', effort: 'Medium' }, - { priority: 'MEDIUM', action: 'Run mock recall drill', impact: 'Team readiness', effort: 'Low' }, - ], - demo_mode: true, - demo_disclaimer: 'Sample Scenario — Explore a realistic recall investigation.', + overall_score: 0, + overall_grade: 'N/A', + overall_status: 'no_data', + time_to_respond_estimate: '', + dimensions: [], + executive_summary: '', + action_items: [], + demo_mode: false, + demo_disclaimer: null, }; // ── Mocks ── @@ -135,8 +127,8 @@ describe('RecallReportPage', () => { vi.clearAllMocks(); mockUseAuth.mockReturnValue({ apiKey: null, isAuthenticated: true }); mockUseTenant.mockReturnValue({ tenantId: 'tenant-123', isSystemTenant: false }); - // Default: API returns demo report with dimensions - mockFetchRecallReport.mockResolvedValue(MOCK_DEMO_REPORT); + // Default: API returns the no-data contract (status "no_data") + mockFetchRecallReport.mockResolvedValue(MOCK_NO_DATA_REPORT); }); afterEach(() => { @@ -157,37 +149,43 @@ describe('RecallReportPage', () => { }); }); - it('renders demo investigation scenario data', async () => { + it('shows the "No data yet" empty state for the no-data contract', async () => { render(, { wrapper: createWrapper() }); + // "No data yet" appears twice: the empty-state title and the grade + // placeholder (backend grade "N/A" renders as the placeholder, not a badge). await waitFor(() => { - expect(screen.getByText(/Romaine Lettuce/)).toBeInTheDocument(); + expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); + expect(screen.queryByText('N/A')).not.toBeInTheDocument(); }); - it('renders readiness dimension scores', async () => { + it('never renders a demo disclaimer', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { - expect(screen.getByText('Trace Speed')).toBeInTheDocument(); + expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); - expect(screen.getByText('Data Completeness')).toBeInTheDocument(); - expect(screen.getByText('Chain Integrity')).toBeInTheDocument(); - expect(screen.getByText('Supplier Coverage')).toBeInTheDocument(); + expect(screen.queryByText(/demo/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/sample scenario/i)).not.toBeInTheDocument(); }); - it('renders affected lots table', async () => { + it('renders no readiness dimensions or action items when the report has none', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { - expect(screen.getByText(/LOT-ROM-2026-0312A/)).toBeInTheDocument(); + expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); + expect(screen.queryByText('Trace Speed')).not.toBeInTheDocument(); + expect(screen.queryByText('Data Completeness')).not.toBeInTheDocument(); + expect(screen.queryByText('Chain Integrity')).not.toBeInTheDocument(); + expect(screen.queryByText('Supplier Coverage')).not.toBeInTheDocument(); + expect(screen.queryByText('Prioritized Actions')).not.toBeInTheDocument(); }); - it('falls back to demo when API returns empty data', async () => { - mockFetchRecallReport.mockResolvedValue({ ...MOCK_DEMO_REPORT, demo_mode: true }); + it('shows the empty state with a next-step link', async () => { render(, { wrapper: createWrapper() }); - // Should still show the demo scenario since demo_mode is true await waitFor(() => { - expect(screen.getByText(/Romaine Lettuce/)).toBeInTheDocument(); + expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); + expect(screen.getByRole('link', { name: /import records/i })).toHaveAttribute('href', '/tools/data-import'); }); it('shows an error without fabricated results when the API fails', async () => { @@ -196,8 +194,7 @@ describe('RecallReportPage', () => { await waitFor(() => { expect(screen.getByText('Recall report unavailable')).toBeInTheDocument(); }); - expect(screen.queryByText(/Romaine Lettuce/)).not.toBeInTheDocument(); - expect(screen.queryByText(/SLA Met/)).not.toBeInTheDocument(); + expect(screen.queryByText('No data yet')).not.toBeInTheDocument(); }); it('does not fetch when credentials are missing', () => { @@ -208,11 +205,6 @@ describe('RecallReportPage', () => { }); it('exports the FDA response through the cookie-backed ingestion proxy without browser auth headers', async () => { - // Override the default demo mock — the export button is disabled and - // relabelled "Connect Live Data To Enable FDA Export" while demo_mode is - // true, so we need a non-demo report to exercise the export path. - mockFetchRecallReport.mockResolvedValue({ ...MOCK_DEMO_REPORT, demo_mode: false }); - const fetchSpy = vi.fn().mockResolvedValue({ ok: true, blob: async () => new Blob(['csv']), diff --git a/frontend/src/app/buyer-programs/page.tsx b/frontend/src/app/buyer-programs/page.tsx index 145fe7dba..f1e3f2871 100644 --- a/frontend/src/app/buyer-programs/page.tsx +++ b/frontend/src/app/buyer-programs/page.tsx @@ -137,7 +137,7 @@ export default function BuyerProgramsPage() {
    • • Advisory coverage/completeness checklist
    • • Append-only commit
    • • FDA-oriented sortable spreadsheet
    • -
    • • CSV upload, JSON events API (EPCIS export)
    • +
    • • CSV upload, JSON events API, EPCIS export (Preview)
    • • Hash manifest re-checkable at /verify
    diff --git a/frontend/src/app/compliance/page.tsx b/frontend/src/app/compliance/page.tsx index 7dc5b5266..38177035e 100644 --- a/frontend/src/app/compliance/page.tsx +++ b/frontend/src/app/compliance/page.tsx @@ -257,7 +257,7 @@ export default function CompliancePage() {
    FSMA 204 - EPCIS 2.0 export + EPCIS 2.0 export (Preview) GS1 FDA CTEs KDEs diff --git a/frontend/src/app/dashboard/compliance/page.tsx b/frontend/src/app/dashboard/compliance/page.tsx index be965d0f8..0ee189b69 100644 --- a/frontend/src/app/dashboard/compliance/page.tsx +++ b/frontend/src/app/dashboard/compliance/page.tsx @@ -33,7 +33,7 @@ import { useAuth } from '@/lib/auth-context'; import { useTenant } from '@/lib/tenant-context'; import { fetchComplianceScore, fetchWorkbenchReadinessSummary } from '@/lib/api-hooks'; import { ErrorState } from '@/components/ui/error-state'; -import { experimentalFeaturesEnabled, filterExperimentalLinks } from '@/lib/experimental-features'; +import { filterExperimentalLinks } from '@/lib/experimental-features'; import { NO_DATA_YET } from '@/lib/empty-values'; /* ── Types matching ComplianceScoreResponse from backend ── */ @@ -127,9 +127,9 @@ const STATUS_CONFIG = { const COMPLIANCE_SETUP_STEPS = [ { title: 'Load traceability records', - detail: 'Import a CSV, scan labels, or run Inflow Lab so the score has CTE and KDE evidence to evaluate.', - href: '/dashboard/inflow-lab', - action: 'Open Inflow Lab', + detail: 'Import a CSV or scan labels so the score has CTE and KDE evidence to evaluate.', + href: '/ingest', + action: 'Import records', icon: Upload, }, { @@ -141,7 +141,7 @@ const COMPLIANCE_SETUP_STEPS = [ }, { title: 'Preview export readiness', - detail: 'Once records exist, configure FDA and EPCIS export jobs for audit-response workflows.', + detail: 'Once records exist, configure FDA and EPCIS (Preview) export jobs for audit-response workflows.', href: '/dashboard/export-jobs', action: 'Set up exports', icon: Download, @@ -439,13 +439,6 @@ export default function ComplianceDashboardPage() { If this workspace is new, there may not be enough records yet. You can keep this page open, or jump directly to the setup paths below.

    - {experimentalFeaturesEnabled() && ( - - - - )}
    - {experimentalFeaturesEnabled() && ( - - - - )} @@ -596,7 +582,7 @@ export default function ComplianceDashboardPage() { @@ -616,7 +602,7 @@ export default function ComplianceDashboardPage() { diff --git a/frontend/src/app/dashboard/export-jobs/page.tsx b/frontend/src/app/dashboard/export-jobs/page.tsx index d4a54ad8a..8ede51ad7 100644 --- a/frontend/src/app/dashboard/export-jobs/page.tsx +++ b/frontend/src/app/dashboard/export-jobs/page.tsx @@ -69,9 +69,9 @@ export default function ExportJobsPage() { const exportPrereqs = filterExperimentalLinks([ { title: 'Connect source data', - detail: 'Use Inflow Lab or import tools to build the record set export jobs will package once scheduling ships.', - href: '/dashboard/inflow-lab', - action: 'Open Inflow Lab', + detail: 'Use the import tools to build the record set export jobs will package once scheduling ships.', + href: '/ingest', + action: 'Import records', icon: Database, }, { @@ -182,7 +182,7 @@ export default function ExportJobsPage() { className={`${fieldControlClasses} h-11`} > - + @@ -250,7 +250,7 @@ export default function ExportJobsPage() {
    {exportPrereqs.map((step) => ( @@ -279,7 +279,7 @@ export default function ExportJobsPage() {
    {[ ['Cadence', 'Weekly / Daily / Monthly'], - ['Bundle', 'FDA or EPCIS'], + ['Bundle', 'FDA or EPCIS (Preview)'], ['Integrity', 'Manifest hash'], ].map(([term, detail]) => (
    diff --git a/frontend/src/app/dashboard/inflow-lab/page.tsx b/frontend/src/app/dashboard/inflow-lab/page.tsx index 7d381be63..1e29a5146 100644 --- a/frontend/src/app/dashboard/inflow-lab/page.tsx +++ b/frontend/src/app/dashboard/inflow-lab/page.tsx @@ -1,23 +1,5 @@ -import { Metadata } from "next"; -import { DashboardInflowLab } from "@/app/tools/inflow-lab/components/DashboardInflowLab"; -import { FeatureNotEnabled } from "@/components/experimental/FeatureNotEnabled"; -import { isExperimentalPageEnabled } from "@/lib/experimental-features"; - -export const metadata: Metadata = { - title: "Inflow Lab | RegEngine Dashboard", - description: - "Test supplier traceability data before it flows into production exports: sandbox-only diagnostics, grouped exceptions, and a sticky lot lifecycle.", -}; +import { notFound } from "next/navigation"; export default function DashboardInflowLabPage() { - if (!isExperimentalPageEnabled("/dashboard/inflow-lab")) { - return ( - - ); - } - return ; + notFound(); } diff --git a/frontend/src/app/dashboard/integrations/page.tsx b/frontend/src/app/dashboard/integrations/page.tsx index 337081ddd..3a3449785 100644 --- a/frontend/src/app/dashboard/integrations/page.tsx +++ b/frontend/src/app/dashboard/integrations/page.tsx @@ -7,16 +7,12 @@ import { ArrowRight, Archive, Braces, - CheckCircle2, ClipboardCheck, FileSpreadsheet, - FlaskConical, - GitBranch, Play, RefreshCcw, Save, ShieldCheck, - Terminal, } from 'lucide-react'; import { CAPABILITY_REGISTRY, @@ -44,7 +40,7 @@ import { cn } from '@/lib/utils'; import { apiClient } from '@/lib/api-client'; import { useAuth } from '@/lib/auth-context'; import { useTenant } from '@/lib/tenant-context'; -import { experimentalFeaturesEnabled, filterExperimentalLinks } from '@/lib/experimental-features'; +import { filterExperimentalLinks } from '@/lib/experimental-features'; import type { CreateIntegrationProfileRequest, IntegrationProfile, @@ -72,19 +68,6 @@ const STATUS_TONES: Record = { contract_only: 'neutral', }; -const INFLOW_CONNECTOR = CAPABILITY_REGISTRY.find((item) => item.id === 'inflow-lab'); - -const INFLOW_FACTS = [ - ['Public slug', 'inflow-lab'], -]; - -const INFLOW_PATH = [ - ['Simulator', 'FSMA 204 event batch'], - ['JSON events API', '/api/v1/webhooks/ingest'], - ['Alias resolution', 'inflow-lab -> inflow_lab'], - ['Dashboard source', 'Tagged as Inflow Lab'], -]; - const ROUTE_GUIDANCE = [ { title: 'Import Data', @@ -98,12 +81,6 @@ const ROUTE_GUIDANCE = [ href: '/ingest/curation', icon: ClipboardCheck, }, - { - title: 'Inflow Lab', - description: 'Run FSMA event simulations and event replay without mixing test traffic into normal imports.', - href: '/dashboard/inflow-lab', - icon: FlaskConical, - }, { title: 'FDA Export', description: 'Generate export jobs after records are curated and ready for regulator response.', @@ -144,20 +121,6 @@ const DEFAULT_PROFILE_MAPPING = { reference_document: 'bol_number', }; -const DEFAULT_PREVIEW_EVENT = JSON.stringify([ - { - event_type: 'shipping', - lot_code: 'TLC-ROMAINE-0426', - item_description: 'Romaine hearts', - case_count: 24, - uom: 'cases', - ship_date: '2026-04-30T15:00:00Z', - origin_facility: 'FreshPack Central', - destination_facility: 'DC-17', - bol_number: 'BOL-88921', - }, -], null, 2); - export default function DashboardIntegrationsPage() { const { isAuthenticated } = useAuth(); const { tenantId } = useTenant(); @@ -167,15 +130,15 @@ export default function DashboardIntegrationsPage() { const [profiles, setProfiles] = useState([]); const [profilesStatus, setProfilesStatus] = useState<'idle' | 'loading' | 'error'>('loading'); const [profileError, setProfileError] = useState(null); - const [profileName, setProfileName] = useState('FreshPack shipping CSV'); - const [supplierName, setSupplierName] = useState('FreshPack Central'); + const [profileName, setProfileName] = useState(''); + const [supplierName, setSupplierName] = useState(''); const [sourceType, setSourceType] = useState('csv'); const [defaultCteType, setDefaultCteType] = useState('shipping'); - const [profileNotes, setProfileNotes] = useState('Supplier uses BOL number for FSMA reference document.'); + const [profileNotes, setProfileNotes] = useState(''); const [fieldMapping, setFieldMapping] = useState>({ ...DEFAULT_PROFILE_MAPPING }); const [savingProfile, setSavingProfile] = useState(false); const [previewProfileId, setPreviewProfileId] = useState(''); - const [previewEventText, setPreviewEventText] = useState(DEFAULT_PREVIEW_EVENT); + const [previewEventText, setPreviewEventText] = useState(''); const [previewResult, setPreviewResult] = useState(null); const [previewError, setPreviewError] = useState(null); const [previewing, setPreviewing] = useState(false); @@ -345,7 +308,7 @@ export default function DashboardIntegrationsPage() { window.location.reload()}>