"""B2B ordering portal data model. This module backs the private customer ordering portal. It deliberately reuses the existing tenant/customer primitives rather than introducing parallel ones: * A **customer/company** is an existing :class:`ClientAccount`. * A **customer user** is an existing :class:`ClientUser` (owner/buyer/viewer/ accounts roles map onto the existing ``ordering`` module access levels). Everything here is private: every row is tenant-scoped, and customer-facing rows are additionally scoped to a ``client_account_id``. The catalogue itself is global to the seller (one tenant), with per-customer visibility and pricing layered on top. Money is stored GST-exclusive (see ``CLAUDE.MD`` costing conventions) as floats, matching the rest of the costing platform. """ from __future__ import annotations from datetime import datetime from sqlalchemy import ( Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.session import Base # --- Controlled vocabularies ------------------------------------------------- # Plain string constants (not DB enums) keep SQLite migrations trivial and match # the existing model style. PRODUCT_CATEGORIES = ( "grains", "premixed", "bags", "bulk_loads", "custom_blends", "services", ) # Full internal order lifecycle. Customers see a simplified subset (see # ``CUSTOMER_VISIBLE_STATUS`` in the ordering service). ORDER_STATUSES = ( "draft", "submitted", "under_review", "confirmed", "sent_to_xero", "in_production", "ready_for_pickup", "dispatched", "completed", "cancelled", ) # How a resolved unit price was derived. Stored on each order line so future # reporting can always explain which rule applied. PRICE_SOURCES = ( "fixed", # CustomerProductPrice with rule_type fixed "contract", # CustomerProductPrice with rule_type contract "price_list", # PriceListItem via an assigned price list "tiered", # a quantity tier won over the base rate "base", # catalogue list price (optionally with customer discount) "quote", # manual quote required; no automatic price ) FULFILMENT_METHODS = ("delivery", "pickup") class ProductCategory(Base): __tablename__ = "product_categories" __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_product_category_slug"),) id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) slug: Mapped[str] = mapped_column(String(64), index=True) name: Mapped[str] = mapped_column(String(255)) description: Mapped[str | None] = mapped_column(Text, nullable=True) sort_order: Mapped[int] = mapped_column(Integer, default=0) active: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) class CatalogueProduct(Base): """A private catalogue product. Named ``CatalogueProduct`` to avoid clashing with the existing costing ``Product`` (a separate concern).""" __tablename__ = "catalogue_products" __table_args__ = (UniqueConstraint("tenant_id", "sku", name="uq_catalogue_product_sku"),) id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) name: Mapped[str] = mapped_column(String(255), index=True) sku: Mapped[str] = mapped_column(String(64), index=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) category: Mapped[str] = mapped_column(String(64), default="grains", index=True) image_url: Mapped[str | None] = mapped_column(String(512), nullable=True) unit_size: Mapped[str | None] = mapped_column(String(64), nullable=True) unit_of_measure: Mapped[str] = mapped_column(String(64), default="each") min_order_quantity: Mapped[float] = mapped_column(Float, default=1.0) # Base/list price, GST-exclusive. Used as the fallback price source and as # the basis for a customer discount percentage. base_price: Mapped[float | None] = mapped_column(Float, nullable=True) stock_status: Mapped[str] = mapped_column(String(32), default="in_stock") active: Mapped[bool] = mapped_column(Boolean, default=True) requires_quote: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class CustomerProductVisibility(Base): """Per-customer override of catalogue visibility. Products are visible to every customer by default (the catalogue is "standard globally"). A row here with ``visible=False`` hides a product from one customer; ``visible=True`` is an explicit allow (no-op unless a future default flips to opt-in). """ __tablename__ = "customer_product_visibility" __table_args__ = ( UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_visibility"), ) id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True) product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True) visible: Mapped[bool] = mapped_column(Boolean, default=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) class PriceList(Base): __tablename__ = "price_lists" __table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_price_list_code"),) id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) code: Mapped[str] = mapped_column(String(64), index=True) name: Mapped[str] = mapped_column(String(255)) description: Mapped[str | None] = mapped_column(Text, nullable=True) active: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) items: Mapped[list["PriceListItem"]] = relationship( back_populates="price_list", cascade="all, delete-orphan", ) class PriceListItem(Base): __tablename__ = "price_list_items" __table_args__ = (UniqueConstraint("price_list_id", "product_id", name="uq_price_list_item"),) id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) price_list_id: Mapped[int] = mapped_column(ForeignKey("price_lists.id"), index=True) product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True) unit_price: Mapped[float] = mapped_column(Float) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) price_list: Mapped[PriceList] = relationship(back_populates="items") class CustomerPriceAssignment(Base): """Links a customer to an assigned price list and a default discount.""" __tablename__ = "customer_price_assignments" __table_args__ = ( UniqueConstraint("client_account_id", name="uq_customer_price_assignment"), ) id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True) price_list_id: Mapped[int | None] = mapped_column(ForeignKey("price_lists.id"), nullable=True, index=True) # Default percentage discount applied to base prices for this customer when # no more specific rule (fixed/contract/price-list) applies. 0..100. discount_percent: Mapped[float] = mapped_column(Float, default=0.0) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) price_list: Mapped[PriceList | None] = relationship() class CustomerProductPrice(Base): """A fixed or contract price for a specific customer + product. Highest priority pricing rule.""" __tablename__ = "customer_product_prices" __table_args__ = ( UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_price"), ) id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True) product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True) unit_price: Mapped[float | None] = mapped_column(Float, nullable=True) # "fixed" | "contract" | "quote" (quote forces a manual-quote workflow for # this customer+product even if the product itself doesn't require one). rule_type: Mapped[str] = mapped_column(String(32), default="fixed") contract_reference: Mapped[str | None] = mapped_column(String(128), nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) active: Mapped[bool] = mapped_column(Boolean, default=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) class PriceTier(Base): """Quantity break for a pricing source. Exactly one of ``customer_product_price_id`` / ``price_list_item_id`` is set.""" __tablename__ = "price_tiers" id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) customer_product_price_id: Mapped[int | None] = mapped_column( ForeignKey("customer_product_prices.id"), nullable=True, index=True ) price_list_item_id: Mapped[int | None] = mapped_column( ForeignKey("price_list_items.id"), nullable=True, index=True ) # Tier applies when ordered quantity >= min_quantity. The highest qualifying # min_quantity wins. min_quantity: Mapped[float] = mapped_column(Float) unit_price: Mapped[float] = mapped_column(Float) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) class Order(Base): __tablename__ = "orders" id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True) # Human-friendly order reference, assigned on submit (e.g. ORD-000123). order_number: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # Who created / submitted it (ClientUser ids; nullable for admin-created). created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True) created_by_name: Mapped[str | None] = mapped_column(String(255), nullable=True) submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Customer-supplied order details. purchase_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True) delivery_notes: Mapped[str | None] = mapped_column(Text, nullable=True) requested_delivery_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) fulfilment_method: Mapped[str] = mapped_column(String(32), default="delivery") # Admin/internal handling. admin_notes: Mapped[str | None] = mapped_column(Text, nullable=True) reopened: Mapped[bool] = mapped_column(Boolean, default=False) # Cached totals (authoritative figures live on lines; this is for listing). subtotal_ex_gst: Mapped[float] = mapped_column(Float, default=0.0) requires_quote: Mapped[bool] = mapped_column(Boolean, default=False) # Xero integration tracking. xero_status: Mapped[str] = mapped_column(String(32), default="not_sent") xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) lines: Mapped[list["OrderLine"]] = relationship( back_populates="order", cascade="all, delete-orphan", order_by="OrderLine.sort_order", ) status_history: Mapped[list["OrderStatusHistory"]] = relationship( back_populates="order", cascade="all, delete-orphan", order_by="OrderStatusHistory.created_at", ) attachments: Mapped[list["OrderAttachment"]] = relationship( back_populates="order", cascade="all, delete-orphan", ) class OrderLine(Base): __tablename__ = "order_lines" id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True) product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True) # Snapshot of product identity at order time (so later catalogue edits don't # rewrite historical orders). product_name: Mapped[str] = mapped_column(String(255)) product_sku: Mapped[str] = mapped_column(String(64)) quantity: Mapped[float] = mapped_column(Float, default=1.0) # Authoritative unit price, GST-exclusive, computed by the backend pricing # engine and frozen at submission. Null only while quote-only. unit_price: Mapped[float | None] = mapped_column(Float, nullable=True) line_total: Mapped[float | None] = mapped_column(Float, nullable=True) requires_quote: Mapped[bool] = mapped_column(Boolean, default=False) # Pricing provenance — enough to explain which rule applied in reporting. price_source: Mapped[str] = mapped_column(String(32), default="base") price_rule_id: Mapped[int | None] = mapped_column(Integer, nullable=True) discount_percent: Mapped[float] = mapped_column(Float, default=0.0) # Admin override of the resolved unit price before confirmation. admin_override_price: Mapped[float | None] = mapped_column(Float, nullable=True) admin_override_reason: Mapped[str | None] = mapped_column(Text, nullable=True) sort_order: Mapped[int] = mapped_column(Integer, default=0) notes: Mapped[str | None] = mapped_column(Text, nullable=True) order: Mapped[Order] = relationship(back_populates="lines") class OrderStatusHistory(Base): __tablename__ = "order_status_history" id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True) from_status: Mapped[str | None] = mapped_column(String(32), nullable=True) to_status: Mapped[str] = mapped_column(String(32)) actor_type: Mapped[str] = mapped_column(String(32), default="system") actor_name: Mapped[str | None] = mapped_column(String(255), nullable=True) note: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) order: Mapped[Order] = relationship(back_populates="status_history") class OrderAttachment(Base): __tablename__ = "order_attachments" id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True) # "purchase_order" | "confirmation_pdf" | "other" kind: Mapped[str] = mapped_column(String(32), default="other") filename: Mapped[str] = mapped_column(String(255)) content_type: Mapped[str | None] = mapped_column(String(128), nullable=True) url: Mapped[str | None] = mapped_column(String(512), nullable=True) note: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) order: Mapped[Order] = relationship(back_populates="attachments") class NotificationSetting(Base): """Per-tenant notification configuration for the ordering portal.""" __tablename__ = "notification_settings" __table_args__ = (UniqueConstraint("tenant_id", name="uq_notification_settings_tenant"),) id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) # Comma-separated internal recipients notified on new order submissions. internal_recipients: Mapped[str | None] = mapped_column(Text, nullable=True) send_customer_confirmation: Mapped[bool] = mapped_column(Boolean, default=True) require_po_number: Mapped[bool] = mapped_column(Boolean, default=False) from_email: Mapped[str | None] = mapped_column(String(255), nullable=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) class XeroSyncLog(Base): __tablename__ = "xero_sync_log" id: Mapped[int] = mapped_column(primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True) # "queued" | "success" | "failed" status: Mapped[str] = mapped_column(String(32), default="queued") request_summary: Mapped[str | None] = mapped_column(Text, nullable=True) xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True) response_message: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)