v0.1.14 - b2b portal
This commit is contained in:
@@ -21,6 +21,7 @@ MODULE_CATALOG = (
|
||||
("products", "Products", "pricing", "Review finished product pricing"),
|
||||
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
|
||||
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
|
||||
("ordering", "Ordering Portal", "commerce", "Browse the private catalogue, build and submit B2B orders"),
|
||||
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
|
||||
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
|
||||
)
|
||||
@@ -79,15 +80,25 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
|
||||
def default_access_level_for_role(role: str, module_key: str) -> str:
|
||||
normalized = role.strip().lower()
|
||||
if normalized == "superadmin":
|
||||
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput"} else "edit"
|
||||
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput", "ordering"} else "edit"
|
||||
if normalized == "admin":
|
||||
if module_key in {"mix_calculator", "operations_throughput"}:
|
||||
if module_key in {"mix_calculator", "operations_throughput", "ordering"}:
|
||||
return "manage"
|
||||
return "edit" if module_key != "client_access" else "none"
|
||||
if normalized == "operator":
|
||||
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput"} else "none"
|
||||
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput", "ordering"} else "none"
|
||||
if normalized == "viewer":
|
||||
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput"} else "none"
|
||||
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput", "ordering"} else "none"
|
||||
# --- B2B ordering-portal customer roles ---------------------------------
|
||||
# Ordering customers are scoped to the ordering module (plus a dashboard
|
||||
# view). They never see costing/admin modules.
|
||||
if normalized == "owner":
|
||||
return "manage" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
|
||||
if normalized == "buyer":
|
||||
return "edit" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
|
||||
if normalized == "accounts":
|
||||
# Accounts users review orders/invoicing but don't place orders.
|
||||
return "view" if module_key in {"ordering", "dashboard"} else "none"
|
||||
return "none"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Order notification service (stub interface).
|
||||
|
||||
Emits the two notifications the ordering spec requires when an order is
|
||||
submitted:
|
||||
|
||||
* a confirmation to the customer, and
|
||||
* an internal notification to configured recipients.
|
||||
|
||||
Real email delivery is intentionally **stubbed** behind this interface: no SMTP
|
||||
provider is configured in dev/alpha, so notifications are logged and returned as
|
||||
structured results. To go live, implement :func:`_deliver_email` (TODO) — no
|
||||
caller changes needed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.client_access import ClientAccount, ClientUser
|
||||
from app.models.ordering import NotificationSetting, Order
|
||||
|
||||
logger = logging.getLogger("data_entry_app.ordering")
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationResult:
|
||||
channel: str # "customer" | "internal"
|
||||
recipients: list[str]
|
||||
subject: str
|
||||
delivered: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session, tenant_id: str) -> NotificationSetting:
|
||||
settings = db.scalar(
|
||||
select(NotificationSetting).where(NotificationSetting.tenant_id == tenant_id)
|
||||
)
|
||||
if settings is None:
|
||||
settings = NotificationSetting(tenant_id=tenant_id)
|
||||
db.add(settings)
|
||||
db.flush()
|
||||
return settings
|
||||
|
||||
|
||||
def _internal_recipients(settings: NotificationSetting) -> list[str]:
|
||||
if not settings.internal_recipients:
|
||||
return []
|
||||
return [part.strip() for part in settings.internal_recipients.split(",") if part.strip()]
|
||||
|
||||
|
||||
def _customer_recipients(db: Session, order: Order) -> list[str]:
|
||||
users = db.scalars(
|
||||
select(ClientUser).where(
|
||||
ClientUser.client_account_id == order.client_account_id,
|
||||
ClientUser.status == "active",
|
||||
)
|
||||
).all()
|
||||
return [user.email for user in users if user.email]
|
||||
|
||||
|
||||
def _deliver_email(*, to: list[str], subject: str, body: str, from_email: str | None) -> bool:
|
||||
"""Deliver an email. Stubbed — logs instead of sending.
|
||||
|
||||
TODO (go-live): integrate the chosen provider (SMTP / SendGrid / SES) using
|
||||
credentials from environment variables. Return True only on confirmed
|
||||
delivery.
|
||||
"""
|
||||
logger.info("ordering.email.stub to=%s subject=%s", to, subject)
|
||||
return False # stub: nothing actually sent
|
||||
|
||||
|
||||
def send_order_submitted_notifications(db: Session, order: Order) -> list[NotificationResult]:
|
||||
"""Send customer confirmation + internal notification for a submitted order."""
|
||||
settings = get_or_create_settings(db, order.tenant_id)
|
||||
customer = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
|
||||
customer_name = customer.name if customer else "Customer"
|
||||
order_ref = order.order_number or f"#{order.id}"
|
||||
results: list[NotificationResult] = []
|
||||
|
||||
if settings.send_customer_confirmation:
|
||||
to = _customer_recipients(db, order)
|
||||
subject = f"Order {order_ref} received"
|
||||
body = (
|
||||
f"Thank you — we have received order {order_ref} for {customer_name}.\n"
|
||||
f"Lines: {len(order.lines)}. Subtotal (ex GST): {order.subtotal_ex_gst:.2f}.\n"
|
||||
"Our team will review and confirm shortly."
|
||||
)
|
||||
delivered = _deliver_email(to=to, subject=subject, body=body, from_email=settings.from_email) if to else False
|
||||
results.append(
|
||||
NotificationResult(
|
||||
channel="customer",
|
||||
recipients=to,
|
||||
subject=subject,
|
||||
delivered=delivered,
|
||||
detail="stubbed" if not delivered else "sent",
|
||||
)
|
||||
)
|
||||
|
||||
internal = _internal_recipients(settings)
|
||||
subject = f"New order {order_ref} from {customer_name}"
|
||||
body = (
|
||||
f"New order {order_ref} submitted by {order.created_by_name or 'a customer user'}.\n"
|
||||
f"Customer: {customer_name}. Lines: {len(order.lines)}. "
|
||||
f"Subtotal (ex GST): {order.subtotal_ex_gst:.2f}."
|
||||
)
|
||||
delivered = _deliver_email(to=internal, subject=subject, body=body, from_email=settings.from_email) if internal else False
|
||||
results.append(
|
||||
NotificationResult(
|
||||
channel="internal",
|
||||
recipients=internal,
|
||||
subject=subject,
|
||||
delivered=delivered,
|
||||
detail="no recipients configured" if not internal else ("stubbed" if not delivered else "sent"),
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Order confirmation PDF generation (reportlab)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from app.models.client_access import ClientAccount
|
||||
from app.models.ordering import Order
|
||||
|
||||
|
||||
class OrderPdfUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _fmt_money(value: float | None) -> str:
|
||||
return "Quote" if value is None else f"${value:,.2f}"
|
||||
|
||||
|
||||
def build_order_confirmation_pdf(order: Order, customer: ClientAccount | None) -> bytes:
|
||||
try:
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
except ModuleNotFoundError as exc: # pragma: no cover
|
||||
raise OrderPdfUnavailableError(
|
||||
"PDF generation is unavailable because 'reportlab' is not installed."
|
||||
) from exc
|
||||
|
||||
buffer = BytesIO()
|
||||
page_width, page_height = A4
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
margin = 40
|
||||
y = page_height - margin
|
||||
|
||||
pdf.setFont("Helvetica-Bold", 18)
|
||||
pdf.drawString(margin, y, "Order Confirmation")
|
||||
y -= 24
|
||||
|
||||
pdf.setFont("Helvetica", 10)
|
||||
order_ref = order.order_number or f"#{order.id}"
|
||||
pdf.drawString(margin, y, f"Order: {order_ref}")
|
||||
pdf.drawRightString(page_width - margin, y, datetime.utcnow().strftime("%d %b %Y %H:%M UTC"))
|
||||
y -= 14
|
||||
pdf.drawString(margin, y, f"Customer: {customer.name if customer else order.client_account_id}")
|
||||
y -= 14
|
||||
pdf.drawString(margin, y, f"Status: {order.status.replace('_', ' ').title()}")
|
||||
y -= 14
|
||||
if order.purchase_order_number:
|
||||
pdf.drawString(margin, y, f"PO Number: {order.purchase_order_number}")
|
||||
y -= 14
|
||||
pdf.drawString(margin, y, f"Fulfilment: {order.fulfilment_method.title()}")
|
||||
y -= 14
|
||||
if order.requested_delivery_date:
|
||||
pdf.drawString(margin, y, f"Requested date: {order.requested_delivery_date.strftime('%d %b %Y')}")
|
||||
y -= 14
|
||||
y -= 6
|
||||
|
||||
# Table header
|
||||
pdf.setFont("Helvetica-Bold", 9)
|
||||
pdf.setFillColor(colors.HexColor("#22362d"))
|
||||
pdf.drawString(margin, y, "Product")
|
||||
pdf.drawString(margin + 220, y, "SKU")
|
||||
pdf.drawRightString(margin + 330, y, "Qty")
|
||||
pdf.drawRightString(margin + 420, y, "Unit (ex GST)")
|
||||
pdf.drawRightString(page_width - margin, y, "Line total")
|
||||
y -= 6
|
||||
pdf.setStrokeColor(colors.HexColor("#cccccc"))
|
||||
pdf.line(margin, y, page_width - margin, y)
|
||||
y -= 14
|
||||
|
||||
pdf.setFont("Helvetica", 9)
|
||||
pdf.setFillColor(colors.black)
|
||||
for line in order.lines:
|
||||
if y < margin + 60:
|
||||
pdf.showPage()
|
||||
y = page_height - margin
|
||||
pdf.setFont("Helvetica", 9)
|
||||
unit = line.admin_override_price if line.admin_override_price is not None else line.unit_price
|
||||
line_total = None if unit is None else round(unit * line.quantity, 2)
|
||||
pdf.drawString(margin, y, (line.product_name or "")[:38])
|
||||
pdf.drawString(margin + 220, y, (line.product_sku or "")[:16])
|
||||
pdf.drawRightString(margin + 330, y, f"{line.quantity:g}")
|
||||
pdf.drawRightString(margin + 420, y, _fmt_money(unit))
|
||||
pdf.drawRightString(page_width - margin, y, _fmt_money(line_total))
|
||||
y -= 14
|
||||
|
||||
y -= 4
|
||||
pdf.line(margin, y, page_width - margin, y)
|
||||
y -= 16
|
||||
pdf.setFont("Helvetica-Bold", 10)
|
||||
pdf.drawRightString(margin + 420, y, "Subtotal (ex GST)")
|
||||
pdf.drawRightString(page_width - margin, y, _fmt_money(order.subtotal_ex_gst))
|
||||
y -= 20
|
||||
|
||||
if order.requires_quote:
|
||||
pdf.setFont("Helvetica-Oblique", 9)
|
||||
pdf.setFillColor(colors.HexColor("#8a5a00"))
|
||||
pdf.drawString(margin, y, "This order contains quote-only items. Final pricing will be confirmed by our team.")
|
||||
y -= 14
|
||||
|
||||
if order.delivery_notes:
|
||||
pdf.setFont("Helvetica", 9)
|
||||
pdf.setFillColor(colors.black)
|
||||
pdf.drawString(margin, y, f"Notes: {order.delivery_notes[:90]}")
|
||||
|
||||
pdf.setFont("Helvetica-Oblique", 8)
|
||||
pdf.setFillColor(colors.HexColor("#888888"))
|
||||
pdf.drawString(margin, margin - 12, "Prices exclude GST. This is an order confirmation, not a tax invoice.")
|
||||
|
||||
pdf.showPage()
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Customer-specific pricing engine for the B2B ordering portal.
|
||||
|
||||
The backend is the single source of truth for prices — the frontend never
|
||||
computes a final price. :func:`resolve_price` returns a fully-attributed
|
||||
:class:`PriceResolution` so every order line, and any future report, can explain
|
||||
exactly which rule produced the number.
|
||||
|
||||
Resolution priority (highest wins):
|
||||
|
||||
1. **Quote-only** — the product requires a manual quote, or the customer has a
|
||||
``quote`` price rule for it. No automatic price.
|
||||
2. **Fixed / contract** — a :class:`CustomerProductPrice` for this customer +
|
||||
product (quantity tiers may refine it).
|
||||
3. **Price list** — a :class:`PriceListItem` from the customer's assigned price
|
||||
list (quantity tiers may refine it).
|
||||
4. **Base + discount** — the catalogue list price, optionally reduced by the
|
||||
customer's default discount percentage.
|
||||
5. **Quote fallback** — no resolvable price ⇒ treated as quote-only.
|
||||
|
||||
All prices are GST-exclusive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerPriceAssignment,
|
||||
CustomerProductPrice,
|
||||
PriceListItem,
|
||||
PriceTier,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PriceResolution:
|
||||
unit_price: float | None
|
||||
price_source: str # one of ordering.PRICE_SOURCES
|
||||
price_rule_id: int | None
|
||||
discount_percent: float
|
||||
requires_quote: bool
|
||||
label: str
|
||||
|
||||
def line_total(self, quantity: float) -> float | None:
|
||||
if self.unit_price is None:
|
||||
return None
|
||||
return round(self.unit_price * quantity, 4)
|
||||
|
||||
|
||||
_SOURCE_LABELS = {
|
||||
"fixed": "Fixed price",
|
||||
"contract": "Contract price",
|
||||
"price_list": "Price list",
|
||||
"tiered": "Tiered price",
|
||||
"base": "List price",
|
||||
"quote": "Quote required",
|
||||
}
|
||||
|
||||
|
||||
def _best_tier_price(
|
||||
db: Session,
|
||||
*,
|
||||
customer_product_price_id: int | None = None,
|
||||
price_list_item_id: int | None = None,
|
||||
quantity: float,
|
||||
) -> float | None:
|
||||
"""Return the unit price of the highest qualifying quantity tier, if any."""
|
||||
stmt = select(PriceTier).where(PriceTier.min_quantity <= quantity)
|
||||
if customer_product_price_id is not None:
|
||||
stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id)
|
||||
elif price_list_item_id is not None:
|
||||
stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id)
|
||||
else:
|
||||
return None
|
||||
tiers = db.scalars(stmt.order_by(PriceTier.min_quantity.desc())).all()
|
||||
return tiers[0].unit_price if tiers else None
|
||||
|
||||
|
||||
def _quote(label: str = "Quote required") -> PriceResolution:
|
||||
return PriceResolution(
|
||||
unit_price=None,
|
||||
price_source="quote",
|
||||
price_rule_id=None,
|
||||
discount_percent=0.0,
|
||||
requires_quote=True,
|
||||
label=label,
|
||||
)
|
||||
|
||||
|
||||
def get_customer_assignment(
|
||||
db: Session, *, client_account_id: int
|
||||
) -> CustomerPriceAssignment | None:
|
||||
return db.scalar(
|
||||
select(CustomerPriceAssignment).where(
|
||||
CustomerPriceAssignment.client_account_id == client_account_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resolve_price(
|
||||
db: Session,
|
||||
*,
|
||||
client_account_id: int,
|
||||
product: CatalogueProduct,
|
||||
quantity: float,
|
||||
) -> PriceResolution:
|
||||
"""Resolve the GST-exclusive unit price for a customer + product + quantity."""
|
||||
quantity = max(quantity, 0.0)
|
||||
|
||||
# 1. Product-level manual-quote flag.
|
||||
if product.requires_quote:
|
||||
return _quote()
|
||||
|
||||
# 2. Customer-specific fixed/contract/quote rule.
|
||||
cpp = db.scalar(
|
||||
select(CustomerProductPrice).where(
|
||||
CustomerProductPrice.client_account_id == client_account_id,
|
||||
CustomerProductPrice.product_id == product.id,
|
||||
CustomerProductPrice.active.is_(True),
|
||||
)
|
||||
)
|
||||
if cpp is not None:
|
||||
if cpp.rule_type == "quote":
|
||||
return _quote()
|
||||
tier_price = _best_tier_price(
|
||||
db, customer_product_price_id=cpp.id, quantity=quantity
|
||||
)
|
||||
if tier_price is not None:
|
||||
return PriceResolution(
|
||||
unit_price=round(tier_price, 4),
|
||||
price_source="tiered",
|
||||
price_rule_id=cpp.id,
|
||||
discount_percent=0.0,
|
||||
requires_quote=False,
|
||||
label=_SOURCE_LABELS["tiered"],
|
||||
)
|
||||
if cpp.unit_price is not None:
|
||||
source = "contract" if cpp.rule_type == "contract" else "fixed"
|
||||
return PriceResolution(
|
||||
unit_price=round(cpp.unit_price, 4),
|
||||
price_source=source,
|
||||
price_rule_id=cpp.id,
|
||||
discount_percent=0.0,
|
||||
requires_quote=False,
|
||||
label=_SOURCE_LABELS[source],
|
||||
)
|
||||
|
||||
assignment = get_customer_assignment(db, client_account_id=client_account_id)
|
||||
|
||||
# 3. Assigned price list.
|
||||
if assignment is not None and assignment.price_list_id is not None:
|
||||
pli = db.scalar(
|
||||
select(PriceListItem).where(
|
||||
PriceListItem.price_list_id == assignment.price_list_id,
|
||||
PriceListItem.product_id == product.id,
|
||||
)
|
||||
)
|
||||
if pli is not None:
|
||||
tier_price = _best_tier_price(
|
||||
db, price_list_item_id=pli.id, quantity=quantity
|
||||
)
|
||||
if tier_price is not None:
|
||||
return PriceResolution(
|
||||
unit_price=round(tier_price, 4),
|
||||
price_source="tiered",
|
||||
price_rule_id=pli.id,
|
||||
discount_percent=0.0,
|
||||
requires_quote=False,
|
||||
label=_SOURCE_LABELS["tiered"],
|
||||
)
|
||||
return PriceResolution(
|
||||
unit_price=round(pli.unit_price, 4),
|
||||
price_source="price_list",
|
||||
price_rule_id=pli.id,
|
||||
discount_percent=0.0,
|
||||
requires_quote=False,
|
||||
label=_SOURCE_LABELS["price_list"],
|
||||
)
|
||||
|
||||
# 4. Base price (+ optional customer discount).
|
||||
if product.base_price is not None:
|
||||
discount = assignment.discount_percent if assignment else 0.0
|
||||
discount = min(max(discount, 0.0), 100.0)
|
||||
unit_price = round(product.base_price * (1.0 - discount / 100.0), 4)
|
||||
label = _SOURCE_LABELS["base"]
|
||||
if discount:
|
||||
label = f"List price less {discount:g}%"
|
||||
return PriceResolution(
|
||||
unit_price=unit_price,
|
||||
price_source="base",
|
||||
price_rule_id=product.id,
|
||||
discount_percent=discount,
|
||||
requires_quote=False,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# 5. No resolvable price ⇒ quote.
|
||||
return _quote("No price set — quote required")
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Shared ordering-portal business logic: lifecycle, visibility, totals,
|
||||
serialization, and audit.
|
||||
|
||||
Customer isolation is enforced by callers passing ``tenant_id`` /
|
||||
``client_account_id`` into every query; this module never widens that scope.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.core.config import settings
|
||||
from app.models.client_access import ClientAccount
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerProductVisibility,
|
||||
Order,
|
||||
OrderLine,
|
||||
OrderStatusHistory,
|
||||
)
|
||||
from app.services.client_access_service import record_audit_event
|
||||
from app.services.ordering_pricing import resolve_price
|
||||
|
||||
# The ordering catalogue is "standard globally" — it lives under a single
|
||||
# seller/ordering tenant, while individual customers (ClientAccounts) may have
|
||||
# their own tenant_id. Customer isolation therefore keys on the globally-unique
|
||||
# ``client_account_id``, NOT on the customer's session tenant. Admin and
|
||||
# customer code both store/read ordering data under ORDERING_TENANT.
|
||||
ORDERING_TENANT = settings.client_tenant_id
|
||||
|
||||
# --- Lifecycle ---------------------------------------------------------------
|
||||
|
||||
# Full internal lifecycle → the simplified status a customer sees.
|
||||
CUSTOMER_VISIBLE_STATUS = {
|
||||
"draft": "draft",
|
||||
"submitted": "submitted",
|
||||
"under_review": "processing",
|
||||
"confirmed": "confirmed",
|
||||
"sent_to_xero": "confirmed",
|
||||
"in_production": "processing",
|
||||
"ready_for_pickup": "ready",
|
||||
"dispatched": "dispatched",
|
||||
"completed": "completed",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
|
||||
# Statuses an admin may move an order into from a given status. Cancellation is
|
||||
# allowed from any non-terminal status (handled separately).
|
||||
ALLOWED_ADMIN_TRANSITIONS: dict[str, set[str]] = {
|
||||
"draft": {"submitted", "cancelled"},
|
||||
"submitted": {"under_review", "confirmed", "cancelled"},
|
||||
"under_review": {"confirmed", "submitted", "cancelled"},
|
||||
"confirmed": {"sent_to_xero", "in_production", "cancelled"},
|
||||
"sent_to_xero": {"in_production", "cancelled"},
|
||||
"in_production": {"ready_for_pickup", "dispatched", "cancelled"},
|
||||
"ready_for_pickup": {"dispatched", "completed", "cancelled"},
|
||||
"dispatched": {"completed", "cancelled"},
|
||||
"completed": set(),
|
||||
"cancelled": set(),
|
||||
}
|
||||
|
||||
# Customers may only edit an order while it is a draft.
|
||||
CUSTOMER_EDITABLE_STATUSES = {"draft"}
|
||||
|
||||
|
||||
def can_admin_transition(from_status: str, to_status: str) -> bool:
|
||||
if to_status == "cancelled" and from_status not in {"completed", "cancelled"}:
|
||||
return True
|
||||
return to_status in ALLOWED_ADMIN_TRANSITIONS.get(from_status, set())
|
||||
|
||||
|
||||
def record_status_change(
|
||||
db: Session,
|
||||
order: Order,
|
||||
*,
|
||||
to_status: str,
|
||||
actor_type: str,
|
||||
actor_name: str | None,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
OrderStatusHistory(
|
||||
tenant_id=order.tenant_id,
|
||||
order_id=order.id,
|
||||
from_status=order.status,
|
||||
to_status=to_status,
|
||||
actor_type=actor_type,
|
||||
actor_name=actor_name,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
order.status = to_status
|
||||
|
||||
|
||||
def next_order_number(db: Session, tenant_id: str) -> str:
|
||||
# Cheap monotonic-ish numbering based on max id; good enough for v1.
|
||||
last_id = db.scalar(select(Order.id).order_by(Order.id.desc()).limit(1)) or 0
|
||||
return f"ORD-{last_id + 1:06d}"
|
||||
|
||||
|
||||
# --- Product visibility ------------------------------------------------------
|
||||
|
||||
|
||||
def visible_product_ids_for_customer(db: Session, *, tenant_id: str, client_account_id: int) -> set[int]:
|
||||
"""Ids the customer is explicitly *hidden* from (opt-out model)."""
|
||||
rows = db.scalars(
|
||||
select(CustomerProductVisibility.product_id).where(
|
||||
CustomerProductVisibility.client_account_id == client_account_id,
|
||||
CustomerProductVisibility.tenant_id == tenant_id,
|
||||
CustomerProductVisibility.visible.is_(False),
|
||||
)
|
||||
).all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
def list_visible_products(
|
||||
db: Session, *, tenant_id: str, client_account_id: int
|
||||
) -> list[CatalogueProduct]:
|
||||
hidden = visible_product_ids_for_customer(
|
||||
db, tenant_id=tenant_id, client_account_id=client_account_id
|
||||
)
|
||||
products = db.scalars(
|
||||
select(CatalogueProduct)
|
||||
.where(CatalogueProduct.tenant_id == tenant_id, CatalogueProduct.active.is_(True))
|
||||
.order_by(CatalogueProduct.category, CatalogueProduct.name)
|
||||
).all()
|
||||
return [p for p in products if p.id not in hidden]
|
||||
|
||||
|
||||
# --- Totals ------------------------------------------------------------------
|
||||
|
||||
|
||||
def effective_unit_price(line: OrderLine) -> float | None:
|
||||
if line.admin_override_price is not None:
|
||||
return line.admin_override_price
|
||||
return line.unit_price
|
||||
|
||||
|
||||
def recompute_order_totals(order: Order) -> None:
|
||||
subtotal = 0.0
|
||||
requires_quote = False
|
||||
for line in order.lines:
|
||||
unit = effective_unit_price(line)
|
||||
if line.requires_quote or unit is None:
|
||||
requires_quote = True
|
||||
line.line_total = None
|
||||
continue
|
||||
line.line_total = round(unit * line.quantity, 4)
|
||||
subtotal += line.line_total
|
||||
order.subtotal_ex_gst = round(subtotal, 4)
|
||||
order.requires_quote = requires_quote
|
||||
|
||||
|
||||
# --- Serialization -----------------------------------------------------------
|
||||
|
||||
|
||||
def serialize_product(
|
||||
product: CatalogueProduct,
|
||||
*,
|
||||
db: Session | None = None,
|
||||
client_account_id: int | None = None,
|
||||
quantity: float = 1.0,
|
||||
) -> dict:
|
||||
"""Serialize a catalogue product. When ``db`` + ``client_account_id`` are
|
||||
given, attach the resolved customer-specific price + provenance."""
|
||||
data = {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"sku": product.sku,
|
||||
"description": product.description,
|
||||
"category": product.category,
|
||||
"image_url": product.image_url,
|
||||
"unit_size": product.unit_size,
|
||||
"unit_of_measure": product.unit_of_measure,
|
||||
"min_order_quantity": product.min_order_quantity,
|
||||
"stock_status": product.stock_status,
|
||||
"active": product.active,
|
||||
"requires_quote": product.requires_quote,
|
||||
"base_price": product.base_price,
|
||||
"created_at": product.created_at,
|
||||
}
|
||||
if db is not None and client_account_id is not None:
|
||||
resolution = resolve_price(
|
||||
db, client_account_id=client_account_id, product=product, quantity=quantity
|
||||
)
|
||||
data["price"] = {
|
||||
"unit_price": resolution.unit_price,
|
||||
"price_source": resolution.price_source,
|
||||
"price_rule_id": resolution.price_rule_id,
|
||||
"discount_percent": resolution.discount_percent,
|
||||
"requires_quote": resolution.requires_quote,
|
||||
"label": resolution.label,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def serialize_order_line(line: OrderLine, *, for_admin: bool) -> dict:
|
||||
data = {
|
||||
"id": line.id,
|
||||
"product_id": line.product_id,
|
||||
"product_name": line.product_name,
|
||||
"product_sku": line.product_sku,
|
||||
"quantity": line.quantity,
|
||||
"unit_price": effective_unit_price(line),
|
||||
"line_total": line.line_total,
|
||||
"requires_quote": line.requires_quote,
|
||||
"price_source": line.price_source,
|
||||
"discount_percent": line.discount_percent,
|
||||
"notes": line.notes,
|
||||
}
|
||||
if for_admin:
|
||||
data.update(
|
||||
{
|
||||
"resolved_unit_price": line.unit_price,
|
||||
"admin_override_price": line.admin_override_price,
|
||||
"admin_override_reason": line.admin_override_reason,
|
||||
"price_rule_id": line.price_rule_id,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def serialize_order(order: Order, *, for_admin: bool) -> dict:
|
||||
status = order.status if for_admin else CUSTOMER_VISIBLE_STATUS.get(order.status, order.status)
|
||||
data = {
|
||||
"id": order.id,
|
||||
"order_number": order.order_number,
|
||||
"status": status,
|
||||
"client_account_id": order.client_account_id,
|
||||
"created_by_name": order.created_by_name,
|
||||
"purchase_order_number": order.purchase_order_number,
|
||||
"delivery_notes": order.delivery_notes,
|
||||
"requested_delivery_date": order.requested_delivery_date,
|
||||
"fulfilment_method": order.fulfilment_method,
|
||||
"subtotal_ex_gst": order.subtotal_ex_gst,
|
||||
"requires_quote": order.requires_quote,
|
||||
"submitted_at": order.submitted_at,
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
"editable": order.status in CUSTOMER_EDITABLE_STATUSES,
|
||||
"lines": [serialize_order_line(line, for_admin=for_admin) for line in order.lines],
|
||||
}
|
||||
if for_admin:
|
||||
data.update(
|
||||
{
|
||||
"raw_status": order.status,
|
||||
"admin_notes": order.admin_notes,
|
||||
"reopened": order.reopened,
|
||||
"xero_status": order.xero_status,
|
||||
"xero_invoice_id": order.xero_invoice_id,
|
||||
"status_history": [
|
||||
{
|
||||
"id": h.id,
|
||||
"from_status": h.from_status,
|
||||
"to_status": h.to_status,
|
||||
"actor_type": h.actor_type,
|
||||
"actor_name": h.actor_name,
|
||||
"note": h.note,
|
||||
"created_at": h.created_at,
|
||||
}
|
||||
for h in order.status_history
|
||||
],
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# --- Audit -------------------------------------------------------------------
|
||||
|
||||
|
||||
def audit_order_event(
|
||||
db: Session,
|
||||
*,
|
||||
session: AuthSession,
|
||||
order: Order,
|
||||
action: str,
|
||||
summary: str,
|
||||
target_id: int | None = None,
|
||||
) -> None:
|
||||
"""Record an ordering audit event onto the shared audit trail."""
|
||||
actor_type = "lean_admin" if session.role in {"admin", "internal"} else "customer"
|
||||
record_audit_event(
|
||||
db,
|
||||
tenant_id=order.tenant_id,
|
||||
client_account_id=order.client_account_id,
|
||||
actor_type=actor_type,
|
||||
actor_name=session.name or "",
|
||||
actor_email=session.email or "",
|
||||
actor_role=session.client_role or session.role,
|
||||
action=action,
|
||||
target_type="order",
|
||||
target_id=target_id if target_id is not None else order.id,
|
||||
module_key="ordering",
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def get_customer_account(db: Session, *, client_account_id: int) -> ClientAccount | None:
|
||||
# client_account_id is a global primary key; a customer's own tenant_id may
|
||||
# differ from ORDERING_TENANT, so we look up by id alone.
|
||||
return db.scalar(select(ClientAccount).where(ClientAccount.id == client_account_id))
|
||||
|
||||
|
||||
def ensure_customer_active(account: ClientAccount | None) -> None:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail="Customer account not found")
|
||||
if account.status != "active":
|
||||
raise HTTPException(status_code=403, detail="This customer account is disabled")
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Xero integration layer for the ordering portal.
|
||||
|
||||
This is a **clean, stubbed interface**. No Xero credentials are hard-coded — all
|
||||
configuration comes from environment variables (``XERO_*``). When credentials
|
||||
are absent (the default in dev/alpha), the service runs in "stub" mode: it
|
||||
records what *would* be sent and returns a deterministic fake invoice id so the
|
||||
rest of the order lifecycle can be exercised end-to-end.
|
||||
|
||||
To go live, implement the real HTTP calls inside :func:`_submit_to_xero_api`
|
||||
(see the TODOs) — no caller needs to change.
|
||||
|
||||
Service responsibilities (per spec):
|
||||
* Map a customer (ClientAccount) to a Xero contact.
|
||||
* Map catalogue products to Xero item codes.
|
||||
* Create a draft invoice equivalent for a confirmed order.
|
||||
* Record Xero response ids/status (via XeroSyncLog, written by the caller).
|
||||
* Handle failures gracefully (never raise into the request path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.client_access import ClientAccount
|
||||
from app.models.ordering import Order
|
||||
|
||||
|
||||
@dataclass
|
||||
class XeroConfig:
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
tenant_id: str | None = None
|
||||
base_url: str = "https://api.xero.com/api.xro/2.0"
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret and self.tenant_id)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "XeroConfig":
|
||||
return cls(
|
||||
client_id=os.getenv("XERO_CLIENT_ID") or None,
|
||||
client_secret=os.getenv("XERO_CLIENT_SECRET") or None,
|
||||
tenant_id=os.getenv("XERO_TENANT_ID") or None,
|
||||
base_url=os.getenv("XERO_API_BASE_URL", "https://api.xero.com/api.xro/2.0"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class XeroSubmissionResult:
|
||||
status: str # "success" | "failed"
|
||||
xero_invoice_id: str | None
|
||||
message: str
|
||||
request_summary: str
|
||||
stubbed: bool = False
|
||||
line_items: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def map_customer_to_contact(customer: ClientAccount) -> dict:
|
||||
"""Map a customer account onto a Xero contact payload."""
|
||||
return {
|
||||
# TODO: persist and reuse a real Xero ContactID once the contact has
|
||||
# been created/matched in Xero. For now we key on the client code.
|
||||
"ContactNumber": customer.client_code,
|
||||
"Name": customer.name,
|
||||
}
|
||||
|
||||
|
||||
def map_product_to_item_code(product_sku: str) -> str:
|
||||
"""Map a catalogue SKU onto a Xero item code.
|
||||
|
||||
TODO: support an explicit SKU→Xero item-code mapping table if the codes
|
||||
diverge. Today the SKU is used directly.
|
||||
"""
|
||||
return product_sku
|
||||
|
||||
|
||||
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
||||
"""Build the Xero draft-invoice payload for a confirmed order."""
|
||||
line_items = []
|
||||
for line in order.lines:
|
||||
if line.requires_quote or line.unit_price is None:
|
||||
# Quote-only lines can't carry a price; skip until quoted.
|
||||
continue
|
||||
unit_price = line.admin_override_price if line.admin_override_price is not None else line.unit_price
|
||||
line_items.append(
|
||||
{
|
||||
"ItemCode": map_product_to_item_code(line.product_sku),
|
||||
"Description": line.product_name,
|
||||
"Quantity": line.quantity,
|
||||
"UnitAmount": unit_price,
|
||||
"AccountCode": os.getenv("XERO_SALES_ACCOUNT_CODE", "200"),
|
||||
# Prices are GST-exclusive throughout the platform.
|
||||
"TaxType": os.getenv("XERO_TAX_TYPE", "OUTPUT"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"Type": "ACCREC",
|
||||
"Status": "DRAFT",
|
||||
"Contact": map_customer_to_contact(customer),
|
||||
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
|
||||
"LineAmountTypes": "Exclusive",
|
||||
"LineItems": line_items,
|
||||
}
|
||||
|
||||
|
||||
def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResult:
|
||||
"""Real Xero submission. Stubbed until credentials/endpoints are wired.
|
||||
|
||||
TODO (go-live):
|
||||
1. Obtain an OAuth2 token (client-credentials or stored refresh token).
|
||||
2. POST ``payload`` to ``{config.base_url}/Invoices`` with the
|
||||
``Xero-tenant-id`` header set to ``config.tenant_id``.
|
||||
3. Parse ``Invoices[0].InvoiceID`` from the response.
|
||||
4. Map non-2xx responses onto ``status="failed"`` with the error body.
|
||||
"""
|
||||
summary = f"{len(payload.get('LineItems', []))} line(s) for {payload['Contact']['Name']}"
|
||||
# Real call would go here. Intentionally not implemented yet.
|
||||
return XeroSubmissionResult(
|
||||
status="failed",
|
||||
xero_invoice_id=None,
|
||||
message="Xero live submission is not implemented yet (stub interface).",
|
||||
request_summary=summary,
|
||||
stubbed=False,
|
||||
line_items=payload.get("LineItems", []),
|
||||
)
|
||||
|
||||
|
||||
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
|
||||
"""Submit a confirmed order to Xero, or stub it when unconfigured.
|
||||
|
||||
Never raises — failures are returned as ``status="failed"`` results so the
|
||||
order lifecycle can record the attempt and continue.
|
||||
"""
|
||||
config = XeroConfig.from_env()
|
||||
payload = build_invoice_payload(order, customer)
|
||||
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
|
||||
|
||||
if not config.configured:
|
||||
# Stub mode: deterministic fake invoice id so downstream flows work.
|
||||
fake_id = f"STUB-INV-{order.id:06d}"
|
||||
return XeroSubmissionResult(
|
||||
status="success",
|
||||
xero_invoice_id=fake_id,
|
||||
message="Xero not configured — order recorded in stub mode.",
|
||||
request_summary=summary,
|
||||
stubbed=True,
|
||||
line_items=payload["LineItems"],
|
||||
)
|
||||
|
||||
try:
|
||||
return _submit_to_xero_api(config, payload)
|
||||
except Exception as exc: # pragma: no cover - defensive: never break the request path
|
||||
return XeroSubmissionResult(
|
||||
status="failed",
|
||||
xero_invoice_id=None,
|
||||
message=f"Xero submission error: {exc}",
|
||||
request_summary=summary,
|
||||
stubbed=False,
|
||||
line_items=payload["LineItems"],
|
||||
)
|
||||
|
||||
|
||||
def xero_status_snapshot() -> dict:
|
||||
config = XeroConfig.from_env()
|
||||
return {
|
||||
"configured": config.configured,
|
||||
"mode": "live" if config.configured else "stub",
|
||||
"base_url": config.base_url,
|
||||
"checked_at": datetime.utcnow().isoformat(),
|
||||
"missing_env": [
|
||||
name
|
||||
for name, value in (
|
||||
("XERO_CLIENT_ID", config.client_id),
|
||||
("XERO_CLIENT_SECRET", config.client_secret),
|
||||
("XERO_TENANT_ID", config.tenant_id),
|
||||
)
|
||||
if not value
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user