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:
+320
-11
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,6 +26,7 @@ class MixCalculatorSessionLineRead(BaseModel):
|
||||
required_kg: float
|
||||
mix_percentage: float
|
||||
unit: str
|
||||
rounding_decimals: int = 2
|
||||
sort_order: int
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-entry-app-backend"
|
||||
version = "0.1.14"
|
||||
version = "0.1.19"
|
||||
description = "Costing platform MVP backend"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1.0",
|
||||
"python-multipart>=0.0.9,<1.0",
|
||||
"openpyxl>=3.1,<4.0",
|
||||
"rich>=13.9,<15.0",
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
|
||||
Reference in New Issue
Block a user