feat: product enrichment foundation (GTIN, KB evidence, providers, quota gateway, resolver, migration 0029)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -285,6 +285,11 @@ class Installation(Base):
|
||||
# RTC auto-apply is authorized centrally per installation and is OFF until an operator explicitly turns it on (the Agent has its own second switch).
|
||||
rtc_auto_apply_authorized: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # legacy (0027); rtc_write_mode is the source of truth
|
||||
rtc_write_mode: Mapped[str] = mapped_column(String(24), default="DISABLED", server_default="DISABLED") # DISABLED | DRY_RUN | APPROVAL_REQUIRED | ENABLED
|
||||
# Per-installation FUNCTIONS (central, no local switch). Existing installations keep working exactly as before (corrector on, features configured);
|
||||
# a NEW installation starts WAITING_FEATURE_CONFIGURATION (features_configured=False): it heartbeats but processes nothing until an admin chooses.
|
||||
corrector_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="true")
|
||||
enrichment_mode: Mapped[str] = mapped_column(String(16), default="DISABLED", server_default="DISABLED") # DISABLED | DRY_RUN | ENABLED
|
||||
features_configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="true")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -447,6 +452,10 @@ class AgentEnrollment(Base):
|
||||
hostname_seen: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
hostname_mismatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
agent_id: Mapped[int | None] = mapped_column(ForeignKey("agents.id"), nullable=True, index=True)
|
||||
# Functions chosen when the installation was created (copied to the Installation at enrollment). Nothing chosen => WAITING_FEATURE_CONFIGURATION.
|
||||
features_selected: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
feature_corrector: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
feature_enrichment_mode: Mapped[str] = mapped_column(String(16), default="DISABLED", server_default="DISABLED")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@@ -620,6 +629,7 @@ class KbKnowledge(Base):
|
||||
dataset_versions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
validated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="EVIDENCE_ONLY", server_default="EVIDENCE_ONLY", index=True) # UNVERIFIED | EVIDENCE_ONLY | VERIFIED_KB | CONFLICT | STALE
|
||||
|
||||
|
||||
class KbObservation(Base):
|
||||
@@ -786,3 +796,114 @@ class RtcCandidate(Base):
|
||||
basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="PENDING", server_default="PENDING", index=True) # PENDING | APPROVED | REJECTED | APPLIED | STALE
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ProductEnrichment(Base):
|
||||
"""Per (installation, product) state of the new-product enrichment. Historical products never get a row (no mass provider use)."""
|
||||
__tablename__ = "product_enrichments"
|
||||
__table_args__ = (UniqueConstraint("installation_id", "product_code", name="uq_product_enrichment"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
installation_id: Mapped[int] = mapped_column(ForeignKey("installations.id"), index=True)
|
||||
product_code: Mapped[str] = mapped_column(String(64))
|
||||
gtin: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="PENDING", server_default="PENDING", index=True)
|
||||
# PENDING | RESOLVED | APPLIED | PARTIAL | NOT_RESOLVED | CONFLICT | PENDING_PROVIDER_QUOTA | PENDING_PROVIDERS_OFFLINE | INVALID_GTIN
|
||||
decisions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
fields_applied: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
human_overridden: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ProductFieldAudit(Base):
|
||||
"""Append-only audit of every field the enrichment wrote (or refused to). Never holds secrets."""
|
||||
__tablename__ = "product_field_audits"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
installation_id: Mapped[int] = mapped_column(ForeignKey("installations.id"), index=True)
|
||||
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id"), index=True)
|
||||
product_code: Mapped[str] = mapped_column(String(64), index=True)
|
||||
gtin: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
description_snapshot: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
field: Mapped[str] = mapped_column(String(48))
|
||||
old_value: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
new_value: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
rule: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
kb_status: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
evidence: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
dataset_versions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
authorization_origin: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
job_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
outcome: Mapped[str] = mapped_column(String(24), default="APPLIED", server_default="APPLIED") # APPLIED | NO_WRITE_REQUIRED | ABORTED | HUMAN_OVERRIDDEN
|
||||
reason: Mapped[str | None] = mapped_column(String(96), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ProviderState(Base):
|
||||
"""Live state of each external product provider (owned by the Hub: quota belongs to the Hub, never to Agents)."""
|
||||
__tablename__ = "provider_states"
|
||||
provider: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
status: Mapped[str] = mapped_column(String(16), default="NOT_CONFIGURED", server_default="NOT_CONFIGURED") # ONLINE | DEGRADED | RATE_LIMITED | AUTH_ERROR | OFFLINE | NOT_CONFIGURED
|
||||
quota_kind: Mapped[str] = mapped_column(String(16), default="DOCUMENTED_RATE", server_default="DOCUMENTED_RATE") # REMOTE_QUOTA | LOCAL_ESTIMATE | DOCUMENTED_RATE
|
||||
daily_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
rate_per_minute: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
circuit_open_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
last_request_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ProviderUsageDaily(Base):
|
||||
__tablename__ = "provider_usage_daily"
|
||||
__table_args__ = (UniqueConstraint("provider", "day", name="uq_provider_usage_day"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
provider: Mapped[str] = mapped_column(String(32), index=True)
|
||||
day: Mapped[str] = mapped_column(String(10), index=True)
|
||||
requests: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
success: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
not_found: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
errors: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
rate_limited: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
timeouts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
cache_hits: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
cache_misses: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
|
||||
class GtinLookupCache(Base):
|
||||
"""Positive and negative (NOT_FOUND) provider results per GTIN, with TTL. Single place that spares quota across all installations."""
|
||||
__tablename__ = "gtin_lookup_cache"
|
||||
__table_args__ = (UniqueConstraint("gtin", "provider", name="uq_gtin_lookup_cache"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
gtin: Mapped[str] = mapped_column(String(16), index=True)
|
||||
provider: Mapped[str] = mapped_column(String(32))
|
||||
found: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
|
||||
|
||||
class SourceRefreshRun(Base):
|
||||
"""Observability of official fiscal sources (NCM Siscomex, ...): one row per refresh attempt."""
|
||||
__tablename__ = "source_refresh_runs"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
source: Mapped[str] = mapped_column(String(32), index=True)
|
||||
source_url: Mapped[str] = mapped_column(Text)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="RUNNING", server_default="RUNNING") # RUNNING | UPDATED | UNCHANGED | FAILED
|
||||
sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
version_label: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
record_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
|
||||
Reference in New Issue
Block a user