"""Bot-owned tables (bot_* prefix, idempotent DDL in db_init.py). These are separate from the ERP schema on purpose: the bot is an autonomous container and owns its own state (links, drafts, message audit log). """ from datetime import datetime from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass def _uuid_pk() -> Mapped[str]: return mapped_column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid()) def _timestamptz(**kwargs) -> Mapped[datetime]: return mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, **kwargs) class BotLink(Base): """Bale chat ↔ ERP user mapping (established via Casdoor login).""" __tablename__ = "bot_links" id: Mapped[str] = _uuid_pk() chat_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True) user_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False)) # users.id casdoor_username: Mapped[str | None] = mapped_column(Text) display_name: Mapped[str | None] = mapped_column(Text) role: Mapped[str | None] = mapped_column(String(32)) created_at: Mapped[datetime] = _timestamptz() updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now()) class BotDraft(Base): """A prepared-but-not-committed ERP action (quote / supplier / material).""" __tablename__ = "bot_drafts" __table_args__ = ( Index("ix_bot_drafts_chat_status", "chat_id", "status"), Index("ix_bot_drafts_review_msg", "review_message_id"), Index("ix_bot_drafts_source_msg", "source_message_id"), ) id: Mapped[str] = _uuid_pk() chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) kind: Mapped[str] = mapped_column(String(16), nullable=False) # quote|supplier|material payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) missing: Mapped[dict | None] = mapped_column(JSONB) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active|committed|cancelled|expired|failed source: Mapped[str | None] = mapped_column(String(16)) # text|voice source_message_id: Mapped[int | None] = mapped_column(BigInteger) review_message_id: Mapped[int | None] = mapped_column(BigInteger) last_bale_update_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = _timestamptz() updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now()) class BotMessage(Base): """Audit log of every inbound/outbound message (who asked for what).""" __tablename__ = "bot_messages" id: Mapped[str] = _uuid_pk() chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) direction: Mapped[str] = mapped_column(String(4), nullable=False) # in|out kind: Mapped[str] = mapped_column(String(16), nullable=False, default="text") # text|voice|callback|error body: Mapped[str | None] = mapped_column(Text) payload: Mapped[dict | None] = mapped_column(JSONB) created_at: Mapped[datetime] = _timestamptz() class BotMeta(Base): """Tiny KV store (e.g. last processed Bale update_id across restarts).""" __tablename__ = "bot_meta" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str | None] = mapped_column(Text) updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now()) class BotTrace(Base): """Observability: one row per incoming interaction (message) — the chain root. Events of the whole pipeline hang off it (bot_trace_events). Linked to the draft it produced so later button clicks can be attributed to the same chain. """ __tablename__ = "bot_traces" id: Mapped[str] = _uuid_pk() chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False) message_id: Mapped[int | None] = mapped_column(BigInteger) kind: Mapped[str] = mapped_column(String(8), nullable=False) # text | voice source: Mapped[str | None] = mapped_column(String(16)) # bale | wizard | api draft_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False)) draft_kind: Mapped[str | None] = mapped_column(String(16)) committed_quote_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False)) status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") # open|committed|cancelled created_at: Mapped[datetime] = _timestamptz() updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now()) class BotTraceEvent(Base): """Observability: append-only pipeline event log (payload schemas by `stage`). Written ONLY through app.observability (fire-and-forget, _jsonable-safe). Nothing is ever deleted from here by the bot. """ __tablename__ = "bot_trace_events" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) trace_id: Mapped[str] = mapped_column( UUID(as_uuid=False), ForeignKey("bot_traces.id", ondelete="CASCADE"), nullable=False ) stage: Mapped[str] = mapped_column(String(32), nullable=False) ok: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) latency_ms: Mapped[int | None] = mapped_column(Integer) model: Mapped[str | None] = mapped_column(String(64)) field: Mapped[str | None] = mapped_column(String(32)) payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) created_at: Mapped[datetime] = _timestamptz()