Files
data-entry-app/backend/app/models/ordering.py
T

391 lines
18 KiB
Python
Raw Normal View History

2026-06-11 23:56:02 +12:00
"""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)