v0.1.19 - Throughput overview & responsive header

- Throughput: new "Throughput Overview" header with Gauge icon and brand-green
  badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix)
- Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks)
  driving the customer-mix cards; stats window widened to 12 weeks so switching
  range is a pure client-side re-filter
- Throughput: cards collapse to a single even 5-across row on laptop and up,
  with container-query value text that scales to each card's width
- Throughput: date logic pinned to Australian Eastern time (fixes the day-early
  date); This week subtitle shows the Mon-Sun date range
- Throughput: subtler tinted add-form; removed the inline-entry kicker and the
  "Open full form" link
- Topbar: fix cramped laptop header - action toggles no longer wrap above the
  user button; search drops to its own row earlier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:01:10 +12:00
co-authored by Claude Opus 4.8
parent 4ff372d307
commit 2de82776cb
64 changed files with 6034 additions and 1134 deletions
+320 -11
View File
@@ -1,14 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db
from app.models.mix import Mix
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.schemas.editor import (
EditorIngredientCreate,
EditorIngredientRow,
EditorIngredientUpdate,
EditorMixFormulaRead,
EditorMixIngredientCreate,
EditorMixIngredientUpdate,
EditorMixRow,
EditorMixUpdate,
EditorProductFormulaRead,
EditorProductIngredientCreate,
@@ -17,6 +24,7 @@ from app.schemas.editor import (
EditorProductUpdate,
)
from app.services.client_access_service import has_access_level
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
router = APIRouter(prefix="/api/editor", tags=["editor"])
@@ -63,6 +71,65 @@ def _serialize_product_formula(product: Product) -> dict:
}
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"visible": visible_count > 0,
"product_count": product_count,
"visible_product_count": visible_count,
"notes": mix.notes,
}
def _mix_product_counts(db: Session, tenant_id: str) -> dict[int, tuple[int, int]]:
"""Per-mix (total products, visible products) used to drive the Status column."""
rows = db.execute(
select(
Product.mix_id,
func.count(),
func.sum(case((Product.visible, 1), else_=0)),
)
.where(Product.tenant_id == tenant_id)
.group_by(Product.mix_id)
).all()
return {mix_id: (int(total), int(visible or 0)) for mix_id, total, visible in rows}
def _serialize_mix_formula(mix: Mix) -> dict:
ingredients = [
{
"id": ingredient.id,
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"notes": ingredient.notes,
}
for ingredient in sorted(
mix.ingredients,
key=lambda item: item.raw_material.name if item.raw_material else "",
)
]
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"ingredients": ingredients,
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
}
def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None:
return db.scalar(
select(Mix)
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
)
def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
return db.scalar(
select(Product)
@@ -156,7 +223,34 @@ def update_editor_product(
return _serialize_row(product)
@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow])
@router.get("/mixes", response_model=list[EditorMixRow])
def list_editor_mixes(
q: str | None = Query(default=None, max_length=255),
client_name: str | None = Query(default=None, max_length=255),
limit: int = Query(default=500, ge=1, le=1000),
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
statement = select(Mix).where(Mix.tenant_id == session.tenant_id)
if client_name:
statement = statement.where(Mix.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term)))
statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit)
counts = _mix_product_counts(db, session.tenant_id or "")
mixes = db.scalars(statement).all()
return [
_serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0])
for mix in mixes
]
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
def update_editor_mix(
mix_id: int,
payload: EditorMixUpdate,
@@ -167,18 +261,120 @@ def update_editor_mix(
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
updates = payload.model_dump(exclude_unset=True)
# `visible` is a virtual field: it fans out to the visibility of every product
# under the mix rather than mapping to a mix column.
visible = updates.pop("visible", None)
for field, value in updates.items():
setattr(mix, field, value)
if visible is not None:
for product in db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
).all():
product.visible = visible
db.commit()
products = db.scalars(
select(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name, Product.id)
).all()
return [_serialize_row(product) for product in products]
counts = _mix_product_counts(db, session.tenant_id or "")
total, visible_count = counts.get(mix_id, (0, 0))
return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)
@router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
def get_editor_mix_ingredients(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
return _serialize_mix_formula(mix)
@router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201)
def add_editor_mix_ingredient(
mix_id: int,
payload: EditorMixIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found")
db.add(
MixIngredient(
tenant_id=session.tenant_id or "",
mix_id=mix_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
notes=payload.notes,
)
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def update_editor_mix_ingredient(
mix_id: int,
ingredient_id: int,
payload: EditorMixIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient)
.join(Mix)
.where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
Mix.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(ingredient, field, value)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def delete_editor_mix_ingredient(
mix_id: int,
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient)
.join(Mix)
.where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
Mix.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
@@ -282,3 +478,116 @@ def delete_editor_product_ingredient(
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
# --- Ingredients (raw materials) catalogue -----------------------------------
#
# The mix editor consumes raw materials as the ingredients dropdown; this gives
# Lean admins a sibling editor to curate that catalogue — the ingredients that
# ultimately get used inside mixes. Same auth/tenant model as the mix editor.
def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
active_price = get_active_price(material)
cost_per_kg = (
calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None
)
return {
"id": material.id,
"name": material.name,
"supplier": material.supplier,
"unit_of_measure": material.unit_of_measure,
"kg_per_unit": material.kg_per_unit,
"status": material.status,
"rounding_decimals": material.rounding_decimals,
"notes": material.notes,
"cost_per_kg": cost_per_kg,
"usage_count": usage_count,
"created_at": material.created_at,
}
def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]:
"""How many product/mix formula rows reference each raw material."""
rows = db.execute(
select(ProductIngredient.raw_material_id, func.count())
.where(ProductIngredient.tenant_id == tenant_id)
.group_by(ProductIngredient.raw_material_id)
).all()
return {raw_material_id: count for raw_material_id, count in rows}
@router.get("/ingredients", response_model=list[EditorIngredientRow])
def list_editor_ingredients(
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == tenant_id)
.options(selectinload(RawMaterial.price_versions))
.order_by(RawMaterial.name)
).all()
usage = _ingredient_usage_counts(db, tenant_id)
return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials]
@router.post("/ingredients", response_model=EditorIngredientRow, status_code=201)
def create_editor_ingredient(
payload: EditorIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
material = RawMaterial(
tenant_id=session.tenant_id or "",
name=payload.name.strip(),
supplier=(payload.supplier or "").strip() or None,
unit_of_measure=payload.unit_of_measure.strip(),
kg_per_unit=payload.kg_per_unit,
status=payload.status.strip() or "active",
rounding_decimals=payload.rounding_decimals,
notes=payload.notes,
)
db.add(material)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
db.refresh(material)
return _serialize_ingredient(material, 0)
@router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow)
def update_editor_ingredient(
ingredient_id: int,
payload: EditorIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
material = db.scalar(
select(RawMaterial)
.where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)
.options(selectinload(RawMaterial.price_versions))
)
if material is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
updates = payload.model_dump(exclude_unset=True)
if "name" in updates and updates["name"] is not None:
updates["name"] = updates["name"].strip()
if "supplier" in updates:
updates["supplier"] = (updates["supplier"] or "").strip() or None
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
updates["unit_of_measure"] = updates["unit_of_measure"].strip()
for field, value in updates.items():
setattr(material, field, value)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
db.refresh(material)
usage = _ingredient_usage_counts(db, tenant_id)
return _serialize_ingredient(material, usage.get(material.id, 0))
+162 -3
View File
@@ -7,9 +7,10 @@ within the seller's ordering tenant.
from __future__ import annotations
import re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
@@ -27,6 +28,7 @@ from app.models.ordering import (
PriceListItem,
PriceTier,
ProductCategory,
XeroContactLink,
XeroSyncLog,
)
from app.schemas.ordering import (
@@ -47,6 +49,7 @@ from app.schemas.ordering import (
PriceListItemUpsert,
ReopenOrderRequest,
VisibilityUpdate,
XeroContactLinkUpsert,
)
from app.services import ordering_service as svc
from app.services.client_access_service import (
@@ -54,7 +57,11 @@ from app.services.client_access_service import (
record_audit_event,
)
from app.services.order_notifications import get_or_create_settings
from app.services.xero_service import submit_order_to_xero, xero_status_snapshot
from app.services.xero_service import (
list_xero_contacts,
submit_order_to_xero,
xero_status_snapshot,
)
router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"])
@@ -122,11 +129,21 @@ def _serialize_tiers(db: Session, *, customer_product_price_id=None, price_list_
# --- Customers ---------------------------------------------------------------
def _xero_link_for(db: Session, customer_id: int) -> XeroContactLink | None:
return db.scalar(
select(XeroContactLink).where(
XeroContactLink.tenant_id == TENANT,
XeroContactLink.client_account_id == customer_id,
)
)
def _serialize_customer(db: Session, account: ClientAccount) -> dict:
users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all()
assignment = db.scalar(
select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id)
)
link = _xero_link_for(db, account.id)
return {
"id": account.id,
"name": account.name,
@@ -137,6 +154,8 @@ def _serialize_customer(db: Session, account: ClientAccount) -> dict:
"user_count": len(users),
"price_list_id": assignment.price_list_id if assignment else None,
"discount_percent": assignment.discount_percent if assignment else 0.0,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"created_at": account.created_at,
}
@@ -905,7 +924,10 @@ def send_order_to_xero(
if order.status not in {"confirmed", "in_production", "sent_to_xero"}:
raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero")
account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
result = submit_order_to_xero(order, account)
link = _xero_link_for(db, order.client_account_id)
result = submit_order_to_xero(order, account, link)
if link is not None and result.status == "success":
link.last_synced_at = datetime.utcnow()
db.add(
XeroSyncLog(
@@ -985,8 +1007,19 @@ def get_xero_status(
recent = db.scalars(
select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20)
).all()
total_customers = db.scalar(
select(func.count()).select_from(ClientAccount)
) or 0
linked_customers = db.scalar(
select(func.count()).select_from(XeroContactLink).where(XeroContactLink.tenant_id == TENANT)
) or 0
return {
"connection": xero_status_snapshot(),
"contact_links": {
"linked": linked_customers,
"total": total_customers,
"unlinked": max(total_customers - linked_customers, 0),
},
"recent_syncs": [
{
"id": log.id,
@@ -999,3 +1032,129 @@ def get_xero_status(
for log in recent
],
}
# --- Xero contact mapping ----------------------------------------------------
@router.get("/xero/contacts")
def list_available_xero_contacts(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""The Xero contacts available to link a customer against (stub or live)."""
contacts, stubbed = list_xero_contacts()
return {"contacts": [c.as_dict() for c in contacts], "stubbed": stubbed}
@router.get("/xero/contact-links")
def list_xero_contact_links(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""Every customer with its current Xero link and a suggested match.
The suggestion is a best-effort name match against the available contacts so
the operator can confirm rather than hunt through a dropdown.
"""
contacts, _ = list_xero_contacts()
by_name = {c.name.strip().lower(): c for c in contacts}
accounts = db.scalars(select(ClientAccount).order_by(ClientAccount.name)).all()
rows = []
for account in accounts:
link = _xero_link_for(db, account.id)
suggestion = None if link else by_name.get(account.name.strip().lower())
rows.append(
{
"customer_id": account.id,
"customer_name": account.name,
"client_code": account.client_code,
"linked": link is not None,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"xero_contact_email": link.xero_contact_email if link else None,
"last_synced_at": link.last_synced_at if link else None,
"suggested_contact_id": suggestion.contact_id if suggestion else None,
}
)
return rows
@router.put("/customers/{customer_id}/xero-link")
def link_customer_to_xero(
customer_id: int,
payload: XeroContactLinkUpsert,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
# Default the cached name/email from the known contact list when the caller
# only sends an id, so the mapping reads nicely without a live round-trip.
name = payload.xero_contact_name
email = payload.xero_contact_email
if name is None or email is None:
contacts, _ = list_xero_contacts()
match = next((c for c in contacts if c.contact_id == payload.xero_contact_id), None)
if match is not None:
name = name or match.name
email = email or match.email
link = _xero_link_for(db, customer_id)
if link is None:
link = XeroContactLink(
tenant_id=TENANT,
client_account_id=customer_id,
xero_contact_id=payload.xero_contact_id,
xero_contact_name=name,
xero_contact_email=email,
)
db.add(link)
else:
link.xero_contact_id = payload.xero_contact_id
link.xero_contact_name = name
link.xero_contact_email = email
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_linked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} linked to Xero contact {name or payload.xero_contact_id}.",
**_actor(session),
)
db.commit()
return {
"customer_id": customer_id,
"linked": True,
"xero_contact_id": link.xero_contact_id,
"xero_contact_name": link.xero_contact_name,
"xero_contact_email": link.xero_contact_email,
}
@router.delete("/customers/{customer_id}/xero-link", status_code=status.HTTP_204_NO_CONTENT)
def unlink_customer_from_xero(
customer_id: int,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
link = _xero_link_for(db, customer_id)
if link is not None:
db.delete(link)
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_unlinked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} unlinked from Xero contact.",
**_actor(session),
)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+37 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -13,16 +13,21 @@ from app.schemas.throughput import (
ThroughputEntryCreate,
ThroughputEntryRead,
ThroughputEntryUpdate,
ThroughputImportResult,
ThroughputProductCreate,
ThroughputProductRead,
ThroughputProductUpdate,
)
from app.services.throughput_service import (
calculate_kg,
import_entries_from_file,
normalise_staff_name,
serialize_entry,
)
# Uploaded files larger than this are rejected before we read them into memory.
_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB
router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"])
MODULE_KEY = "operations_throughput"
@@ -184,6 +189,33 @@ def create_entry(
return serialize_entry(entry)
@router.post("/import", response_model=ThroughputImportResult)
def import_entries(
file: UploadFile = File(...),
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
content = file.file.read()
if not content:
raise HTTPException(status_code=400, detail="The uploaded file is empty.")
if len(content) > _MAX_IMPORT_BYTES:
raise HTTPException(status_code=413, detail="File is too large. Keep uploads under 10 MB.")
try:
result = import_entries_from_file(
db,
filename=file.filename or "upload.csv",
content=content,
tenant_id=session.tenant_id,
created_by=session.email,
)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
def get_entry(
entry_id: int,
@@ -233,7 +265,10 @@ def update_entry(
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_entry(
entry_id: int,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")),
# Correcting a mistaken run is part of day-to-day operating, so deleting an
# entry sits at the same "edit" level as adding/editing one. (No throughput
# role is granted "manage", so requiring it here would 403 everyone.)
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
entry = db.scalar(
+2
View File
@@ -129,6 +129,8 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"),
("production_throughput_entries", "job_number", "VARCHAR(64)"),
("production_throughput_entries", "stock_quantity", "FLOAT"),
("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
)
+2
View File
@@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base):
required_kg: Mapped[float] = mapped_column(Float)
mix_percentage: Mapped[float] = mapped_column(Float)
unit: Mapped[str] = mapped_column(String(64))
# Snapshot of the ingredient's rounding setting at save time.
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
+28
View File
@@ -376,6 +376,34 @@ class NotificationSetting(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class XeroContactLink(Base):
"""Persistent link between a customer (:class:`ClientAccount`) and a Xero
contact.
Maintained from the Integrations console. Once a customer is linked, order
invoices reference the real Xero ``ContactID`` instead of falling back to the
client code which is what lets Xero attach the invoice to the right
contact rather than creating a duplicate. One link per customer per tenant.
"""
__tablename__ = "xero_contact_links"
__table_args__ = (
UniqueConstraint("tenant_id", "client_account_id", name="uq_xero_contact_link_customer"),
)
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)
xero_contact_id: Mapped[str] = mapped_column(String(128))
xero_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
xero_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# When the contact details were last reconciled with Xero (a future live
# sync can refresh name/email and stamp this).
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, 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)
class XeroSyncLog(Base):
__tablename__ = "xero_sync_log"
+4 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text
from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
@@ -18,6 +18,9 @@ class RawMaterial(Base):
unit_of_measure: Mapped[str] = mapped_column(String(64))
kg_per_unit: Mapped[float] = mapped_column(Float)
status: Mapped[str] = mapped_column(String(32), default="active")
# Decimal places this ingredient's required-kg is rounded to in the mix
# calculator output. Set per-ingredient from the Ingredients Editor.
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+91
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
@@ -36,6 +38,52 @@ class EditorMixUpdate(BaseModel):
client_name: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
# Toggling a mix's status fans out to the visibility of every product under it.
visible: bool | None = None
class EditorMixRow(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
# A mix reads as "Active" when at least one of its products is visible.
visible: bool
product_count: int
visible_product_count: int
notes: str | None
class EditorMixIngredientRead(BaseModel):
id: int
raw_material_id: int
raw_material_name: str
quantity_kg: float
notes: str | None
class EditorMixFormulaRead(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
ingredients: list[EditorMixIngredientRead]
total_kg: float
class EditorMixIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorMixIngredientUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
quantity_kg: float | None = Field(default=None, gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorProductIngredientCreate(BaseModel):
@@ -71,3 +119,46 @@ class EditorProductFormulaRead(BaseModel):
mix_name: str
ingredients: list[EditorProductIngredientRead]
total_kg: float
# --- Ingredients (raw materials) catalogue -----------------------------------
class EditorIngredientRow(BaseModel):
id: int
name: str
supplier: str | None
unit_of_measure: str
kg_per_unit: float
status: str
# Decimal places this ingredient is rounded to in the mix calculator output.
rounding_decimals: int
notes: str | None
cost_per_kg: float | None
# How many product/mix formulas currently reference this ingredient.
usage_count: int
created_at: datetime
class EditorIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
supplier: str | None = Field(default=None, max_length=255)
unit_of_measure: str = Field(min_length=1, max_length=64)
kg_per_unit: float = Field(gt=0)
status: str = Field(default="active", max_length=32)
rounding_decimals: int = Field(default=2, ge=0, le=6)
notes: str | None = Field(default=None, max_length=2000)
class EditorIngredientUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=255)
supplier: str | None = Field(default=None, max_length=255)
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
kg_per_unit: float | None = Field(default=None, gt=0)
status: str | None = Field(default=None, max_length=32)
rounding_decimals: int | None = Field(default=None, ge=0, le=6)
notes: str | None = Field(default=None, max_length=2000)
+1
View File
@@ -26,6 +26,7 @@ class MixCalculatorSessionLineRead(BaseModel):
required_kg: float
mix_percentage: float
unit: str
rounding_decimals: int = 2
sort_order: int
+12
View File
@@ -238,6 +238,18 @@ class NotificationSettingsUpdate(BaseModel):
from_email: str | None = None
# --- Admin: Xero integration -------------------------------------------------
class XeroContactLinkUpsert(BaseModel):
"""Link a customer to a Xero contact. ``xero_contact_id`` is the Xero
``ContactID`` (or, in stub mode, the deterministic stub id)."""
xero_contact_id: str = Field(min_length=1, max_length=128)
xero_contact_name: str | None = Field(default=None, max_length=255)
xero_contact_email: str | None = Field(default=None, max_length=255)
# --- Admin: customers & users ------------------------------------------------
_CUSTOMER_STATUSES = {"active", "disabled"}
+7
View File
@@ -117,6 +117,13 @@ class ThroughputEntryUpdate(BaseModel):
notes: str | None = Field(default=None, max_length=2000)
class ThroughputImportResult(BaseModel):
entries_imported: int
entries_skipped: int
products_created: int
errors: list[str] = Field(default_factory=list)
class ThroughputEntryRead(BaseModel):
id: int
tenant_id: str
+4 -1
View File
@@ -319,7 +319,10 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
)
pdf.setFont("Helvetica", table_font_size)
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg")
# Each ingredient carries its own rounding (set in the Ingredients Editor)
# so the printed sheet matches the on-screen calculated output.
line_decimals = getattr(line, "rounding_decimals", 2)
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg, line_decimals)}kg")
strip_y = table_bottom - 6
if note_lines:
@@ -43,6 +43,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"raw_material_name": ingredient.raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure,
"rounding_decimals": ingredient.raw_material.rounding_decimals,
"sort_order": ingredient.sort_order,
}
for ingredient in product.ingredients
@@ -55,6 +56,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
"sort_order": index,
}
for index, ingredient in enumerate(product.mix.ingredients, start=1)
@@ -128,6 +130,7 @@ def calculate_mix_calculator_preview(
"required_kg": required_kg,
"mix_percentage": mix_percentage,
"unit": ingredient["unit"],
"rounding_decimals": ingredient.get("rounding_decimals", 2),
"sort_order": ingredient["sort_order"] or index,
}
)
@@ -260,6 +263,7 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_
"required_kg": round(line.required_kg, 4),
"mix_percentage": round(line.mix_percentage, 4),
"unit": line.unit,
"rounding_decimals": line.rounding_decimals,
"sort_order": line.sort_order,
}
for line in session_record.lines
@@ -331,6 +335,7 @@ def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, pay
required_kg=line["required_kg"],
mix_percentage=line["mix_percentage"],
unit=line["unit"],
rounding_decimals=line.get("rounding_decimals", 2),
sort_order=line["sort_order"],
)
for line in preview["lines"]
+309
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import csv
import io
import logging
import os
from datetime import date, datetime
@@ -369,3 +371,310 @@ def resolve_workbook_path() -> Path | None:
if candidate.exists():
return candidate
return None
# ── Ad-hoc CSV / spreadsheet upload import ──────────────────────────────────
# Lets an operator upload their own CSV or .xlsx of packing runs (from Settings
# → Import) and have every row saved as a throughput entry. Unlike the bundled
# workbook seed above, this is column-header driven so the file can be a simple
# hand-built sheet rather than the exact "Operations Throughput.xlsx" layout.
# Maps the column headers we accept (normalised: lower-cased, spaces/dashes →
# single spaces) onto the canonical field used internally. Several aliases per
# field so a human-built sheet "just works".
_HEADER_ALIASES: dict[str, str] = {
"date": "date",
"production date": "date",
"production_date": "date",
"product": "product",
"product name": "product",
"product_name": "product",
"product name snapshot": "product",
"name": "product",
"item id": "item_id",
"item_id": "item_id",
"itemid": "item_id",
"sku": "item_id",
"quantity": "quantity",
"qty": "quantity",
"packed": "quantity",
"quantity packed": "quantity",
"amount": "quantity",
"quantity type": "quantity_type",
"type": "quantity_type",
"unit": "quantity_type",
"packed as": "quantity_type",
"bag size": "bag_size",
"bag_size": "bag_size",
"kg per bag": "bag_size",
"kg/bag": "bag_size",
"bagsize": "bag_size",
"staff": "staff_name",
"staff name": "staff_name",
"packed by": "staff_name",
"operator": "staff_name",
"for order": "for_order",
"order": "for_order",
"for stock": "for_stock",
"stock": "for_stock",
"job number": "job_number",
"job": "job_number",
"job no": "job_number",
"order number": "job_number",
"stock quantity": "stock_quantity",
"stock qty": "stock_quantity",
"sample box no": "sample_box_no",
"sample box": "sample_box_no",
"scales checked": "scales_checked",
"scales": "scales_checked",
"label correct": "label_correct",
"label": "label_correct",
"bag sealed": "bag_sealed",
"sealed": "bag_sealed",
"pallet good condition": "pallet_good_condition",
"pallet": "pallet_good_condition",
"notes": "notes",
"note": "notes",
"comment": "notes",
"comments": "notes",
}
# How many row-level errors we collect before truncating, to keep the response
# (and the toast) sane on a badly-formed file.
_MAX_REPORTED_ERRORS = 50
def _normalise_header(raw: object) -> str | None:
if raw is None:
return None
key = " ".join(str(raw).strip().lower().replace("-", " ").replace("_", " ").split())
if not key:
return None
if key in _HEADER_ALIASES:
return _HEADER_ALIASES[key]
# Test weights: "test weight 1".."test weight 5" (and "tw1" style).
for n in range(1, 6):
if key in {f"test weight {n}", f"tw{n}", f"test {n}"}:
return f"test_weight_{n}"
return None
def _coerce_quantity_type(value: object) -> str | None:
if value is None:
return None
text = str(value).strip().lower()
if not text:
return None
if text in {"bag", "bags", "b"}:
return "bags"
if text in {"kg", "kgs", "kilogram", "kilograms", "bulka", "bulk"}:
return "kg"
return None
def _read_tabular_file(filename: str, content: bytes) -> tuple[list[str | None], list[tuple]]:
"""Return (headers, data_rows). Detects CSV vs .xlsx by extension/content."""
lowered = (filename or "").lower()
is_excel = lowered.endswith((".xlsx", ".xlsm", ".xls"))
if is_excel:
workbook = load_workbook(io.BytesIO(content), data_only=True, read_only=True)
ws = workbook.active
rows = [tuple(r) for r in ws.iter_rows(values_only=True)]
workbook.close()
else:
text = None
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
try:
text = content.decode(encoding)
break
except UnicodeDecodeError:
continue
if text is None:
raise ValueError("Could not decode the file as text. Save it as UTF-8 CSV or .xlsx.")
# Sniff the delimiter (comma/semicolon/tab) but fall back to comma.
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except csv.Error:
dialect = csv.excel
rows = [tuple(r) for r in csv.reader(io.StringIO(text), dialect)]
# Find the first row that has at least one recognised header; treat it as
# the header row and everything after as data.
for index, row in enumerate(rows):
if any(_normalise_header(cell) is not None for cell in row):
return list(row), rows[index + 1 :]
return [], []
def import_entries_from_file(
db: Session,
*,
filename: str,
content: bytes,
tenant_id: str,
created_by: str | None,
) -> dict:
"""Parse an uploaded CSV/spreadsheet and persist each row as a throughput
entry. Products are matched by item_id then name, and auto-created when not
found so every entry stays linked. Returns a summary with row-level errors.
"""
headers, data_rows = _read_tabular_file(filename, content)
if not headers:
raise ValueError(
"No recognised columns found. The file needs a header row with at "
"least Date, Product and Quantity columns."
)
# Map canonical field name → column index. First occurrence wins.
field_index: dict[str, int] = {}
for col, raw in enumerate(headers):
field = _normalise_header(raw)
if field and field not in field_index:
field_index[field] = col
for required in ("date", "product", "quantity"):
if required not in field_index:
raise ValueError(
f"Missing required '{required}' column. Required columns are "
"Date, Product and Quantity."
)
def cell(row: tuple, field: str) -> object:
idx = field_index.get(field)
if idx is None or idx >= len(row):
return None
return row[idx]
# Index existing products for matching (by item_id and by lower-cased name).
by_item: dict[str, ThroughputProduct] = {}
by_name: dict[str, ThroughputProduct] = {}
for product in db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
).all():
if product.item_id:
by_item[str(product.item_id)] = product
by_name[product.name.lower()] = product
imported = 0
skipped = 0
products_created = 0
errors: list[str] = []
def note_error(message: str) -> None:
if len(errors) < _MAX_REPORTED_ERRORS:
errors.append(message)
for offset, row in enumerate(data_rows):
# Sheet/file row number for human-friendly error messages (header = 1).
line_no = offset + 2
if not row or all(value is None or str(value).strip() == "" for value in row):
continue
production_date = _coerce_date(cell(row, "date"))
product_name = _coerce_text(cell(row, "product"))
quantity = _coerce_float(cell(row, "quantity"))
if production_date is None:
skipped += 1
note_error(f"Row {line_no}: missing or invalid date.")
continue
if not product_name:
skipped += 1
note_error(f"Row {line_no}: missing product name.")
continue
if quantity is None or quantity < 0:
skipped += 1
note_error(f"Row {line_no}: missing or invalid quantity.")
continue
bag_size = _coerce_float(cell(row, "bag_size"))
quantity_type = _coerce_quantity_type(cell(row, "quantity_type"))
if quantity_type is None:
# Infer: bulka-style rows have a blank or very large bag size.
if bag_size is None or bag_size >= _BULKA_BAG_SIZE_THRESHOLD or "bulka" in product_name.lower():
quantity_type = "kg"
else:
quantity_type = "bags"
if quantity_type == "bags" and (bag_size is None or bag_size <= 0):
skipped += 1
note_error(f"Row {line_no}: bag size is required when packed as bags.")
continue
item_id_raw = cell(row, "item_id")
item_id = None
if item_id_raw is not None:
if isinstance(item_id_raw, float) and item_id_raw.is_integer():
item_id = str(int(item_id_raw))
else:
item_id = _coerce_text(item_id_raw)
product = (by_item.get(item_id) if item_id else None) or by_name.get(product_name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=item_id,
name=product_name,
default_bag_size=bag_size,
is_bulka_default=_infer_bulka_default(product_name, bag_size),
active=True,
notes="Auto-created during throughput import",
)
db.add(product)
db.flush()
products_created += 1
if item_id:
by_item[item_id] = product
by_name[product_name.lower()] = product
for_order = _coerce_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False
for_stock = _coerce_bool(cell(row, "for_stock")) if field_index.get("for_stock") is not None else False
stock_quantity = _coerce_float(cell(row, "stock_quantity")) if for_stock else None
calculated = calculate_kg(quantity, quantity_type, bag_size)
entry = ProductionThroughput(
tenant_id=tenant_id,
production_date=production_date,
product_id=product.id,
product_name_snapshot=product_name,
bag_size=bag_size,
scales_checked=_coerce_bool(cell(row, "scales_checked")),
label_correct=_coerce_bool(cell(row, "label_correct")),
bag_sealed=_coerce_bool(cell(row, "bag_sealed")),
pallet_good_condition=_coerce_bool(cell(row, "pallet_good_condition")),
for_order=for_order,
for_stock=for_stock,
job_number=_coerce_text(cell(row, "job_number")) if for_order else None,
stock_quantity=stock_quantity,
sample_box_no=_coerce_text(cell(row, "sample_box_no")),
test_weight_1=_coerce_float(cell(row, "test_weight_1")),
test_weight_2=_coerce_float(cell(row, "test_weight_2")),
test_weight_3=_coerce_float(cell(row, "test_weight_3")),
test_weight_4=_coerce_float(cell(row, "test_weight_4")),
test_weight_5=_coerce_float(cell(row, "test_weight_5")),
quantity=quantity,
quantity_type=quantity_type,
calculated_kg=calculated,
staff_name=normalise_staff_name(cell(row, "staff_name")),
notes=_coerce_text(cell(row, "notes")),
created_by=created_by or "csv-import",
)
db.add(entry)
imported += 1
if imported == 0 and products_created == 0:
# Nothing landed — don't leave a half-open transaction.
db.rollback()
else:
db.commit()
return {
"entries_imported": imported,
"entries_skipped": skipped,
"products_created": products_created,
"errors": errors,
}
+80 -11
View File
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from app.models.client_access import ClientAccount
from app.models.ordering import Order
from app.models.ordering import Order, XeroContactLink
@dataclass
@@ -57,11 +57,75 @@ class XeroSubmissionResult:
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."""
@dataclass
class XeroContact:
"""A Xero contact available to link a customer against."""
contact_id: str
name: str
email: str | None = None
status: str = "ACTIVE"
def as_dict(self) -> dict:
return {
"contact_id": self.contact_id,
"name": self.name,
"email": self.email,
"status": self.status,
}
# Deterministic sample contacts used while running in stub mode (no Xero
# credentials). They stand in for "what's in Xero" so the customer→contact
# mapping UI is usable before the live API is wired. Ids mimic Xero GUIDs.
_STUB_CONTACTS: tuple[XeroContact, ...] = (
XeroContact("STUB-CON-0001", "Hunter Premium Produce", "accounts@hunterpremium.example", "ACTIVE"),
XeroContact("STUB-CON-0002", "Mayreef Pty Ltd", "ap@mayreef.example", "ACTIVE"),
XeroContact("STUB-CON-0003", "Ian McKay Stock Feeds", "ian@mckayfeeds.example", "ACTIVE"),
XeroContact("STUB-CON-0004", "Peckish Bird Foods", "orders@peckish.example", "ACTIVE"),
XeroContact("STUB-CON-0005", "Hay & Straw Co", "info@hayandstraw.example", "ACTIVE"),
XeroContact("STUB-CON-0006", "PHF Horse Mixes", "accounts@phfhorse.example", "ACTIVE"),
)
def _fetch_contacts_from_api(config: XeroConfig) -> list[XeroContact]:
"""Live contact fetch. Stubbed until credentials/endpoints are wired.
TODO (go-live): GET ``{config.base_url}/Contacts`` with the
``Xero-tenant-id`` header, page through ``Contacts[]`` and map each onto a
:class:`XeroContact` (``ContactID``/``Name``/``EmailAddress``/``ContactStatus``).
"""
raise NotImplementedError("Live Xero contact fetch is not implemented yet.")
def list_xero_contacts(config: XeroConfig | None = None) -> tuple[list[XeroContact], bool]:
"""Return the Xero contacts available for linking and whether they're stubbed.
Never raises on a live-mode error it returns an empty list so the mapping
console still renders.
"""
config = config or XeroConfig.from_env()
if not config.configured:
return list(_STUB_CONTACTS), True
try:
return _fetch_contacts_from_api(config), False
except Exception: # pragma: no cover - defensive: never break the request path
return [], False
def map_customer_to_contact(customer: ClientAccount, link: XeroContactLink | None = None) -> dict:
"""Map a customer account onto a Xero contact payload.
When the customer has been linked to a Xero contact we send the real
``ContactID`` so Xero attaches the invoice to the existing contact. Without a
link we fall back to keying on the client code (Xero will match-or-create).
"""
if link is not None and link.xero_contact_id:
return {
"ContactID": link.xero_contact_id,
"Name": link.xero_contact_name or customer.name,
}
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,
}
@@ -76,7 +140,9 @@ def map_product_to_item_code(product_sku: str) -> str:
return product_sku
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
def build_invoice_payload(
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
) -> dict:
"""Build the Xero draft-invoice payload for a confirmed order."""
line_items = []
for line in order.lines:
@@ -98,7 +164,7 @@ def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
return {
"Type": "ACCREC",
"Status": "DRAFT",
"Contact": map_customer_to_contact(customer),
"Contact": map_customer_to_contact(customer, link),
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
"LineAmountTypes": "Exclusive",
"LineItems": line_items,
@@ -127,14 +193,17 @@ def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResu
)
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
def submit_order_to_xero(
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
) -> 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.
Pass ``link`` to invoice against the customer's mapped Xero contact. 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)
payload = build_invoice_payload(order, customer, link)
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
if not config.configured: