diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index 9f03e2a..e83c56c 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -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)) diff --git a/backend/app/api/ordering_admin.py b/backend/app/api/ordering_admin.py index 16c3124..dd04acc 100644 --- a/backend/app/api/ordering_admin.py +++ b/backend/app/api/ordering_admin.py @@ -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) diff --git a/backend/app/api/throughput.py b/backend/app/api/throughput.py index 38e5fd2..ae708c2 100644 --- a/backend/app/api/throughput.py +++ b/backend/app/api/throughput.py @@ -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( diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 60a5ed5..0fc2fbd 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -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"), ) diff --git a/backend/app/models/mix_calculator.py b/backend/app/models/mix_calculator.py index 23a7b9a..f08c96e 100644 --- a/backend/app/models/mix_calculator.py +++ b/backend/app/models/mix_calculator.py @@ -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") diff --git a/backend/app/models/ordering.py b/backend/app/models/ordering.py index 60ecb7a..8f6f3b3 100644 --- a/backend/app/models/ordering.py +++ b/backend/app/models/ordering.py @@ -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" diff --git a/backend/app/models/raw_material.py b/backend/app/models/raw_material.py index 1d0f0bc..48452e0 100644 --- a/backend/app/models/raw_material.py +++ b/backend/app/models/raw_material.py @@ -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) diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py index e855329..23f5ca5 100644 --- a/backend/app/schemas/editor.py +++ b/backend/app/schemas/editor.py @@ -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) diff --git a/backend/app/schemas/mix_calculator.py b/backend/app/schemas/mix_calculator.py index 98886fd..b46ab17 100644 --- a/backend/app/schemas/mix_calculator.py +++ b/backend/app/schemas/mix_calculator.py @@ -26,6 +26,7 @@ class MixCalculatorSessionLineRead(BaseModel): required_kg: float mix_percentage: float unit: str + rounding_decimals: int = 2 sort_order: int diff --git a/backend/app/schemas/ordering.py b/backend/app/schemas/ordering.py index 4c74818..9840a51 100644 --- a/backend/app/schemas/ordering.py +++ b/backend/app/schemas/ordering.py @@ -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"} diff --git a/backend/app/schemas/throughput.py b/backend/app/schemas/throughput.py index 94ede52..c1220be 100644 --- a/backend/app/schemas/throughput.py +++ b/backend/app/schemas/throughput.py @@ -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 diff --git a/backend/app/services/mix_calculator_pdf.py b/backend/app/services/mix_calculator_pdf.py index 99df75a..f5cf911 100644 --- a/backend/app/services/mix_calculator_pdf.py +++ b/backend/app/services/mix_calculator_pdf.py @@ -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: diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py index 84453e8..05c1ffa 100644 --- a/backend/app/services/mix_calculator_service.py +++ b/backend/app/services/mix_calculator_service.py @@ -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"] diff --git a/backend/app/services/throughput_service.py b/backend/app/services/throughput_service.py index 0b7f570..27c5455 100644 --- a/backend/app/services/throughput_service.py +++ b/backend/app/services/throughput_service.py @@ -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, + } diff --git a/backend/app/services/xero_service.py b/backend/app/services/xero_service.py index 3e3aa66..a4aed9c 100644 --- a/backend/app/services/xero_service.py +++ b/backend/app/services/xero_service.py @@ -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: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index af1d27a..a0f23a7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86a70de..45c7a3e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.12", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.12", + "version": "0.1.18", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 20be484..f6e9421 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.14", + "version": "0.1.19", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/actions/tooltip.ts b/frontend/src/lib/actions/tooltip.ts new file mode 100644 index 0000000..7ae224a --- /dev/null +++ b/frontend/src/lib/actions/tooltip.ts @@ -0,0 +1,100 @@ +/** + * Modern hover/focus tooltip action. + * + * Renders a styled bubble appended to
with fixed positioning, so it is + * never clipped by an overflow:hidden ancestor (e.g. the topbar). Themes via the + * shared design tokens — see `.app-tooltip` in styles/theme.css. + * + * Usage: + * + */ +type Placement = 'top' | 'bottom'; + +type TooltipOptions = string | { label: string; placement?: Placement; delay?: number }; + +const GAP = 8; +const DEFAULT_DELAY = 300; + +function normalize(options: TooltipOptions): { label: string; placement: Placement; delay: number } { + if (typeof options === 'string') { + return { label: options, placement: 'bottom', delay: DEFAULT_DELAY }; + } + return { + label: options.label, + placement: options.placement ?? 'bottom', + delay: options.delay ?? DEFAULT_DELAY + }; +} + +export function tooltip(node: HTMLElement, options: TooltipOptions) { + let current = normalize(options); + let bubble: HTMLDivElement | null = null; + let showTimer: ReturnType{label}
-{detail}
-{label}
+{detail}
+